Compare commits

...
20 Commits
Author SHA1 Message Date
Assclaw 38169b881e Support original-language movie requests and fix repair dialog layout
Magent CI/CD / verify (push) Successful in 1m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-11 18:05:18 +12:00
Assclaw 52c85daae3 Add reviewed duplicate account consolidation and prevent duplicate imports
Magent CI/CD / verify (push) Successful in 11m16s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m33s
2026-09-11 16:38:53 +12:00
Assclaw df6fe58278 Normalize portal kinds before checking feature access
Magent CI/CD / verify (push) Successful in 11m13s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s
2026-09-11 12:32:51 +12:00
Assclaw ec0a866ef3 Add user feature permissions and unified account management
Magent CI/CD / verify (push) Canceled after 1m19s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-11 12:31:25 +12:00
Assclaw e2be8b3872 Remove changelog link from user menu
Magent CI/CD / verify (push) Successful in 11m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 5m59s
2026-09-10 17:12:30 +12:00
Assclaw b286ca3c42 Let users email personal reports on demand
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-10 16:15:39 +12:00
Assclaw 6e473fd0a7 Unify user management and add reviewed identity repairs
Magent CI/CD / verify (push) Canceled after 9m3s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-10 16:06:30 +12:00
Assclaw 9856c7fb90 Collapse request discovery after selecting a title
Magent CI/CD / verify (push) Successful in 11m0s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m11s
2026-09-10 15:47:50 +12:00
Assclaw df651eb312 Use admin quality defaults throughout the request pipeline
Magent CI/CD / verify (push) Successful in 10m49s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m6s
2026-09-10 15:20:23 +12:00
Assclaw 77f2c1b42a Add reviewed resolution for missing user identity links
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m29s
2026-09-10 15:02:04 +12:00
Assclaw b310e86f80 Align user directory headings with account rows
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m47s
2026-09-10 13:46:11 +12:00
Assclaw 747a330b19 Center the user directory and group management tools in a dialog
Magent CI/CD / verify (push) Canceled after 9m23s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-10 13:36:37 +12:00
Assclaw b0f8c89db7 Add Grizzlyflix newsletters with curated editions and weekly delivery
Magent CI/CD / verify (push) Successful in 10m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m14s
2026-09-09 23:42:52 +12:00
Assclaw e014baadc3 Preserve monthly recap destinations through sign-in
Magent CI/CD / verify (push) Successful in 10m36s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m26s
2026-09-09 22:42:58 +12:00
Assclaw 1979e02cde Add opt-in monthly email recaps with scheduling and delivery history
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-09 22:39:22 +12:00
Assclaw 333a799e21 Add personal monthly viewing reports and CSV exports
Magent CI/CD / verify (push) Successful in 10m58s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m42s
2026-09-09 16:25:35 +12:00
Assclaw 437836243c Remove page numbering from navigation and page links
Magent CI/CD / verify (push) Successful in 10m28s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m0s
2026-09-08 16:36:38 +12:00
Assclaw e7e4c9eff3 Fix viewing-history artwork and add transcode playback metrics
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m9s
2026-09-08 16:15:16 +12:00
Assclaw 12611a9819 Add admin review and confirmation of cross-service user IDs
Magent CI/CD / verify (push) Successful in 10m29s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 54s
2026-09-08 15:41:45 +12:00
Assclaw 2976145dd8 Add private Jellystat viewing stats to Magent beta
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m16s
2026-09-08 11:25:05 +12:00
105 changed files with 9563 additions and 378 deletions
+1 -2
View File
@@ -28,8 +28,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
- name: Install frontend dependencies
working-directory: frontend
+2
View File
@@ -23,6 +23,8 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
- Local database for speed and audit history.
- Users and access control (admin vs user, block access).
- Local account password changes via "My profile".
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
- Docker-first deployment for easy hosting.
## Quick start (Docker - primary)
+3
View File
@@ -161,6 +161,8 @@ def _load_current_user_from_token(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
user = normalize_user_auth_provider(user)
from .feature_access import permissions
features = permissions(user)
if request is not None:
ip = _extract_client_ip(request)
@@ -168,6 +170,7 @@ def _load_current_user_from_token(
upsert_user_activity(user["username"], ip, user_agent)
return {
"features": features,
"username": user["username"],
"email": user.get("email"),
"role": user["role"],
+118
View File
@@ -0,0 +1,118 @@
"""Jellystat API adapter. Credentials and raw history never leave the backend."""
import asyncio
import json
import re
from datetime import datetime
import httpx
from .base import ApiClient
class JellystatError(Exception):
pass
class HistoryLimitError(JellystatError):
pass
def same_user_id(left, right) -> bool:
return bool(left and right) and str(left).replace("-", "").lower() == str(right).replace("-", "").lower()
class JellystatClient(ApiClient):
PAGE_SIZE = 200
MAX_PAGES = 50
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
async def _read(self, client: httpx.AsyncClient, method: str, path: str, **kwargs):
try:
response = await client.request(method, f"{self.base_url}{path}",
headers={"x-api-token": self.api_key}, **kwargs)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise JellystatError("Jellystat did not return a valid response") from exc
async def test_connection(self) -> dict:
# This protected endpoint confirms API authentication without returning user data.
async with httpx.AsyncClient(timeout=10.0) as client:
result = await self._read(client, "GET", "/api/getLibraries")
if not isinstance(result, list):
raise JellystatError("Jellystat returned an unexpected library response")
return {"connected": True}
async def check_user_ids(self, user_ids: list[str]) -> dict:
"""Read metadata for known identities; never scan everyone's playback history."""
if not self.configured():
return {user_id: {"state": "not_configured"} for user_id in user_ids}
results = {user_id: {"state": "unavailable"} for user_id in user_ids}
semaphore = asyncio.Semaphore(6)
async with httpx.AsyncClient(timeout=8.0) as client:
async def check(user_id):
if not re.fullmatch(r"[a-f0-9]{32}", user_id):
return
async with semaphore:
try:
response = await client.post(f"{self.base_url}/api/getUserDetails",
headers={"x-api-token": self.api_key}, json={"userid": user_id})
if response.status_code == 404 or (response.status_code == 200 and not response.content.strip()):
results[user_id] = {"state": "missing"}
return
response.raise_for_status()
row = response.json()
if row is None:
results[user_id] = {"state": "missing"}
elif isinstance(row, dict) and same_user_id(row.get("Id"), user_id):
results[user_id] = {"state": "matched", "id": user_id, "name": str(row.get("Name") or "")[:200]}
except (httpx.HTTPError, ValueError):
pass
try:
async with asyncio.timeout(25):
await asyncio.gather(*(check(user_id) for user_id in user_ids))
except TimeoutError:
pass
return results
async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
raise JellystatError("Invalid linked Jellyfin identity")
# Only fixed, user-scoped endpoints are used. Never pass browser search/filters through.
filters = json.dumps([{"field": "ActivityDateInserted", "min": start.isoformat(), "max": end.isoformat()}])
try:
async with asyncio.timeout(30):
async with httpx.AsyncClient(timeout=10.0) as client:
libraries = await self._read(client, "GET", "/api/getLibraries")
if not isinstance(libraries, list) or any(not isinstance(row, dict) for row in libraries):
raise JellystatError("Jellystat returned an unexpected library response")
history = []
for page in range(1, self.MAX_PAGES + 1):
payload = await self._read(client, "POST", "/api/getUserHistory",
json={"userid": user_id}, params={"page": page, "size": self.PAGE_SIZE,
"sort": "ActivityDateInserted", "desc": "true", "filters": filters})
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
raise JellystatError("Jellystat returned an unexpected history response")
rows = payload["results"]
try:
pages = int(payload["pages"])
except (KeyError, TypeError, ValueError) as exc:
raise JellystatError("Jellystat did not return history pagination") from exc
if pages < 0 or (pages == 0 and rows) or len(rows) > self.PAGE_SIZE:
raise JellystatError("Jellystat returned invalid history pagination")
if pages > self.MAX_PAGES:
raise HistoryLimitError("Select a shorter period to view this history")
for row in rows:
if not isinstance(row, dict) or not same_user_id(row.get("UserId"), user_id):
raise JellystatError("Jellystat returned history for an unexpected account")
history.extend(rows)
if page >= pages:
return history, libraries
if not rows:
raise JellystatError("Jellystat returned incomplete history")
except TimeoutError as exc:
raise JellystatError("Jellystat took too long to return history") from exc
raise HistoryLimitError("Select a shorter period to view this history")
+5
View File
@@ -258,6 +258,11 @@ class Settings(BaseSettings):
jellyseerr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
)
jellystat_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
)
jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_KEY")
jellyfin_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
)
+65 -7
View File
@@ -187,6 +187,41 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
def init_db() -> None:
with _connect() as conn:
conn.execute("""CREATE TABLE IF NOT EXISTS user_duplicate_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT, kept_user_id INTEGER NOT NULL,
archive_json TEXT NOT NULL, repaired_by TEXT NOT NULL, repaired_at TEXT NOT NULL)""")
conn.execute("""CREATE TABLE IF NOT EXISTS user_feature_permissions (
user_id INTEGER NOT NULL, feature TEXT NOT NULL, enabled INTEGER NOT NULL,
PRIMARY KEY(user_id, feature))""")
conn.execute("""
CREATE TABLE IF NOT EXISTS jellyfin_user_links (
source TEXT NOT NULL, local_user_id INTEGER NOT NULL, jellyfin_user_id TEXT NOT NULL,
PRIMARY KEY (source, local_user_id), UNIQUE (source, jellyfin_user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS user_identity_confirmations (
local_user_id INTEGER PRIMARY KEY,
jellyfin_server_id TEXT NOT NULL,
jellyfin_user_id TEXT NOT NULL,
jellyfin_source TEXT NOT NULL,
seerr_source TEXT NOT NULL,
seerr_user_id INTEGER NOT NULL,
confirmed_at TEXT NOT NULL,
confirmed_by TEXT NOT NULL,
UNIQUE (jellyfin_server_id, jellyfin_user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS user_identity_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_user_id INTEGER NOT NULL,
before_json TEXT NOT NULL,
after_json TEXT NOT NULL,
repaired_at TEXT NOT NULL,
repaired_by TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS request_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -714,11 +749,20 @@ def init_db() -> None:
conn.execute("PRAGMA optimize")
except sqlite3.OperationalError:
pass
from .services.recap_store import init_schema as init_recap_schema
init_recap_schema(conn)
from .services.newsletter_store import init_schema as init_newsletter_schema
init_newsletter_schema(conn)
conn.execute("""CREATE TRIGGER IF NOT EXISTS delete_user_feature_permissions
AFTER DELETE ON users BEGIN
DELETE FROM user_feature_permissions WHERE user_id = OLD.id;
END""")
_backfill_auth_providers()
ensure_admin_user()
_backfill_request_repairs()
def start_request_repair(tracking: Dict[str, Any]) -> None:
"""Persist the new collection cycle before a managed file is removed."""
with _connect() as conn:
@@ -966,10 +1010,15 @@ def create_user(
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> None:
username = str(username).strip()
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
normalized_email = _normalize_stored_email(email)
with _connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if any(str(row[0]).strip().casefold() == username.casefold()
for row in conn.execute("SELECT username FROM users")):
raise sqlite3.IntegrityError("A normalized username already exists")
conn.execute(
"""
INSERT INTO users (
@@ -1020,10 +1069,15 @@ def create_user_if_missing(
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> bool:
username = str(username).strip()
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
normalized_email = _normalize_stored_email(email)
with _connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if any(str(row[0]).strip().casefold() == username.casefold()
for row in conn.execute("SELECT username FROM users")):
return False
cursor = conn.execute(
"""
INSERT OR IGNORE INTO users (
@@ -1085,6 +1139,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY id
""",
(username,),
).fetchone()
@@ -1282,6 +1337,7 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) ->
conn.execute(
"""
UPDATE users SET jellyseerr_user_id = ? WHERE username = ? COLLATE NOCASE
AND id NOT IN (SELECT local_user_id FROM user_identity_confirmations)
""",
(jellyseerr_user_id, username),
)
@@ -3789,21 +3845,23 @@ def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[st
]
def get_portal_overview() -> Dict[str, Any]:
def get_portal_overview(kind: Optional[str] = None) -> Dict[str, Any]:
with _connect() as conn:
kind_rows = conn.execute(
"""
SELECT kind, COUNT(*)
FROM portal_items
WHERE (? IS NULL OR kind = ?)
GROUP BY kind
"""
""", (kind, kind)
).fetchall()
status_rows = conn.execute(
"""
SELECT status, COUNT(*)
FROM portal_items
WHERE (? IS NULL OR kind = ?)
GROUP BY status
"""
""", (kind, kind)
).fetchall()
request_workflow_rows = conn.execute(
"""
@@ -3812,12 +3870,12 @@ def get_portal_overview() -> Dict[str, Any]:
COALESCE(workflow_media_status, ''),
COUNT(*)
FROM portal_items
WHERE kind = 'request'
WHERE kind = 'request' AND (? IS NULL OR kind = ?)
GROUP BY workflow_request_status, workflow_media_status
"""
""", (kind, kind)
).fetchall()
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items WHERE (? IS NULL OR kind = ?)", (kind, kind)).fetchone()
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments c JOIN portal_items i ON i.id = c.item_id WHERE (? IS NULL OR i.kind = ?)", (kind, kind)).fetchone()
request_workflow: Dict[str, Dict[str, int]] = {}
for row in request_workflow_rows:
request_status = str(row[0] or "")
+36
View File
@@ -0,0 +1,36 @@
"""Live account permissions. Invite access uses the existing users column."""
from .db import _connect
FEATURES = ("stats", "requests", "new_requests", "issues", "invites")
def permissions(user: dict) -> dict[str, bool]:
if user.get("role") == "admin":
return dict.fromkeys(FEATURES, True)
values = dict.fromkeys(FEATURES, True)
values["invites"] = bool(user.get("invite_management_enabled", False))
with _connect() as conn:
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
JOIN users u ON u.id = p.user_id WHERE u.username = ? COLLATE NOCASE""",
(user.get("username", ""),)).fetchall()
values.update({key: bool(enabled) for key, enabled in rows if key in FEATURES and key != "invites"})
return values
def update_permissions(changes: dict[str, bool], username: str | None = None) -> int:
if not changes or any(key not in FEATURES or type(value) is not bool for key, value in changes.items()):
raise ValueError("Choose valid features with true or false values")
with _connect() as conn:
conn.execute("BEGIN IMMEDIATE")
users = conn.execute("SELECT id FROM users WHERE role != 'admin'" +
(" AND username = ? COLLATE NOCASE" if username is not None else ""),
(username,) if username is not None else ()).fetchall()
for (user_id,) in users:
for feature, enabled in changes.items():
if feature == "invites":
conn.execute("UPDATE users SET invite_management_enabled = ? WHERE id = ?", (int(enabled), user_id))
else:
conn.execute("""INSERT INTO user_feature_permissions(user_id, feature, enabled) VALUES (?, ?, ?)
ON CONFLICT(user_id, feature) DO UPDATE SET enabled = excluded.enabled""",
(user_id, feature, int(enabled)))
return len(users)
+75
View File
@@ -0,0 +1,75 @@
from fastapi import Depends, HTTPException, Request
from .auth import get_current_user, get_current_user_event_stream
from .db import get_portal_item
def check(user: dict, *features: str) -> None:
access = user.get("features") or {}
if user.get("role") == "admin":
return
if not any(access.get(feature, False) for feature in features):
raise HTTPException(status_code=403, detail="This feature is disabled for your account")
def require_stats(user: dict = Depends(get_current_user)) -> dict:
check(user, "stats")
return user
def require_invites(user: dict = Depends(get_current_user)) -> dict:
check(user, "invites")
return user
def require_request_access(request: Request, user: dict = Depends(get_current_user)) -> None:
path = request.url.path.rstrip("/")
if path.endswith("/search") and "/actions/" not in path:
# The issue picker uses the same media search; creation is checked separately.
check(user, "new_requests", "issues")
elif path.endswith(("/create", "/request-options")):
check(user, "new_requests")
elif path.endswith(("/issue-options", "/replacement-options", "/actions/replace", "/actions/search-missing", "/actions/repair-subtitles")):
check(user, "issues")
else:
check(user, "requests")
async def require_portal_access(request: Request, user: dict = Depends(get_current_user)) -> None:
if user.get("role") == "admin":
return
path = request.url.path.rstrip("/")
access = user.get("features", {})
if access.get("requests") and access.get("issues") and access.get("new_requests"):
return
if "/issues" in path:
check(user, "issues")
elif path.endswith("/requests") or path.endswith("/pipeline"):
check(user, "requests")
elif "item_id" in request.path_params:
try:
item = get_portal_item(int(request.path_params["item_id"]))
except (ValueError, TypeError):
item = None
if not item:
raise HTTPException(status_code=404, detail="Item not found")
check(user, "requests" if item.get("kind") == "request" else "issues")
elif path.endswith("/items") and request.method == "POST":
payload = await request.json()
kind = str(payload.get("kind") or "").strip().lower() if isinstance(payload, dict) else ""
check(user, "new_requests" if not kind or kind == "request" else "issues")
elif path.endswith(("/items", "/overview")) and request.query_params.get("kind"):
kind = request.query_params["kind"].strip().lower()
if not kind:
check(user, "requests")
check(user, "issues")
else:
check(user, "requests" if kind == "request" else "issues")
else:
# Unfiltered lists/overview can include both kinds.
check(user, "requests")
check(user, "issues")
def require_request_stream(user: dict = Depends(get_current_user_event_stream)) -> dict:
check(user, "requests")
return user
+12
View File
@@ -27,8 +27,14 @@ from .routers.site import router as site_router
from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .routers.operations import router as operations_router
from .routers.insights import router as insights_router
from .routers.identities import router as identities_router
from .routers.recaps import router as recaps_router
from .routers.newsletters import router as newsletters_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.email_recaps import run_email_recap_loop
from .services.newsletters import run_newsletter_loop
from .services.operation_progress import (
begin_operation,
finish_operation,
@@ -265,6 +271,8 @@ async def startup() -> None:
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
_launch_background_task("db-cleanup", run_daily_db_cleanup)
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
_launch_background_task("email-recaps", run_email_recap_loop)
_launch_background_task("newsletters", run_newsletter_loop)
logger.info("startup complete")
@@ -280,3 +288,7 @@ app.include_router(site_router)
app.include_router(events_router)
app.include_router(portal_router)
app.include_router(operations_router)
app.include_router(insights_router)
app.include_router(identities_router)
app.include_router(recaps_router)
app.include_router(newsletters_router)
+31 -3
View File
@@ -1,3 +1,4 @@
from ..feature_access import permissions, update_permissions
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import asyncio
@@ -132,6 +133,7 @@ def _optional_recipient_email(value: object) -> Optional[str]:
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
SENSITIVE_KEYS = {
"jellystat_api_key",
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
"magent_notify_email_smtp_password",
@@ -150,6 +152,7 @@ SENSITIVE_KEYS = {
}
URL_SETTING_KEYS = {
"jellystat_base_url",
"magent_application_url",
"magent_api_url",
"magent_proxy_base_url",
@@ -172,6 +175,8 @@ NOTIFICATION_URL_SETTING_KEYS = {
}
SETTING_KEYS: List[str] = [
"jellystat_base_url",
"jellystat_api_key",
"magent_application_url",
"magent_application_port",
"magent_api_url",
@@ -1195,7 +1200,7 @@ async def list_users_summary() -> Dict[str, Any]:
username = user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
results.append({**user, "stats": stats})
results.append({**user, "features": permissions(user), "stats": stats})
return {"users": results}
@router.get("/users/{username}")
@@ -1205,7 +1210,7 @@ async def get_user_summary(username: str) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
@router.get("/users/id/{user_id}")
@@ -1215,7 +1220,7 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
@router.post("/users/{username}/block")
@@ -2118,3 +2123,26 @@ async def remove_invite(invite_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="Invite not found")
logger.warning("Admin deleted invite: invite_id=%s", invite_id)
return {"status": "ok", "deleted": True, "invite_id": invite_id}
@router.put("/users/features/bulk")
async def bulk_feature_permissions(payload: Dict[str, Any]) -> dict:
try:
updated = update_permissions(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"updated": updated, "scope": "non-admin-users"}
@router.put("/users/{username}/features")
async def user_feature_permissions(username: str, payload: Dict[str, Any]) -> dict:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.get("role") == "admin":
raise HTTPException(status_code=400, detail="Administrators always have all features")
try:
update_permissions(payload, username)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"features": permissions(get_user_by_username(username))}
+7 -1
View File
@@ -1,3 +1,4 @@
from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import logging
@@ -757,6 +758,11 @@ async def jellyfin_login(
save_jellyfin_users_cache(users)
except Exception:
pass
from ..services.jellyfin_identity import link_user
jellyfin_id = client._extract_user_id(auth_response)
if jellyfin_id:
link_user(canonical_username, jellyfin_id, runtime.jellyfin_base_url)
sync_jellyfin_password_state(canonical_username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map:
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
@@ -1228,7 +1234,7 @@ async def update_profile_email(payload: dict, current_user: dict = Depends(get_c
return {"status": "ok", "email": email}
@router.get("/profile/invites")
@router.get("/profile/invites", dependencies=[Depends(require_invites)])
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
username = str(current_user.get("username") or "").strip()
if not username:
+19 -3
View File
@@ -9,7 +9,9 @@ from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from ..feature_guards import require_request_stream, check
from ..feature_access import permissions
from ..db import get_user_by_username
from . import requests as requests_router
router = APIRouter(prefix="/events", tags=["events"])
@@ -76,7 +78,7 @@ async def events_stream(
request: Request,
recent_days: int = 90,
recent_stage: str = "all",
user: Dict[str, Any] = Depends(get_current_user_event_stream),
user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
@@ -91,6 +93,13 @@ async def events_stream(
if await request.is_disconnected():
break
try:
account = get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
break
check({**account, "features": permissions(account)}, "requests")
except HTTPException:
break
now = time.monotonic()
sent_any = False
@@ -148,7 +157,7 @@ async def events_stream(
async def request_events_stream(
request_id: str,
request: Request,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
request_id = str(request_id).strip()
if not request_id:
@@ -164,6 +173,13 @@ async def request_events_stream(
if await request.is_disconnected():
break
try:
account = get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
break
check({**account, "features": permissions(account)}, "requests")
except HTTPException:
break
now = time.monotonic()
sent_any = False
+99
View File
@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import require_admin
from ..services.identity_review import confirm_identities, review_identities, resolve_identity, repair_identity
from ..services.duplicate_accounts import repair_duplicates
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
class Confirmation(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
user_ids: list[int] = Field(min_length=1, max_length=3000)
@field_validator("user_ids")
@classmethod
def unique_positive_ids(cls, value):
if any(user_id <= 0 for user_id in value) or len(set(value)) != len(value):
raise ValueError("Choose unique positive user IDs")
return value
@router.get("")
async def review(response: Response):
response.headers["Cache-Control"] = "no-store"
report, _, _ = await review_identities()
return report
@router.post("/confirm")
async def confirm(payload: Confirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await confirm_identities(payload.revision, payload.user_ids, admin)
class Resolution(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: int = Field(gt=0, strict=True)
jellyfin_user_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class ResolutionConfirmation(Resolution):
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
@router.post("/resolve/check")
async def check_resolution(payload: Resolution, response: Response):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id)
@router.post("/resolve/confirm")
async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
class RepairResolution(Resolution):
create_seerr: bool = Field(default=False, strict=True)
class RepairConfirmation(RepairResolution):
revision: str = Field(pattern=r'^[a-f0-9]{64}$')
@router.post('/repair/check')
async def check_repair(payload: RepairResolution, response: Response):
response.headers['Cache-Control'] = 'no-store'
return await repair_identity(payload.user_id, payload.jellyfin_user_id, create_seerr=payload.create_seerr)
@router.post('/repair/confirm')
async def confirm_repair(payload: RepairConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers['Cache-Control'] = 'no-store'
return await repair_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin, payload.create_seerr)
class DuplicateCheck(BaseModel):
model_config = ConfigDict(extra='forbid')
user_id: int = Field(gt=0, strict=True)
keep_id: int | None = Field(default=None, gt=0, strict=True)
class DuplicateConfirmation(DuplicateCheck):
keep_id: int = Field(gt=0, strict=True)
revision: str = Field(pattern=r'^[a-f0-9]{64}$')
@router.post('/duplicates/check')
async def check_duplicates(payload: DuplicateCheck, response: Response):
response.headers['Cache-Control'] = 'no-store'
return await repair_duplicates(payload.user_id, payload.keep_id)
@router.post('/duplicates/confirm')
async def confirm_duplicates(payload: DuplicateConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers['Cache-Control'] = 'no-store'
return await repair_duplicates(payload.user_id, payload.keep_id, payload.revision, admin)
+78
View File
@@ -0,0 +1,78 @@
from ..feature_guards import require_stats
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import get_current_user
from ..clients.jellystat import HistoryLimitError, JellystatError
from ..services.insights import get_insights
from ..services.insights_artwork import get_artwork
from ..services.monthly_reports import get_monthly_report, report_csv
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/insights", tags=["insights"], dependencies=[Depends(require_stats)])
class MonthlyReportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
month: str | None = Field(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$")
async def monthly_data(user: dict, month: str | None) -> dict:
try:
return await get_monthly_report(user, month)
except ValueError as exc:
raise HTTPException(422, "Choose the current month or one of the previous 23 months.") from exc
except HistoryLimitError as exc:
raise HTTPException(422, "This report exceeds Jellystat's history limit. No partial report has been generated.") from exc
except JellystatError as exc:
raise HTTPException(502, "Your monthly report is temporarily unavailable. Please try again shortly.") from exc
@router.get("/reports/monthly")
async def monthly_report(query: Annotated[MonthlyReportQuery, Query()], response: Response,
user: dict = Depends(get_current_user)) -> dict:
response.headers["Cache-Control"] = "no-store"
return await monthly_data(user, query.month)
@router.get("/reports/monthly.csv")
async def monthly_export(query: Annotated[MonthlyReportQuery, Query()], user: dict = Depends(get_current_user)):
report = await monthly_data(user, query.month)
if report["state"] != "ready":
raise HTTPException(409, "Connect Jellystat and link your viewing account before downloading a report.")
return Response(report_csv(report), media_type="text/csv; charset=utf-8", headers={
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
"Content-Disposition": f'attachment; filename="magent-monthly-report-{report["month"]}.csv"'})
@router.get("/artwork/{item_id}")
async def artwork(item_id: str, token: Annotated[str, Query(max_length=100)], user: dict = Depends(get_current_user)):
content, media_type = await get_artwork(user, get_runtime_settings(), item_id, token)
return Response(content=content, media_type=media_type,
headers={"Cache-Control": "private, max-age=600", "Vary": "Cookie, Authorization", "X-Content-Type-Options": "nosniff"})
class InsightsQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
days: int = 30
@field_validator("days")
@classmethod
def supported_period(cls, value: int) -> int:
if value not in {7, 30, 90, 365}:
raise ValueError("Choose 7, 30, 90 or 365 days")
return value
@router.get("")
async def dashboard(query: Annotated[InsightsQuery, Query()], response: Response,
user: dict = Depends(get_current_user)) -> dict:
response.headers["Cache-Control"] = "no-store"
try:
return await get_insights(user, query.days)
except HistoryLimitError as exc:
raise HTTPException(status_code=422, detail="There is too much history for this period. Choose a shorter period.") from exc
except JellystatError as exc:
raise HTTPException(status_code=502, detail="Your viewing stats are temporarily unavailable. Please try again shortly.") from exc
+191
View File
@@ -0,0 +1,191 @@
import time
from datetime import datetime, timezone
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import Field, field_validator
from ..auth import get_current_user, require_admin
from ..runtime import get_runtime_settings
from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
from .recaps import StrictPayload, Preference, RecapSettings, TokenAction, no_cache
router = APIRouter(tags=['newsletters'], dependencies=[Depends(no_cache)])
class Settings(StrictPayload):
enabled: bool
weekday: int = Field(ge=0, le=6)
hour: int = Field(ge=0, le=23)
limit_titles: int = Field(ge=1, le=24)
public_url: str = Field(max_length=500)
intro: str = Field(default='', max_length=2000)
revision: int = Field(ge=1)
_url = field_validator('public_url')(RecapSettings.origin_only.__func__)
class NewDraft(StrictPayload):
days: Literal[7, 14, 30] = 7
class Selection(StrictPayload):
id: str = Field(pattern=r'^[a-f0-9]{32}$')
selected: bool
featured: bool
class Version(StrictPayload):
revision: int = Field(ge=1)
class EditionUpdate(Version):
subject: str = Field(min_length=1, max_length=150)
intro: str = Field(default='', max_length=2000)
titles: list[Selection] = Field(max_length=60)
@field_validator('subject')
@classmethod
def subject_line(cls, value):
value = value.strip()
if not value or any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError('Use a single, non-empty subject line.')
return value
class Test(Version):
request_id: UUID
class Publish(Version):
send_at: datetime | None = None
def fail(exc):
if isinstance(exc, service.NewsletterError):
raise HTTPException(exc.status, exc.detail) from exc
if isinstance(exc, store.Conflict):
raise HTTPException(429 if 'five minutes' in str(exc) else 409, str(exc)) from exc
raise HTTPException(502, str(exc) if isinstance(exc, catalog.CatalogError) else 'Jellyfin took too long to prepare this edition. Please try again.') from exc
@router.get('/profile/newsletters')
def preference(user: dict = Depends(get_current_user)):
try:
return service.preferences(user)
except service.NewsletterError as exc:
fail(exc)
@router.put('/profile/newsletters')
async def set_preference(payload: Preference, user: dict = Depends(get_current_user)):
try:
if payload.enabled:
return await service.subscribe(user)
store.disable(service.account_for(user)['id'])
return service.preferences(user)
except service.NewsletterError as exc:
fail(exc)
@router.post('/newsletter-subscription/check')
def check_token(payload: TokenAction):
try:
return service.token_action(payload.token, payload.action)
except service.NewsletterError as exc:
fail(exc)
@router.post('/newsletter-subscription/confirm')
def confirm_token(payload: TokenAction):
try:
return service.token_action(payload.token, payload.action, apply=True)
except service.NewsletterError as exc:
fail(exc)
@router.get('/admin/newsletters')
def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = Depends(require_admin)):
ready, detail = service.delivery_ready()
return {'settings': store.public_settings(), 'ready': ready, 'detail': detail,
'playback_url': service.playback_url(get_runtime_settings()), **store.overview(offset)}
@router.put('/admin/newsletters')
def settings(payload: Settings, user: dict = Depends(require_admin)):
try:
ready, detail = service.delivery_ready(payload.public_url)
if payload.enabled and not ready:
raise service.NewsletterError(detail)
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
except (service.NewsletterError, store.Conflict) as exc:
fail(exc)
@router.post('/admin/newsletters/drafts', status_code=201)
async def create_draft(payload: NewDraft, user: dict = Depends(require_admin)):
try:
return await service.create_draft(user, payload.days)
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
fail(exc)
@router.get('/admin/newsletters/editions/{identity}')
def edition(identity: UUID, user: dict = Depends(require_admin)):
try:
return service.require_edition(identity.hex)
except service.NewsletterError as exc:
fail(exc)
@router.put('/admin/newsletters/editions/{identity}')
def update_edition(identity: UUID, payload: EditionUpdate, user: dict = Depends(require_admin)):
try:
return store.update_edition(identity.hex, payload.revision, payload.subject, payload.intro,
[entry.model_dump() for entry in payload.titles], time.time())
except store.Conflict as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/preview')
async def preview(identity: UUID, payload: Version, user: dict = Depends(require_admin)):
try:
return await service.preview(identity.hex, payload.revision)
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/test', status_code=202)
def send_test(identity: UUID, payload: Test, user: dict = Depends(require_admin)):
try:
return service.queue_test(user, identity.hex, payload.revision, str(payload.request_id))
except (service.NewsletterError, store.Conflict) as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/publish', status_code=202)
def publish(identity: UUID, payload: Publish, user: dict = Depends(require_admin)):
try:
return service.publish(identity.hex, payload.revision, payload.send_at)
except (service.NewsletterError, store.Conflict) as exc:
fail(exc)
@router.post('/admin/newsletters/editions/{identity}/cancel')
def cancel(identity: UUID, user: dict = Depends(require_admin)):
try:
service.require_edition(identity.hex)
return store.cancel(identity.hex, time.time())
except service.NewsletterError as exc:
fail(exc)
@router.get('/admin/newsletters/artwork/{identity}')
async def artwork(identity: UUID, user: dict = Depends(require_admin)):
runtime = get_runtime_settings()
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise HTTPException(404, 'Artwork unavailable')
content = await catalog.poster(runtime, identity.hex)
if not content:
raise HTTPException(404, 'Artwork unavailable')
return Response(content=content, media_type='image/jpeg', headers={'Cache-Control': 'private, max-age=600'})
+6 -4
View File
@@ -1,4 +1,5 @@
from __future__ import annotations
from ..feature_guards import require_portal_access
import logging
import re
@@ -33,7 +34,7 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"}
@@ -654,10 +655,11 @@ async def _notify(
@router.get("/overview")
async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
mine = count_portal_items(mine_username=str(current_user.get("username") or ""))
async def portal_overview(kind: Optional[str] = None, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
kind = _normalize_choice(kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True)
mine = count_portal_items(kind=kind, mine_username=str(current_user.get("username") or ""))
return {
"overview": get_portal_overview(),
"overview": get_portal_overview(kind) if kind else get_portal_overview(),
"my_items": mine,
}
+143
View File
@@ -0,0 +1,143 @@
from datetime import datetime, timezone
from typing import Literal
from urllib.parse import urlsplit
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import get_current_user, require_admin
from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store
def no_cache(response: Response):
response.headers["Cache-Control"] = "no-store"
router = APIRouter(tags=["email-recaps"], dependencies=[Depends(no_cache)])
class StrictPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
class Preference(StrictPayload):
enabled: bool
automatic_monthly: bool | None = Field(default=None, strict=True)
class RecapSettings(StrictPayload):
enabled: bool
day: int = Field(ge=1, le=28)
hour: int = Field(ge=0, le=23)
public_url: str = Field(max_length=500)
@field_validator("public_url")
@classmethod
def origin_only(cls, value: str) -> str:
value = value.strip().rstrip('/')
if not value:
return value
try:
url = urlsplit(value)
port = url.port
except ValueError as exc:
raise ValueError("Enter the public Magent address, such as https://magent.example.com.") from exc
if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password
or url.path or url.query or url.fragment or any(char.isspace() or ord(char) < 33 for char in value)
or any(char in value for char in '<>"\\') or (port is not None and port < 1)):
raise ValueError("Enter a http(s) Magent address without a path, credentials or query.")
return value
class TestEmail(StrictPayload):
month: str | None = Field(default=None, pattern=r"^[0-9]{4}-[0-9]{2}$")
request_id: UUID
class TokenAction(StrictPayload):
token: str = Field(min_length=40, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
action: Literal["confirm", "unsubscribe"]
def error(exc: recaps.RecapError):
raise HTTPException(exc.status, exc.detail) from exc
@router.get("/profile/email-recaps")
def preferences(user: dict = Depends(require_stats)) -> dict:
try:
return recaps.preferences(user)
except recaps.RecapError as exc:
error(exc)
@router.put("/profile/email-recaps")
async def preference(payload: Preference, user: dict = Depends(require_stats)) -> dict:
try:
if payload.enabled:
return await recaps.subscribe(user, payload.automatic_monthly)
store.disable(recaps.current_account(user)["id"])
return recaps.preferences(user)
except recaps.RecapError as exc:
error(exc)
@router.post("/email-recaps/check")
def check_token(payload: TokenAction) -> dict:
try:
return recaps.token_action(payload.token, payload.action)
except recaps.RecapError as exc:
error(exc)
@router.post("/email-recaps/confirm")
def apply_token(payload: TokenAction) -> dict:
try:
return recaps.token_action(payload.token, payload.action, apply=True)
except recaps.RecapError as exc:
error(exc)
@router.get("/admin/email-recaps")
def overview(offset: int = Query(default=0, ge=0), user: dict = Depends(require_admin)) -> dict:
ready, detail = recaps.delivery_ready()
months = recaps.month_periods(None, datetime.now(timezone.utc))["available_months"][1:]
return {"settings": store.settings(), "ready": ready, "detail": detail, "months": months,
"worker_enabled": recaps.worker_enabled(), **store.history(offset=offset)}
@router.put("/admin/email-recaps")
def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dict:
if payload.enabled:
# Validate against the proposed URL without writing any partial settings.
ready, detail = recaps.smtp_email_config_ready()
runtime = recaps.get_runtime_settings()
if not payload.public_url or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail)
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
@router.get("/admin/email-recaps/preview")
async def preview(month: str | None = Query(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$"), user: dict = Depends(require_admin)) -> dict:
try:
return await recaps.preview(user, month)
except recaps.RecapError as exc:
error(exc)
@router.post("/admin/email-recaps/test", status_code=202)
def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
try:
return recaps.queue_test(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
error(exc)
@router.post('/profile/email-recaps/send', status_code=202)
def email_personal_report(payload: TestEmail, user: dict = Depends(require_stats)) -> dict:
try:
return recaps.queue_personal(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
error(exc)
+29 -15
View File
@@ -1,3 +1,5 @@
from ..services.request_language import language_info, original_profile, is_original_profile
from ..feature_guards import require_request_access
from typing import Any, Dict, List, Optional, Tuple
import asyncio
import httpx
@@ -65,7 +67,7 @@ from ..services.snapshot import (
jellyfin_item_matches_request,
)
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)])
CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
@@ -562,7 +564,6 @@ async def _resolve_request_destination(
runtime: Any,
seerr: JellyseerrClient,
media_type: str,
requested_profile_id: Optional[int] = None,
) -> Dict[str, Any]:
if media_type == "tv":
collector_name = "Sonarr"
@@ -601,16 +602,17 @@ async def _resolve_request_destination(
raise HTTPException(status_code=409, detail=f"{collector_name} has no quality profiles available.")
profile_ids = {int(item["id"]) for item in profiles}
default_profile_id = _quality_profile_id(server.get("activeProfileId"))
# Magent's administrator default is authoritative for every new request.
# An unset default inherits Seerr's profile; a stale default must be repaired.
default_profile_id = configured_profile_id
if default_profile_id is None:
default_profile_id = _quality_profile_id(server.get("activeProfileId"))
if default_profile_id not in profile_ids:
default_profile_id = configured_profile_id if configured_profile_id in profile_ids else profiles[0]["id"]
selected_profile_id = requested_profile_id if requested_profile_id is not None else default_profile_id
if selected_profile_id not in profile_ids:
raise HTTPException(
status_code=400,
detail=f"The selected quality profile is not available in {collector_name}.",
status_code=409,
detail=f"The default quality profile is not available in {collector_name}. Ask an administrator to select a valid default in Admin settings.",
)
selected_profile_id = default_profile_id
roots = _normalize_request_roots(root_payload)
root_folder = str(server.get("activeDirectory") or "").strip()
@@ -3126,6 +3128,7 @@ async def request_options(
"backdropPath": details.get("backdropPath") or details.get("backdrop_path"),
"seasons": seasons,
"existingRequestId": existing_request_id,
"originalLanguage": language_info(details),
},
"destination": {
"collector": destination["collector"],
@@ -3164,10 +3167,6 @@ async def create_request(
raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
raw_profile_id = payload.get("profileId")
profile_id = _quality_profile_id(raw_profile_id) if raw_profile_id is not None else None
if raw_profile_id is not None and (profile_id is None or profile_id <= 0):
raise HTTPException(status_code=400, detail="profileId must be a positive integer")
raw_is_4k = payload.get("is4k")
if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
raise HTTPException(status_code=400, detail="is4k must be true or false")
@@ -3230,7 +3229,16 @@ async def create_request(
detail=f"Season selection is not available for this series: {invalid_seasons}",
)
destination = await _resolve_request_destination(runtime, client, media_type, profile_id)
language = language_info(details)
accept_original = payload.get("acceptOriginalLanguage", False)
if not isinstance(accept_original, bool):
raise HTTPException(400, "The language choice must be true or false.")
if accept_original and not language:
raise HTTPException(409, "The original language could not be verified. Reload this title.")
destination = await _resolve_request_destination(runtime, client, media_type)
if accept_original and media_type == "movie":
destination["profile_id"] = await original_profile(
RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), destination["profile_id"])
try:
created = await client.create_request(
@@ -3265,7 +3273,8 @@ async def create_request(
"request_created",
"Create request",
"ok",
f"{media_type} request created from discovery by {user.get('username')}.",
f"{media_type} request created from discovery by {user.get('username')}."
+ (f" Original-language audio accepted ({language['code']})." if accept_original else ""),
)
return {
@@ -3448,6 +3457,11 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
raise HTTPException(status_code=400, detail="Radarr not configured")
target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
if current_profile_id and current_profile_id != target_profile_id:
profiles = await client.get_quality_profiles()
current = next((p for p in profiles if p.get("id") == current_profile_id), {})
if is_original_profile(current):
target_profile_id = current_profile_id
profile_message = None
movie_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and movie_id and current_profile_id != target_profile_id:
+9
View File
@@ -11,6 +11,7 @@ from ..clients.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
@@ -118,6 +119,11 @@ async def services_status() -> Dict[str, Any]:
)
)
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
# Optional analytics must not degrade the media pipeline when not configured.
if jellystat.configured():
services.append(await _check("Jellystat", True, jellystat.test_connection))
overall = "up"
if any(s.get("status") == "down" for s in services):
overall = "down"
@@ -141,6 +147,9 @@ async def test_service(service: str) -> Dict[str, Any]:
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower()
if service_key == "jellystat":
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
return await _check("Jellystat", jellystat.configured(), jellystat.test_connection)
checks = {
"seerr": (
"Seerr",
+191
View File
@@ -0,0 +1,191 @@
"""Reviewed consolidation of same-name Jellyfin accounts, entirely within Magent."""
import asyncio
import json
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..feature_access import FEATURES
from . import identity_review as review
from .jellyfin_identity import source_key
NAME_REFERENCES = {
'signup_invites': ('created_by',),
'portal_items': ('created_by_username', 'assignee_username'),
'portal_comments': ('author_username',),
'portal_item_activity': ('actor_username',),
'platform_issues': ('reporter_username',),
'platform_issue_events': ('author_username',),
'requests_cache': ('requested_by', 'requested_by_norm'),
}
def account_state(conn, ids):
conn.row_factory = db.sqlite3.Row
placeholders = ','.join('?' for _ in ids)
return {table: [dict(row) for row in conn.execute(
f'SELECT * FROM {table} WHERE {column} IN ({placeholders}) ORDER BY {column}', ids)]
for table, column in [('users', 'id'), ('user_feature_permissions', 'user_id'),
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
def build_preview(report, local, runtime, state, user_id, keep_id=None):
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
if not target:
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
group = [row for row in report['rows'] if review.name_key(row['user']['username']) == review.name_key(target['user']['username'])]
ids = {row['user']['id'] for row in group}
if len(ids) < 2:
raise HTTPException(409, 'No same-name duplicate group remains. Run the account check again.')
jf_id = target['candidate_jellyfin_id']
source = source_key(runtime.jellyfin_base_url)
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
recommended = min(ids, key=lambda identity: (identity not in owned, identity))
keep_id = keep_id or recommended
if keep_id not in ids:
raise HTTPException(400, 'Choose an account from this duplicate group to keep.')
problems = []
if any(report['services'].get(service) != 'available' for service in ('jellyfin', 'seerr', 'jellystat')):
problems.append('Restore all three media-service connections before consolidating accounts.')
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
if target['jellyfin'] and review.name_key(target['jellyfin']['name']) != review.name_key(target['user']['username']):
problems.append('The current Jellyfin name does not match this duplicate group.')
if len([account for account in report['jellyfin_users'] if review.name_key(account['name']) == review.name_key(target['user']['username'])]) != 1:
problems.append('The name must identify exactly one current Jellyfin account.')
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
for row in group:
if row['user']['role'] != 'user' or row['user']['auth_provider'] != 'jellyfin':
problems.append('Only non-admin Jellyfin sign-in accounts can use duplicate consolidation.')
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
for link in local['links']:
if link['local_user_id'] in ids:
if link['source'] != source or review.normalized_id(link['jellyfin_user_id']) != jf_id:
problems.append('A duplicate has a different saved Jellyfin identity or server.')
elif link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id:
problems.append('Another account or orphaned reservation owns this Jellyfin identity.')
for item in local['confirmations']:
if item['local_user_id'] in ids:
if (item['jellyfin_server_id'] != report['server_id'] or item['jellyfin_user_id'] != jf_id
or item['jellyfin_source'] != source or item['seerr_source'] != source_key(runtime.jellyseerr_base_url)
or item['seerr_user_id'] != seerr_id):
problems.append('A saved confirmation points to a different identity or server.')
elif item['jellyfin_server_id'] == report['server_id'] and item['jellyfin_user_id'] == jf_id:
problems.append('Another confirmation owns this identity.')
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
problems.append('An account outside this same-name group also claims the identity.')
accounts = [account for account in state['users'] if account['id'] in ids]
kept = next(account for account in accounts if account['id'] == keep_id)
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
overrides.get((account['id'], key), True) for account in accounts) for key in FEATURES}
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
try:
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
except (ValueError, TypeError, AttributeError):
expiry = kept['expires_at']
problems.append('An expiry date is invalid. Correct it before repairing duplicates.')
proposed = {'id': keep_id, 'username': target['jellyfin']['name'] if target['jellyfin'] else kept['username'],
'email': kept['email'], 'profile_id': kept['profile_id'], 'expires_at': expiry,
'is_blocked': any(account['is_blocked'] for account in accounts),
'auto_search_enabled': all(account['auto_search_enabled'] for account in accounts),
'features': features, 'jellyfin_user_id': jf_id, 'seerr_user_id': seerr_id}
public = [{key: account.get(key) for key in ('id', 'username', 'email', 'profile_id', 'last_login_at', 'created_at')}
for account in accounts]
return {'accounts': public, 'keep_id': keep_id, 'recommended_id': recommended, 'proposed': proposed,
'issues': sorted(set(problems)), 'can_confirm': not problems,
'revision': review.digest([report['revision'], state, keep_id, proposed])}
async def prepare(user_id, keep_id=None):
report, local, runtime = await review.review_identities()
target = next((row for row in local['users'] if row['id'] == user_id), None)
if not target:
raise HTTPException(404, 'Account not found.')
ids = sorted(row['id'] for row in local['users'] if review.name_key(row['username']) == review.name_key(target['username']))
with closing(db._connect()) as conn:
conn.execute('BEGIN')
if review.digest(review.snapshot(conn)) != review.digest(local):
raise HTTPException(409, 'Accounts changed during the check. Preview again.')
state = account_state(conn, ids)
return build_preview(report, local, runtime, state, user_id, keep_id), report, local, runtime, state
def consolidate(preview, report, local, runtime, state, admin):
if not preview['can_confirm']:
raise HTTPException(409, 'This duplicate group cannot be consolidated. Review the listed conflicts.')
ids = sorted(account['id'] for account in state['users'])
keep = preview['keep_id']
removed = [identity for identity in ids if identity != keep]
values = preview['proposed']
now = datetime.now(timezone.utc).isoformat()
with closing(db._connect()) as conn, conn:
conn.execute('BEGIN IMMEDIATE')
if (review.digest(review.snapshot(conn)) != review.digest(local)
or review.digest(account_state(conn, ids)) != review.digest(state)
or review.config_digest(review.get_runtime_settings()) != review.config_digest(runtime)):
raise HTTPException(409, 'Accounts, permissions or subscriptions changed. Preview again before saving.')
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
if conn.execute(f"SELECT 1 FROM {table} WHERE user_id IN ({','.join('?' for _ in ids)}) AND state='sending'", ids).fetchone():
raise HTTPException(409, 'An account email is currently being sent. Wait for delivery to finish, then preview again.')
archive = {**state, 'links': [entry for entry in local['links'] if entry['local_user_id'] in ids],
'confirmations': [entry for entry in local['confirmations'] if entry['local_user_id'] in ids],
'proposed': values}
conn.execute('INSERT INTO user_duplicate_repairs(kept_user_id,archive_json,repaired_by,repaired_at) VALUES(?,?,?,?)',
(keep, json.dumps(archive, sort_keys=True), admin['username'], now))
names = {account['username'] for account in state['users']}
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for table, columns in NAME_REFERENCES.items():
if table not in tables:
continue
for column in columns:
old_values = {review.name_key(name) for name in names} if column == 'requested_by_norm' else names
for name in old_values:
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if review.name_key(row['username']) == review.name_key(values['username'])]
for entry in activity:
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
for entry in activity:
conn.execute('''INSERT INTO user_activity(username,ip,user_agent,first_seen_at,last_seen_at,hit_count)
VALUES(?,?,?,?,?,?) ON CONFLICT(username,ip,user_agent) DO UPDATE SET
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at),
hit_count=hit_count+excluded.hit_count''', (values['username'], entry['ip'], entry['user_agent'], entry['first_seen_at'], entry['last_seen_at'], entry['hit_count']))
for name in names:
conn.execute('DELETE FROM password_reset_tokens WHERE username=? COLLATE NOCASE', (name,))
for identity in removed:
# Duplicate subscriptions are not inherited. Preserve delivery history and cancel outstanding work.
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
conn.execute(f"UPDATE {table} SET state='cancelled',detail='Duplicate account consolidated.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (identity,))
conn.execute(f'UPDATE {table} SET user_id=? WHERE user_id=?', (keep, identity))
conn.execute('DELETE FROM jellyfin_user_links WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM users WHERE id=?', (identity,))
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
conn.execute('''UPDATE users SET username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
values['features']['invites'], values['expires_at'], last_login, keep))
for feature, enabled in values['features'].items():
if feature != 'invites':
conn.execute('''INSERT INTO user_feature_permissions VALUES(?,?,?)
ON CONFLICT(user_id,feature) DO UPDATE SET enabled=excluded.enabled''', (keep, feature, int(enabled)))
conn.execute('''INSERT INTO jellyfin_user_links VALUES(?,?,?) ON CONFLICT(source,local_user_id)
DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id''', (source_key(runtime.jellyfin_base_url), keep, values['jellyfin_user_id']))
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (keep,))
conn.execute('''INSERT INTO user_identity_confirmations VALUES(?,?,?,?,?,?,?,?)''',
(keep, report['server_id'], values['jellyfin_user_id'], source_key(runtime.jellyfin_base_url),
source_key(runtime.jellyseerr_base_url), values['seerr_user_id'], now, admin['username']))
return {'kept_user_id': keep, 'consolidated': len(removed), 'repaired_at': now}
async def repair_duplicates(user_id, keep_id=None, revision=None, admin=None):
preview, report, local, runtime, state = await prepare(user_id, keep_id)
if revision is None:
return preview
if revision != preview['revision']:
raise HTTPException(409, 'The duplicate-account preview changed. Preview again before saving.')
return await asyncio.to_thread(consolidate, preview, report, local, runtime, state, admin)
+33
View File
@@ -0,0 +1,33 @@
"""Shared claim and completion rules for the two durable email queues."""
import uuid
def queue_table(table: str) -> str:
if table not in {"email_recap_deliveries", "newsletter_deliveries"}:
raise ValueError("Unknown email queue")
return table
def claim(conn, table: str, now: float) -> dict | None:
table = queue_table(table)
conn.execute(f"""UPDATE {table} SET state='unknown', detail='Delivery interrupted after sending began; check the mail server.', updated_at=?
WHERE state='sending' AND lease_until<?""", (now, now))
conn.execute(f"""UPDATE {table} SET state=CASE WHEN attempts>=3 THEN 'failed' ELSE 'retry' END,
next_attempt_at=?, updated_at=?, detail='Email preparation interrupted.'
WHERE state='preparing' AND lease_until<?""", (now, now, now))
row = conn.execute(f"""SELECT * FROM {table} WHERE state IN ('queued', 'retry') AND next_attempt_at<=?
ORDER BY created_at, id LIMIT 1""", (now,)).fetchone()
if not row:
return None
claim_id = uuid.uuid4().hex
conn.execute(f"""UPDATE {table} SET state='preparing', claim=?, lease_until=?,
attempts=attempts+1, updated_at=? WHERE id=?""", (claim_id, now + 1800, now, row["id"]))
return dict(conn.execute(f"SELECT * FROM {table} WHERE id=?", (row["id"],)).fetchone())
def finish(conn, table: str, delivery: dict, state: str, detail: str, now: float, delay: int = 0):
table = queue_table(table)
conn.execute(f"""UPDATE {table} SET state=?, detail=?, updated_at=?, next_attempt_at=?, lease_until=NULL
WHERE id=? AND claim=? AND state IN ('preparing', 'sending')""",
(state, detail, now, now + delay, delivery["id"], delivery["claim"]))
+269
View File
@@ -0,0 +1,269 @@
"""Opt-in monthly recaps. Scheduling and delivery are safe to run in multiple workers."""
import asyncio
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from urllib.parse import urlencode
from .. import db
from ..clients.jellystat import HistoryLimitError, JellystatError
from ..runtime import get_runtime_settings
from . import recap_email as mail, recap_store as store
from .invite_email import smtp_email_config_ready
from .jellyfin_identity import linked_user_id, source_key
from .monthly_reports import get_monthly_report, month_periods
logger = logging.getLogger(__name__)
class RecapError(Exception):
def __init__(self, detail: str, status: int = 409):
self.detail, self.status = detail, status
super().__init__(detail)
def worker_enabled() -> bool:
return os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() != "false"
def delivery_ready() -> tuple[bool, str]:
config = store.settings()
if not config["public_url"]:
return False, "Set the public Magent address for email links."
ready, detail = smtp_email_config_ready()
if not ready:
return False, detail
runtime = get_runtime_settings()
if not runtime.jellystat_base_url or not runtime.jellystat_api_key:
return False, "Connect Jellystat to generate viewing recaps."
if not worker_enabled():
return False, "Background automation is paused on this server."
return True, "Email delivery is configured."
def current_account(user: dict) -> dict:
account = db.get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
raise RecapError("This account cannot receive viewing recaps.", 403)
return account
def binding_matches(sub: dict, account: dict) -> bool:
runtime = get_runtime_settings()
return bool(account and not account.get("is_blocked") and not account.get("is_expired")
and mail.valid_email(account.get("email"))
and account["email"].strip().casefold() == sub["email"].strip().casefold()
and source_key(runtime.jellyfin_base_url) == sub["identity_source"]
and linked_user_id(account["username"], runtime.jellyfin_base_url) == sub["identity_id"])
def active_subscription(account: dict) -> dict | None:
sub = store.subscription(account["id"])
if sub and sub["state"] != "off" and not binding_matches(sub, account):
store.disable(account["id"])
sub = store.subscription(account["id"])
return sub
def preferences(user: dict) -> dict:
account = current_account(user)
sub = active_subscription(account)
config = store.settings()
ready, detail = delivery_ready()
runtime = get_runtime_settings()
linked = bool(linked_user_id(account["username"], runtime.jellyfin_base_url))
email = mail.valid_email(account.get("email"))
state = sub["state"] if sub else "off"
if state == "pending" and sub["confirmation_expires"] <= time.time():
state = "expired"
return {"state": state, "email": account.get("email"), "can_subscribe": ready and linked and bool(email),
"detail": detail if not ready else "Save a valid email address in your profile." if not email else
"Your Jellyfin account needs a saved identity link." if not linked else "Your monthly story, in your inbox.",
"automatic_monthly": bool(sub["automatic_monthly"]) if sub else False,
"can_send": ready and state == "enabled", "deliveries": store.personal_history(account["id"]),
"schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
"resend_after": (sub["requested_at"] + 300) if sub else None}
async def subscribe(user: dict, automatic_monthly: bool | None = None) -> dict:
account = current_account(user)
preference = preferences(user)
automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly
if preference["state"] == "enabled":
store.set_automatic(account['id'], automatic)
return preferences(user)
if not preference["can_subscribe"]:
raise RecapError(preference["detail"])
config = store.settings()
runtime = get_runtime_settings()
try:
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
linked_user_id(account["username"], runtime.jellyfin_base_url), time.time(), automatic)
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
rendered = mail.render_confirmation(account["username"], url)
try:
await asyncio.to_thread(mail.send_email, account["email"].strip(), rendered,
mail.message_id(uuid.uuid4().hex, config["public_url"]))
except mail.DeliveryError as exc:
raise RecapError("Could not confirm delivery of the verification email. Check your inbox; you can request another in five minutes.", 502) from exc
return {**preferences(user), "message": "Check your inbox and confirm within 24 hours to enable personal report emails."}
def token_action(token: str, action: str, *, apply: bool = False) -> dict:
sub = store.token_subscription(token, action)
if not sub:
raise RecapError("This email link is invalid or has already been used. Open Profile to manage your recaps.", 410)
if action == "unsubscribe":
if apply:
store.disable(sub["user_id"])
return {"action": action, "state": "off" if apply or sub["state"] == "off" else "ready"}
account = db.get_user_by_id(sub["user_id"])
if (sub["state"] != "pending" or sub["confirmation_expires"] <= time.time()
or not binding_matches(sub, account)):
raise RecapError("This confirmation has expired or your account details changed. Request a new link from Profile.", 410)
if apply and not store.confirm(sub, time.time()):
raise RecapError("This confirmation is no longer available. Request a new link from Profile.", 410)
return {"action": action, "state": "enabled" if apply else "ready"}
def completed_month(month: str | None) -> str:
try:
period = month_periods(month, datetime.now(timezone.utc))
except ValueError as exc:
raise RecapError(str(exc), 422) from exc
if period["is_partial"]:
raise RecapError("Choose a completed month for an email recap.", 422)
return period["month"]
async def preview(user: dict, month: str | None) -> dict:
account = current_account(user)
selected = completed_month(month)
config = store.settings()
if not config["public_url"]:
raise RecapError("Save the public Magent address before previewing an email.")
try:
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
except HistoryLimitError as exc:
raise RecapError("This report exceeds Jellystat's history limit. No partial recap was generated.", 422) from exc
except (JellystatError, TimeoutError) as exc:
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")}
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
account = current_account(user)
ready, detail = delivery_ready()
if not ready:
raise RecapError(detail)
sub = active_subscription(account)
if not sub or sub["state"] != "enabled":
raise RecapError("Turn on email recaps and confirm your email in Profile before sending a personal test.")
selected = completed_month(month)
try:
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()["public_url"], time.time())
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
return {"id": delivery_id, "message": "Test queued for your confirmed email. Check delivery history for the result."}
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
account = db.get_user_by_id(delivery["user_id"])
from ..feature_access import permissions
if not account or not permissions(account)["stats"]:
raise mail.DeliveryCancelled()
sub = active_subscription(account) if account else None
config = store.settings()
ready, _ = delivery_ready()
if (not ready or not sub or sub["state"] != "enabled" or sub["version"] != delivery["subscription_version"]
or sub["email"] != delivery["email"] or not binding_matches(sub, account)
or config["public_url"] != delivery["public_url"]
or (delivery["kind"] == "scheduled" and (not config["enabled"] or not sub["automatic_monthly"]))):
raise mail.DeliveryCancelled()
return account, sub
async def process_delivery(delivery: dict) -> None:
state, detail, delay = "failed", "Could not prepare the recap. Check the report and email settings.", 0
try:
account, sub = eligible_delivery(delivery)
report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
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")
def before_data():
eligible_delivery(delivery)
if not store.begin_sending(delivery, time.time()):
raise mail.DeliveryCancelled()
await asyncio.to_thread(mail.send_email, delivery["email"], rendered,
mail.message_id(delivery["id"], delivery["public_url"]), before_data)
state, detail = "sent", "Accepted by the mail server."
except mail.DeliveryCancelled:
state, detail = "cancelled", "Consent, account details or email configuration changed."
except HistoryLimitError:
state, detail = "failed", "Jellystat's history limit was reached. No partial recap was sent."
except (JellystatError, TimeoutError):
state, detail = "retry", "Viewing history is temporarily unavailable."
except mail.DeliveryError as exc:
state, detail = exc.state, exc.detail
except Exception as exc:
# Do not expose provider errors or private report content in history/logs.
logger.error("recap delivery error id=%s type=%s", delivery["id"], type(exc).__name__)
row = store.read_one("SELECT state FROM email_recap_deliveries WHERE id=?", (delivery["id"],))
if row and row["state"] == "sending":
state, detail = "unknown", "Delivery outcome is unknown; check the mail server."
if state == "retry":
if delivery["attempts"] >= 3:
state, detail = "failed", detail + " Stopped after three attempts."
else:
delay = 300 if delivery["attempts"] == 1 else 1800
store.finish(delivery, state, detail, time.time(), delay)
async def run_once() -> None:
store.enqueue_due(datetime.now(timezone.utc))
for _ in range(10):
delivery = store.claim_delivery(time.time())
if not delivery:
break
await process_delivery(delivery)
async def run_email_recap_loop() -> None:
while True:
try:
await run_once()
except Exception as exc:
logger.error("email recap worker failed type=%s", type(exc).__name__)
await asyncio.sleep(30)
def queue_personal(user: dict, month: str | None, request_id: str) -> dict:
account = current_account(user)
ready, detail = delivery_ready()
if not ready:
raise RecapError(detail)
sub = active_subscription(account)
if not sub or sub['state'] != 'enabled':
raise RecapError('Confirm your profile email in email preferences before emailing a report.')
try:
selected = month_periods(month, datetime.now(timezone.utc))['month']
except ValueError as exc:
raise RecapError(str(exc), 422) from exc
try:
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()['public_url'], time.time(), 'on_demand')
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
return {'id': delivery_id, 'message': 'Your report is queued for your confirmed profile email. Delivery status appears below.'}
+369
View File
@@ -0,0 +1,369 @@
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
import asyncio
import copy
import hashlib
import json
import re
import sqlite3
import httpx
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .jellyfin_identity import source_key
MAX_USERS = 3000
CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
"jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
def normalized_id(value):
value = str(value or "").lower().replace("-", "")
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def name_key(value):
return str(value or "").strip().casefold()
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def config_digest(runtime):
return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
def snapshot(conn):
conn.row_factory = sqlite3.Row
return {
"users": [dict(row) for row in conn.execute(
"SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
"links": [dict(row) for row in conn.execute(
"SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
"confirmations": [dict(row) for row in conn.execute(
"SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
# Detect settings changes between checking services and committing the reviewed links.
"config_revision": digest([tuple(row) for row in conn.execute(
"SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
}
def read_snapshot():
with closing(db._connect()) as conn:
return snapshot(conn)
async def jellyfin_directory(runtime):
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return {"state": "not_configured", "users": []}
try:
users, server = await asyncio.gather(client.get_users(), client.get_system_info())
server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
raise ValueError()
clean = []
seen = set()
for user in users:
user_id = normalized_id(user.get("Id"))
if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
raise ValueError()
seen.add(user_id)
clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
except Exception:
return {"state": "unavailable", "users": []}
async def seerr_directory(runtime):
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.base_url or not client.api_key:
return {"state": "not_configured", "users": []}
try:
users = []
seen = set()
expected_total = None
async with asyncio.timeout(20):
for skip in range(0, MAX_USERS, 100):
page = await client.get_users(take=100, skip=skip)
total = page["pageInfo"]["results"]
batch = page["results"]
if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
raise ValueError()
if expected_total is not None and total != expected_total:
raise ValueError()
expected_total = total
for user in batch:
user_id = user.get("id")
if type(user_id) is not int or user_id <= 0 or user_id in seen:
raise ValueError()
seen.add(user_id)
users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
"jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
if len(users) == total:
return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
if len(batch) != 100 or len(users) > total:
raise ValueError()
except Exception:
pass
return {"state": "unavailable", "users": []}
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None, repair=False):
original = local
selections = selections or {}
if repair:
local = copy.deepcopy(local)
for user in local['users']:
if user['id'] in selections:
user['jellyseerr_user_id'] = None
local['links'] = [link for link in local['links'] if not (
link['local_user_id'] in selections and link['source'] == source_key(runtime.jellyfin_base_url))]
local['confirmations'] = [item for item in local['confirmations'] if item['local_user_id'] not in selections]
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
jf_by_name = defaultdict(list)
for row in jellyfin["users"]:
jf_by_name[name_key(row["name"])].append(row["id"])
seerr_by_id = {row["id"]: row for row in seerr["users"]}
seerr_by_jf = defaultdict(list)
for row in seerr["users"]:
if row["jellyfin_id"]:
seerr_by_jf[row["jellyfin_id"]].append(row)
current_source = source_key(runtime.jellyfin_base_url)
seerr_source = source_key(runtime.jellyseerr_base_url)
links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
for user in local["users"]:
local_by_name[name_key(user["username"])].append(user["id"])
if user["jellyseerr_user_id"] is not None:
local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
rows = []
for user in local["users"]:
issues = []
saved = confirmed.get(user["id"])
linked = links.get(user["id"])
stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
by_name = jf_by_name.get(name_key(user["username"]), [])
basis = "none"
candidate = None
if saved:
candidate = saved["jellyfin_user_id"]
basis = "confirmed_id"
if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
issues.append("The confirmed server or Seerr connection has changed.")
elif linked:
candidate, basis = linked, "stored_jellyfin_id"
elif stored_seerr and stored_seerr["jellyfin_id"]:
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
candidate, basis = by_name[0], "suggested_username"
if repair and user['id'] in selections and any(
item['local_user_id'] == user['id'] and item['jellyfin_server_id'] != jellyfin.get('server_id')
for item in original['confirmations']):
issues.append('The Jellyfin server changed. A server migration requires separate review.')
if user["id"] in selections:
chosen = selections[user["id"]]
if saved and chosen != saved["jellyfin_user_id"]:
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
candidate, basis = chosen, "admin_selected"
if len(local_by_name[name_key(user["username"])]) > 1:
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
issues.append("Multiple Magent rows share the stored Seerr ID.")
if len(by_name) > 1:
issues.append("This name matches multiple distinct Jellyfin IDs.")
if candidate and by_name and candidate not in by_name:
issues.append("The stored ID and current Jellyfin username point to different accounts.")
if linked and candidate and linked != candidate:
issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
jf = jf_by_id.get(candidate)
if candidate and not jf and jellyfin["state"] == "available":
issues.append("The linked Jellyfin ID is absent from the current server.")
expected_seerr = seerr_by_jf.get(candidate, [])
if len(expected_seerr) > 1:
issues.append("Multiple Seerr users reference the same Jellyfin ID.")
if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
):
issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
issues.append("The stored Seerr ID has changed since confirmation.")
js = jellystat.get(candidate, {"state": "not_checked"})
rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
"stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
"basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
"can_confirm": False, "state": "unlinked"})
candidates = defaultdict(list)
for row in rows:
if row["candidate_jellyfin_id"]:
candidates[row["candidate_jellyfin_id"]].append(row)
for row in rows:
candidate = row["candidate_jellyfin_id"]
if len(candidates.get(candidate, [])) > 1:
row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
# Also protect IDs already reserved by a link/confirmation whose local user was deleted.
if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
if row["issues"]:
row["state"] = "conflict"
elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
row["state"] = "unavailable"
elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
row["state"] = "unlinked"
elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
row["state"] = "confirmed"
else:
row["state"] = "ready"
row["can_confirm"] = True
upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
"detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
"detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
"jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
"not_checked" if not jellystat else
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
"jellyfin_users": jellyfin["users"], "seerr_users": seerr["users"],
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
report["revision"] = digest([report, digest(original), config_digest(runtime), repair])
report["checked_at"] = datetime.now(timezone.utc).isoformat()
return report
async def review_identities(selections=None, repair=False):
runtime = await asyncio.to_thread(get_runtime_settings)
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
if len(local["users"]) > MAX_USERS:
raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
ids = {row["id"] for row in jf["users"]}
ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
ids.discard(None)
if len(ids) > MAX_USERS:
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
return build_report(local, jf, seerr, js, runtime, selections, repair), local, runtime
def save_confirmations(report, local, runtime, user_ids, admin, repair=False):
rows = {row["user"]["id"]: row for row in report["rows"]}
if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
now = datetime.now(timezone.utc).isoformat()
try:
with closing(db._connect()) as conn, conn:
conn.execute("BEGIN IMMEDIATE")
if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
for user_id in user_ids:
row = rows[user_id]
jf_id = row["jellyfin"]["id"]
seerr_id = row["seerr"][0]["id"]
conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
(source_key(runtime.jellyfin_base_url), user_id, jf_id))
conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_confirmations
(local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
seerr_id, now, admin["username"]))
if repair:
before_user = next(user for user in local['users'] if user['id'] == user_id)
before = {'seerr_user_id': before_user['jellyseerr_user_id'],
'links': [link for link in local['links'] if link['local_user_id'] == user_id],
'confirmation': next((item for item in local['confirmations'] if item['local_user_id'] == user_id), None)}
conn.execute("""UPDATE user_identity_confirmations SET jellyfin_server_id=?,jellyfin_user_id=?,
jellyfin_source=?,seerr_source=?,seerr_user_id=? WHERE local_user_id=?""",
(report['server_id'], jf_id, source_key(runtime.jellyfin_base_url),
source_key(runtime.jellyseerr_base_url), seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_repairs
(local_user_id,before_json,after_json,repaired_at,repaired_by) VALUES (?,?,?,?,?)""",
(user_id, json.dumps(before, sort_keys=True), json.dumps({
'jellyfin_server_id': report['server_id'], 'jellyfin_user_id': jf_id,
'seerr_user_id': seerr_id}, sort_keys=True), now, admin['username']))
except sqlite3.IntegrityError as exc:
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
return {"confirmed": len(user_ids), "confirmed_at": now}
async def confirm_identities(revision, user_ids, admin):
report, local, runtime = await review_identities()
if report["revision"] != revision:
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
if revision is not None:
if report["revision"] != revision:
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
return {"revision": report["revision"], "server_id": report["server_id"],
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
async def repair_identity(user_id, jellyfin_user_id, revision=None, admin=None, create_seerr=False):
report, local, runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
row = next(row for row in report['rows'] if row['user']['id'] == user_id)
importing = bool(create_seerr and row['state'] == 'unlinked' and row['jellyfin']
and not row['seerr'] and row['jellystat']['state'] == 'matched'
and report['services']['seerr'] == 'available')
if importing and any(name_key(account['name']) == name_key(row['jellyfin']['name'])
for account in report['seerr_users']):
importing = False
row['issues'].append('A Seerr account already has this name. Review its existing link before importing.')
report['revision'] = digest([report['revision'], create_seerr])
if revision is not None:
if report['revision'] != revision:
raise HTTPException(409, 'The repair preview changed. Check the selected account again.')
if importing:
if digest(await asyncio.to_thread(read_snapshot)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, 'Accounts or settings changed. Preview the repair again.')
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
try:
await client.post('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [jellyfin_user_id]})
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(502, 'The Seerr import could not be verified. Run a fresh check before trying again; an account may already have been imported.') from exc
# Upstream and SQLite cannot share a transaction. Reconcile using live IDs;
# never delete an imported account if the local save is blocked or interrupted.
refreshed, _, fresh_runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
if config_digest(fresh_runtime) != config_digest(runtime):
raise HTTPException(409, 'Seerr import completed but settings changed. Check accounts again before saving Magent links.')
try:
return await asyncio.to_thread(save_confirmations, refreshed, local, runtime, [user_id], admin, True)
except HTTPException as exc:
raise HTTPException(409, 'Seerr import completed, but Magent links could not be saved. Run another check to review the imported account. No account was deleted.') from exc
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin, True)
before = next(user for user in local['users'] if user['id'] == user_id)
linked = next((link['jellyfin_user_id'] for link in local['links'] if link['local_user_id'] == user_id
and link['source'] == source_key(runtime.jellyfin_base_url)), None)
row['can_confirm'] = row['can_confirm'] or importing
return {'revision': report['revision'], 'server_id': report['server_id'], 'row': row,
'action': 'import_seerr' if importing else 'repair_magent',
'before': {'jellyfin_user_id': linked, 'seerr_user_id': before['jellyseerr_user_id']},
'seerr_users': report['seerr_users'],
'scope': ('Import this single Jellyfin account into Seerr, then verify and save Magent links. Existing Seerr accounts stay unchanged.' if importing else 'Repair Magent links only. Jellyfin and Jellystat IDs and Seerr accounts stay unchanged.')}
+232
View File
@@ -0,0 +1,232 @@
import asyncio
import hashlib
import json
import math
import sqlite3
import time
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timedelta, timezone
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient, JellystatError
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user, linked_user_id
from .insights_artwork import item_id as artwork_item_id, with_artwork
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
HARDWARE = {"amf": "AMD AMF", "qsv": "Intel Quick Sync", "nvenc": "NVIDIA NVENC",
"v4l2m2m": "V4L2", "vaapi": "VAAPI", "videotoolbox": "Apple VideoToolbox", "rkmpp": "Rockchip MPP"}
HARDWARE_ENUM = {0: "none", 1: "amf", 2: "qsv", 3: "nvenc", 4: "v4l2m2m", 5: "vaapi", 6: "videotoolbox", 7: "rkmpp"}
def add_transcoding(row, duration, media_type, totals, hardware, audio_codecs):
# Jellystat can retain stale transcoding metadata after a switch to DirectPlay.
method = row.get("PlayMethod")
if method not in {"Transcode", "DirectStream"}:
return
info = row.get("TranscodingInfo")
if isinstance(info, str):
try:
info = json.loads(info)
except ValueError:
info = None
info = info if isinstance(info, dict) else {}
video_present = media_type in {"movie", "episode"} or bool(info.get("VideoCodec"))
if method == "Transcode" and video_present:
if info.get("IsVideoDirect") is False:
totals["video_minutes"] += duration
value = info.get("HardwareAccelerationType")
value = HARDWARE_ENUM.get(value) if type(value) is int else str(value or "").strip().lower()
if value in HARDWARE:
totals["hardware_video_minutes"] += duration
hardware[HARDWARE[value]] += duration
elif value == "none":
totals["software_video_minutes"] += duration
else:
totals["unknown_hardware_minutes"] += duration
elif info.get("IsVideoDirect") is not True:
totals["unknown_video_minutes"] += duration
if info.get("IsAudioDirect") is False:
totals["audio_minutes"] += duration
codec = str(info.get("AudioCodec") or "Unknown").upper()[:30]
audio_codecs[codec] += duration
elif info.get("IsAudioDirect") is not True:
totals["unknown_audio_minutes"] += duration
def _date(value) -> datetime:
try:
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
except (ValueError, TypeError) as exc:
raise JellystatError("Jellystat returned an invalid history date") from exc
def _duration(value) -> float:
try:
result = float(value or 0)
if not math.isfinite(result) or result < 0:
raise ValueError()
return result
except (ValueError, TypeError, OverflowError) as exc:
raise JellystatError("Jellystat returned an invalid playback duration") from exc
async def resolve_identity(user: dict, runtime) -> str | None:
identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
if identity:
return identity
if user.get("auth_provider") != "jellyfin":
return None
# Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
# Local accounts and email-prefix matches cannot claim a Jellyfin identity.
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return None
try:
users = await client.get_users()
except Exception as exc:
raise JellystatError("Could not resolve the linked Jellyfin account") from exc
matches = [entry for entry in users if isinstance(entry, dict)
and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
if len(matches) != 1 or not matches[0].get("Id"):
return None
await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
operator = "<" if end_exclusive else "<="
clause = f"julianday(created_at) >= julianday(?) AND julianday(created_at) {operator} julianday(?)"
params = [start.isoformat(), end.isoformat()]
if user.get("jellyseerr_user_id") is not None:
clause += " AND requested_by_id = ?"
params.append(user["jellyseerr_user_id"])
else:
clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
params.append(user["username"].strip().lower())
with closing(db._connect()) as conn, conn:
conn.row_factory = sqlite3.Row
counts = conn.execute(f"""SELECT COUNT(*) AS total,
COALESCE(SUM(media_type = 'movie'), 0) AS movies,
COALESCE(SUM(media_type = 'tv'), 0) AS tv,
COALESCE(SUM(status = 1), 0) AS pending,
COALESCE(SUM(status = 2), 0) AS approved,
COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
return {**dict(counts), "recent": [dict(row) for row in recent]}
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)
clients = defaultdict(float)
methods = defaultdict(float)
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
"unknown_hardware_minutes", "unknown_video_minutes", "unknown_audio_minutes"), 0.0)
hardware, audio_codecs = defaultdict(float), defaultdict(float)
titles = {}
movie_ids, episode_ids, seen = set(), set(), set()
recent = []
seconds = 0.0
for row in history:
row_id = str(row.get("Id") or "")
if not row_id:
raise JellystatError("Jellystat returned history without an activity ID")
if row_id in seen:
continue
seen.add(row_id)
date = _date(row.get("ActivityDateInserted"))
# Defend against older upstream versions ignoring the range filter.
if date < start or (date >= end if end_exclusive else date > end):
continue
duration = _duration(row.get("PlaybackDuration"))
if duration <= 0:
continue
item_id = str(row.get("NowPlayingItemId") or row_id)
episode_id = row.get("EpisodeId")
library_type = library_types.get(str(row.get("ParentId")), "")
media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
add_transcoding(row, duration / 60, media_type, transcoding, hardware, audio_codecs)
if media_type == "episode":
episode_ids.add(str(episode_id))
elif media_type == "movie":
movie_ids.add(item_id)
seconds += duration
daily_seconds[date.date().isoformat()] += duration
client = str(row.get("Client") or "Unknown player")[:200]
clients[client] += duration
method = str(row.get("PlayMethod") or "Unknown")
method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
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["minutes"] += duration / 60
title["plays"] += 1
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
"method": method, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
last_date = (end - timedelta(microseconds=1)).date() if end_exclusive and end > start else end.date()
count = (last_date - start.date()).days + 1
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
longest = run = 0
for day in daily:
run = run + 1 if day["date"] in active_days else 0
longest = max(longest, run)
current = 0
cursor = last_date if last_date.isoformat() in active_days else last_date - timedelta(days=1)
while cursor.isoformat() in active_days:
current += 1
cursor -= timedelta(days=1)
top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
for row in top:
row["minutes"] = round(row["minutes"], 1)
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},
"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])],
"transcoding": {**{name: round(value, 1) for name, value in transcoding.items()},
"hardware": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(hardware.items(), key=lambda pair: -pair[1])],
"audio_codecs": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(audio_codecs.items(), key=lambda pair: -pair[1])],
"gpu_busy_minutes": None},
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
async def get_insights(user: dict, days: int) -> dict:
runtime = await asyncio.to_thread(get_runtime_settings)
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
requests = await asyncio.to_thread(request_summary, user, start, end)
base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
"is_admin": user.get("role") == "admin", "summary": None}
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
if not client.configured():
return {**base, "state": "not_configured"}
identity = await resolve_identity(user, runtime)
if not identity:
return {**base, "state": "unlinked"}
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
runtime.jellyfin_base_url, identity, days)
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
return {**base, **with_artwork(cached[1], user, runtime)}
history, libraries = await client.get_user_history(identity, start, end)
data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
"period_start": start.isoformat(), "period_end": end.isoformat()}
for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
return {**base, **with_artwork(data, user, runtime)}
+98
View File
@@ -0,0 +1,98 @@
"""Private Jellyfin thumbnails for items returned in a user's own viewing history."""
import asyncio
import hashlib
import hmac
import re
import time
from collections import OrderedDict
import httpx
from fastapi import HTTPException
from ..config import settings
TOKEN_SECONDS = 3600
MAX_IMAGE_BYTES = 1024 * 1024
MAX_CACHE_BYTES = 16 * 1024 * 1024
_cache = OrderedDict()
_downloads = asyncio.Semaphore(6)
def item_id(value):
value = str(value or "").replace("-", "").lower()
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def source(runtime):
return hashlib.sha256(f"{runtime.jellyfin_base_url}|{runtime.jellyfin_api_key}".encode()).hexdigest()
def signature(user, runtime, media_id, expires):
message = f"insights-artwork\n{user['username']}\n{source(runtime)}\n{media_id}\n{expires}"
return hmac.new(settings.jwt_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
def with_artwork(data, user, runtime):
expires = int(time.time()) + TOKEN_SECONDS
recent = []
for play in data.get("recent", []):
row = {**play}
media_id = row.pop("artwork_item_id", None)
row["artwork_url"] = None
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
recent.append(row)
return {**data, "recent": recent}
def verify_artwork_token(user, runtime, media_id, token):
if not settings.jwt_secret or not re.fullmatch(r"[a-f0-9]{32}", media_id):
raise HTTPException(404, "Artwork unavailable")
if not re.fullmatch(r"[0-9]{1,12}\.[a-f0-9]{64}", token):
raise HTTPException(403, "Artwork link is invalid or expired")
try:
expires_text, supplied = token.split(".", 1)
expires = int(expires_text)
except (ValueError, TypeError):
raise HTTPException(403, "Artwork link is invalid or expired") from None
now = int(time.time())
if expires < now or expires > now + TOKEN_SECONDS or not hmac.compare_digest(supplied, signature(user, runtime, media_id, expires)):
raise HTTPException(403, "Artwork link is invalid or expired")
async def get_artwork(user, runtime, media_id, token):
verify_artwork_token(user, runtime, media_id, token)
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise HTTPException(404, "Artwork unavailable")
key = (source(runtime), media_id)
async with _downloads:
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
_cache.move_to_end(key)
return cached[1], cached[2]
try:
async with httpx.AsyncClient(timeout=8.0) as client:
async with client.stream("GET", f"{runtime.jellyfin_base_url.rstrip('/')}/Items/{media_id}/Images/Primary",
headers={"X-Emby-Token": runtime.jellyfin_api_key},
params={"maxWidth": 120, "maxHeight": 180, "quality": 85, "format": "Webp"}) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type not in {"image/jpeg", "image/png", "image/webp"}:
raise ValueError()
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > MAX_IMAGE_BYTES:
raise ValueError()
if not content:
raise ValueError()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(404, "Artwork unavailable") from exc
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
while _cache and (len(_cache) >= 128 or sum(len(value[1]) for value in _cache.values()) + len(content) > MAX_CACHE_BYTES):
_cache.popitem(last=False)
_cache[key] = (time.monotonic() + 600, bytes(content), content_type)
return bytes(content), content_type
+38
View File
@@ -0,0 +1,38 @@
"""Stable Jellyfin identities for private, user-scoped integrations."""
import hashlib
from contextlib import closing
from .. import db
def source_key(base_url: str | None) -> str:
return hashlib.sha256(str(base_url or "").strip().rstrip("/").encode()).hexdigest()
def linked_user_id(username: str, base_url: str | None) -> str | None:
user = db.get_user_by_username(username)
if not user or not base_url:
return None
with closing(db._connect()) as conn, conn:
row = conn.execute(
"SELECT jellyfin_user_id FROM jellyfin_user_links WHERE source = ? AND local_user_id = ?",
(source_key(base_url), user["id"]),
).fetchone()
return row[0] if row else None
def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> None:
"""Use only verified login or canonical Jellyfin user sync, never playback names."""
user = db.get_user_by_username(username)
if not user or not jellyfin_user_id or not base_url:
return
with closing(db._connect()) as conn, conn:
if conn.execute("SELECT 1 FROM user_identity_confirmations WHERE local_user_id = ?", (user["id"],)).fetchone():
# Reviewed identities are updated only through the admin confirmation workflow.
return
# A renamed or re-created account must not silently take over an existing identity.
conn.execute(
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
(source_key(base_url), user["id"], str(jellyfin_user_id)),
)
+5
View File
@@ -11,6 +11,7 @@ from ..db import (
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user
from .user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
@@ -68,6 +69,10 @@ async def sync_jellyfin_users() -> int:
set_user_jellyseerr_id(name, matched_id)
if matched_email:
set_user_email(name, matched_email)
if user.get("Id"):
local_user = get_user_by_username(name)
if local_user and local_user.get("auth_provider") == "jellyfin":
link_user(name, str(user["Id"]), runtime.jellyfin_base_url)
return imported
+149
View File
@@ -0,0 +1,149 @@
"""Personal calendar-month reports built from retained Jellystat history."""
import asyncio
import csv
import hashlib
import io
import re
import time
from datetime import datetime, timezone
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .insights import request_summary, resolve_identity, summarize
from .insights_artwork import with_artwork
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
MONTH_COUNT = 24
def shift_month(value: datetime, offset: int) -> datetime:
year, month = divmod(value.year * 12 + value.month - 1 + offset, 12)
return datetime(year, month + 1, 1, tzinfo=timezone.utc)
def month_periods(month: str | None, now: datetime) -> dict:
now = now.astimezone(timezone.utc)
this_month = shift_month(now, 0)
available = [shift_month(this_month, -offset).strftime("%Y-%m") for offset in range(MONTH_COUNT)]
selected = month if month is not None else available[1]
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", selected) or selected not in available:
raise ValueError("Choose the current month or one of the previous 23 months.")
start = datetime.strptime(selected, "%Y-%m").replace(tzinfo=timezone.utc)
calendar_end = shift_month(start, 1)
end = min(calendar_end, now)
previous_start = shift_month(start, -1)
partial = end < calendar_end
previous_end = min(previous_start + (end - start), start) if partial else start
return {"month": selected, "available_months": available, "timezone": "UTC",
"period_start": start.isoformat(), "period_end": end.isoformat(),
"is_partial": partial, "comparison_month": previous_start.strftime("%Y-%m"),
"comparison_start": previous_start.isoformat(), "comparison_end": previous_end.isoformat(),
"comparison_capped": partial and previous_start + (end - start) > start}
def change(current: float, previous: float) -> dict:
difference = round(current - previous, 1)
percent = round(difference / previous * 100, 1) if previous else 0.0 if not current else None
return {"current": current, "previous": previous, "difference": difference, "percent": percent}
async def get_monthly_report(user: dict, month: str | None = None) -> dict:
now = datetime.now(timezone.utc)
periods = month_periods(month, now)
runtime = await asyncio.to_thread(get_runtime_settings)
base = {**periods, "source": "Jellystat", "is_admin": user.get("role") == "admin", "summary": None}
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
if not client.configured():
return {**base, "state": "not_configured"}
identity = await resolve_identity(user, runtime)
if not identity:
return {**base, "state": "unlinked"}
# Cache playback only. Request ownership and request statuses are read afresh.
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
runtime.jellyfin_base_url, identity, periods["month"], now.strftime("%Y-%m"))
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
data = cached[1]
else:
history, libraries = await client.get_user_history(identity,
datetime.fromisoformat(periods["comparison_start"]), datetime.fromisoformat(periods["period_end"]))
current = summarize(history, libraries, datetime.fromisoformat(periods["period_start"]),
datetime.fromisoformat(periods["period_end"]), end_exclusive=True)
previous = summarize(history, libraries, datetime.fromisoformat(periods["comparison_start"]),
datetime.fromisoformat(periods["comparison_end"]), end_exclusive=True)
data = {**periods, **current, "previous_summary": previous["summary"], "updated_at": now.isoformat()}
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
requests, previous_requests = await asyncio.gather(
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["period_start"]),
datetime.fromisoformat(data["period_end"]), end_exclusive=True),
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["comparison_start"]),
datetime.fromisoformat(data["comparison_end"]), end_exclusive=True))
changes = {name: change(data["summary"][name], data["previous_summary"][name])
for name in ("minutes", "movies", "episodes", "plays", "active_days", "longest_streak")}
changes["requests"] = change(requests["total"], previous_requests["total"])
return {**base, **with_artwork(data, user, runtime), "state": "ready", "requests": requests,
"previous_requests": {name: value for name, value in previous_requests.items() if name != "recent"},
"changes": changes}
def report_csv(report: dict) -> str:
"""Export normalized data only; protect text cells from spreadsheet formulas."""
output = io.StringIO(newline="")
writer = csv.writer(output)
def row(*cells):
safe = []
for cell in cells:
if isinstance(cell, str) and re.match(r"^[\s\ufeff]*[=+\-@]", cell):
cell = "'" + cell
safe.append(cell)
writer.writerow(safe)
row("Magent monthly viewing report", report["month"])
row("Timezone", "UTC")
row("Period start (inclusive)", report["period_start"])
row("Period end (exclusive)", report["period_end"])
row("Report period", "Month to date" if report["is_partial"] else "Complete calendar month")
row("Comparison start (inclusive)", report["comparison_start"])
row("Comparison end (exclusive)", report["comparison_end"])
row("Generated at", report["updated_at"])
row("Data coverage", "Retained Jellystat history and requests available in Magent; request statuses are current.")
row()
row("Metric", "This period", "Previous period", "Difference", "Change (%)")
labels = {"minutes": "Minutes watched", "movies": "Distinct movies played", "episodes": "Distinct episodes played",
"plays": "Plays", "active_days": "Active days", "longest_streak": "Longest streak (days)", "requests": "Requests made"}
for name, label in labels.items():
value = report["changes"][name]
row(label, value["current"], value["previous"], value["difference"], value["percent"])
row()
row("Date (UTC)", "Minutes watched")
for day in report["daily"]:
row(day["date"], day["minutes"])
row()
row("Most watched title", "Media type", "Minutes", "Plays")
for title in report["top_titles"]:
row(title["title"], title["type"], title["minutes"], title["plays"])
for field, label in (("clients", "Player"), ("methods", "Streaming method")):
row()
row(label, "Playback minutes")
for entry in report[field]:
row(entry["name"], entry["minutes"])
row()
row("Transcoding", "Playback minutes")
for field, label in (("hardware_video_minutes", "GPU-assisted video"), ("audio_minutes", "Audio transcoding"),
("video_minutes", "Video transcoding"), ("software_video_minutes", "Software video"),
("unknown_hardware_minutes", "Video hardware not recorded"),
("unknown_video_minutes", "Video details not recorded"), ("unknown_audio_minutes", "Audio details not recorded")):
row(label, report["transcoding"][field])
row("GPU busy time", "Not recorded; audio/video playback durations can overlap.")
row()
row("Requests", "Count")
for field, label in (("movies", "Movies"), ("tv", "TV shows"), ("pending", "Pending"), ("approved", "Approved"), ("declined", "Declined")):
row(label, report["requests"][field])
return "\ufeff" + output.getvalue()
+202
View File
@@ -0,0 +1,202 @@
"""Bounded Jellyfin arrival snapshots, recipient access checks and email-safe posters."""
import asyncio
import hashlib
import io
import time
from collections import OrderedDict
from datetime import datetime, timezone
import httpx
from PIL import Image
from .insights_artwork import item_id
from .jellyfin_identity import source_key
MAX_ITEMS = 5000
PAGE_SIZE = 200
MAX_TITLES = 60
_posters = OrderedDict()
_poster_lock = asyncio.Semaphore(4)
class CatalogError(Exception):
pass
def date(value) -> datetime | None:
try:
result = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
except (ValueError, TypeError):
return None
async def get_json(client, runtime, path, params=None):
try:
response = await client.get(runtime.jellyfin_base_url.rstrip('/') + path,
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params=params)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise CatalogError('Jellyfin is temporarily unavailable. Please try again.') from exc
def group_arrivals(items: list[dict], start: datetime, end: datetime) -> list[dict]:
groups = {}
seen = set()
for row in items:
identity = item_id(row.get('Id'))
added = date(row.get('DateCreated'))
if (not identity or identity in seen or not added or not start <= added < end
or row.get('LocationType') == 'Virtual' or row.get('IsPlaceHolder')):
continue
kind = row.get('Type')
if kind not in {'Movie', 'Episode'}:
continue
parent = item_id(row.get('SeriesId')) if kind == 'Episode' else identity
if not parent:
continue
seen.add(identity)
title = str((row.get('SeriesName') if kind == 'Episode' else row.get('Name')) or '').strip()
if not title:
continue
entry = groups.setdefault(parent, {'id': parent, 'type': 'series' if kind == 'Episode' else 'movie',
'title': title[:250], 'year': row.get('ProductionYear') if kind == 'Movie' else None,
'overview': str(row.get('Overview') or '')[:500] if kind == 'Movie' else '',
'added_at': added.isoformat(), 'has_artwork': False, 'items': [], 'selected': False, 'featured': False})
entry['added_at'] = max(entry['added_at'], added.isoformat())
entry['has_artwork'] |= bool(row.get('SeriesPrimaryImageTag') if kind == 'Episode' else (row.get('ImageTags') or {}).get('Primary'))
entry['items'].append({'id': identity, 'season': row.get('ParentIndexNumber') if kind == 'Episode' else None,
'number': row.get('IndexNumber') if kind == 'Episode' else None})
return sorted(groups.values(), key=lambda row: (row['added_at'], row['id']), reverse=True)
async def collect(runtime, start: datetime, end: datetime, limit: int = 12) -> dict:
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise CatalogError('Connect Jellyfin before collecting new arrivals.')
rows, seen = [], set()
exhausted = False
async with httpx.AsyncClient(timeout=20) as client:
info = await get_json(client, runtime, '/System/Info')
server_id = item_id(info.get('Id')) if isinstance(info, dict) else None
if not server_id:
raise CatalogError('Jellyfin did not return its server identity.')
for offset in range(0, MAX_ITEMS, PAGE_SIZE):
payload = await get_json(client, runtime, '/Items', {'Recursive': 'true', 'IncludeItemTypes': 'Movie,Episode',
'SortBy': 'DateCreated,SortName', 'SortOrder': 'Descending', 'Fields': 'DateCreated,Overview',
'EnableUserData': 'false', 'IsMissing': 'false', 'IsPlaceHolder': 'false', 'Limit': PAGE_SIZE, 'StartIndex': offset})
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
raise CatalogError('Jellyfin returned an incomplete arrival list.')
page = payload['Items']
total = payload.get('TotalRecordCount')
if not isinstance(total, int) or total < offset + len(page):
raise CatalogError('Jellyfin returned an incomplete arrival count.')
for row in page:
if not isinstance(row, dict) or not item_id(row.get('Id')) or not date(row.get('DateCreated')):
raise CatalogError('Jellyfin returned an arrival without a valid identity or added date.')
identity = item_id(row['Id'])
if identity in seen:
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
seen.add(identity)
if rows and date(row['DateCreated']) > date(rows[-1]['DateCreated']):
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
rows.append(row)
if (not page or len(page) < PAGE_SIZE) and offset + len(page) < total:
raise CatalogError('Jellyfin returned an incomplete arrival page.')
if not page or any(date(row['DateCreated']) < start for row in page) or offset + len(page) >= total:
exhausted = True
break
if not exhausted:
raise CatalogError('More than 5,000 recent items were found. Choose a shorter arrival period; no partial edition was created.')
titles = group_arrivals(rows, start, end)
total = len(titles)
titles = titles[:MAX_TITLES]
for index, title in enumerate(titles):
title['selected'] = index < limit
return {'source': source_key(runtime.jellyfin_base_url), 'server_id': server_id,
'period_start': start.isoformat(), 'period_end': end.isoformat(), 'total_titles': total, 'titles': titles}
async def for_recipient(runtime, content: dict, jellyfin_id: str) -> dict:
"""Scope every ID lookup to a view Jellyfin permits this user to browse.
Jellyfin 10.11's AddUserToQuery skips its default library filter when ItemIds
is present. UserId alone is insufficient; ParentId supplies the allowed scope.
"""
if not item_id(jellyfin_id):
raise CatalogError('The recipient does not have a valid Jellyfin identity.')
selected = [entry for entry in content['titles'] if entry['selected']]
ids = sorted({identity for entry in selected for identity in [entry['id'], *(item['id'] for item in entry['items'])]})
allowed = set()
async with httpx.AsyncClient(timeout=20) as client:
info = await get_json(client, runtime, '/System/Info')
if not isinstance(info, dict) or source_key(runtime.jellyfin_base_url) != content['source'] or item_id(info.get('Id')) != content['server_id']:
raise CatalogError('The Jellyfin server changed. Create a new edition for the current library.')
user = await get_json(client, runtime, '/Users/' + jellyfin_id)
if not isinstance(user, dict) or item_id(user.get('Id')) != item_id(jellyfin_id) or not isinstance(user.get('Policy'), dict):
raise CatalogError('Could not verify the recipients Jellyfin account.')
if user['Policy'].get('IsDisabled') or user['Policy'].get('EnableMediaPlayback') is False:
return {**content, 'titles': [], 'recipient_disabled': True}
views = await get_json(client, runtime, '/UserViews', {'UserId': jellyfin_id, 'IncludeHidden': 'true', 'IncludeExternalContent': 'false'})
if not isinstance(views, dict) or not isinstance(views.get('Items'), list) or len(views['Items']) > 32:
raise CatalogError('Could not check the recipients library access.')
for view in views['Items']:
parent = item_id(view.get('Id')) if isinstance(view, dict) else None
if not parent:
raise CatalogError('Jellyfin returned a library without a valid identity.')
for offset in range(0, len(ids), 100):
chunk = ids[offset:offset + 100]
payload = await get_json(client, runtime, '/Items', {'UserId': jellyfin_id, 'ParentId': parent, 'Ids': ','.join(chunk),
'Recursive': 'true', 'Limit': len(chunk), 'EnableUserData': 'false', 'EnableImages': 'false',
'IsMissing': 'false', 'IsPlaceHolder': 'false'})
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
raise CatalogError('Could not check the recipients library access.')
allowed.update(item_id(item.get('Id')) for item in payload['Items'] if isinstance(item, dict))
titles = []
for entry in selected:
accessible = [item for item in entry['items'] if item['id'] in allowed]
if entry['id'] in allowed and accessible:
titles.append({**entry, 'items': accessible})
return {**content, 'titles': titles}
async def poster(runtime, identity: str) -> bytes | None:
if not item_id(identity):
return None
key = (source_key(runtime.jellyfin_base_url), hashlib.sha256(runtime.jellyfin_api_key.encode()).hexdigest(), identity)
async with _poster_lock:
cached = _posters.get(key)
if cached and cached[0] > time.monotonic():
_posters.move_to_end(key)
return cached[1]
result = None
try:
async with httpx.AsyncClient(timeout=10) as client:
async with client.stream('GET', runtime.jellyfin_base_url.rstrip('/') + f'/Items/{identity}/Images/Primary',
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params={'maxWidth': 160, 'maxHeight': 240, 'quality': 82, 'format': 'Jpg'}) as response:
response.raise_for_status()
data = bytearray()
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > 512 * 1024:
raise ValueError('Poster too large')
with Image.open(io.BytesIO(data)) as image:
if image.width * image.height > 4_000_000:
raise ValueError('Poster dimensions too large')
image.thumbnail((160, 240))
target = io.BytesIO()
image.convert('RGB').save(target, format='JPEG', quality=82)
result = target.getvalue()
except (httpx.HTTPError, ValueError, OSError, Image.DecompressionBombError):
pass
_posters[key] = (time.monotonic() + (1800 if result else 60), result)
while len(_posters) > 128:
_posters.popitem(last=False)
return result
async def posters(runtime, content: dict) -> dict:
titles = [entry for entry in content['titles'] if entry['selected'] and entry['has_artwork']]
results = await asyncio.gather(*(poster(runtime, entry['id']) for entry in titles))
return {entry['id']: data for entry, data in zip(titles, results) if data}
+74
View File
@@ -0,0 +1,74 @@
import base64
import html
from urllib.parse import urlencode
from .recap_email import document
def description(entry):
if entry['type'] == 'movie':
return f"Movie · {entry['year']}" if entry.get('year') else 'Movie'
seasons = sorted({item['season'] for item in entry['items'] if isinstance(item.get('season'), int)})
count = len(entry['items'])
labels = ', '.join('Specials' if value == 0 else str(value) for value in seasons[:8])
suffix = f" · {'Season' if len(seasons) == 1 else 'Seasons'} {labels}" if labels else ''
return f"{count} new {'episode' if count == 1 else 'episodes'}{suffix}"
def render_confirmation(username, url):
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from Grizzlyflix."
return {'subject': 'Confirm your Grizzlyflix newsletter subscription',
'body_text': f'{intro}\n\nConfirm newsletter subscription: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email.',
'body_html': document(title='Your next watch starts here.', intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">A weekly look at new movies and TV updates, with posters and links to watch.</p>',
action='Confirm newsletter subscription', url=url, kicker='NEW ON GRIZZLYFLIX',
footer='This link expires in 24 hours. If you did not request this, ignore this email.')}
def render(content, images, public_url, playback_url, unsubscribe_url, *, preview=False, test=False):
esc = html.escape
titles = [entry for entry in content['titles'] if entry['selected']]
body, lines, attachments = [], [], []
intro = str(content.get('intro') or '').strip()
if intro:
body.append(f'<p style="font-size:15px;line-height:1.8;color:#e5e1e4;overflow-wrap:anywhere">{esc(intro).replace(chr(10), "<br>")}</p>')
lines += [intro, '']
sections = [('Featured picks', [entry for entry in titles if entry['featured']]),
('New movies', [entry for entry in titles if not entry['featured'] and entry['type'] == 'movie']),
('Fresh episodes', [entry for entry in titles if not entry['featured'] and entry['type'] == 'series'])]
for heading, entries in sections:
if not entries:
continue
body.append(f'<h2 style="font-size:20px;margin:28px 0 8px;color:#e5e1e4">{heading}</h2>')
lines += [heading, '']
for entry in entries:
watch = playback_url + '/web/index.html#!/details?' + urlencode({'id': entry['id'], 'serverId': content['server_id']})
image_data = images.get(entry['id'])
cid = f"newsletter-{entry['id']}@magent"
if image_data:
source = 'data:image/jpeg;base64,' + base64.b64encode(image_data).decode() if preview else 'cid:' + cid
poster = f'<img src="{source}" width="80" alt="{esc(entry["title"], quote=True)}" style="display:block;width:80px;height:auto;border-radius:7px;border:0">'
if not preview:
attachments.append({'cid': cid, 'data': image_data})
else:
poster = f'<div style="width:80px;height:112px;line-height:112px;background:#353039;color:#c7bdff;text-align:center;border-radius:7px;font-size:11px">{"TV" if entry["type"] == "series" else "MOVIE"}</div>'
details = description(entry)
overview = str(entry.get('overview') or '')[:180]
copy = f'<p style="margin:8px 0;font-size:12px;line-height:1.6;color:#bdb6c3">{esc(overview)}</p>' if overview and entry['featured'] else ''
body.append(f'''<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;border-bottom:1px solid #363338"><tr>
<td width="92" valign="top" style="padding:18px 12px 18px 0">{poster}</td><td valign="top" style="padding:18px 0;overflow-wrap:anywhere">
<h3 style="margin:0 0 8px;font-size:16px;line-height:1.4;color:#eee8f2">{esc(entry['title'])}</h3><p style="font-size:12px;line-height:1.6;color:#a69fac;margin:0 0 10px">{esc(details)}</p>{copy}
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Grizzlyflix &#8599;</a></td></tr></table>''')
lines += [entry['title'], details, watch, '']
if not titles:
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in Grizzlyflix.</p>')
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
footer = f'You subscribed to the Grizzlyflix newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
subject = ('[Test] ' if test else '') + content['subject']
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Grizzlyflix: {playback_url}', '',
f'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
f'Email preferences: {public_url}/profile#newsletters']),
'body_html': document(title='Whats new on Grizzlyflix',
intro=('This is your test edition. ' if test else '') + 'New stories for your watchlist. Find your next movie or catch up on fresh episodes.',
content=''.join(body), action='Explore Grizzlyflix', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
'inline_images': attachments}
+347
View File
@@ -0,0 +1,347 @@
"""Independent newsletter consent and immutable edition snapshots using the shared email queue."""
import hashlib
import json
import secrets
import uuid
from contextlib import closing
from datetime import datetime, timedelta, timezone
from .. import db
from . import email_queue
from .recap_store import read_one, transaction
class Conflict(ValueError):
pass
def init_schema(conn):
for sql in (
"""CREATE TABLE IF NOT EXISTS newsletter_settings (
id INTEGER PRIMARY KEY CHECK(id=1), enabled INTEGER NOT NULL DEFAULT 0,
weekday INTEGER NOT NULL DEFAULT 4, hour INTEGER NOT NULL DEFAULT 9, limit_titles INTEGER NOT NULL DEFAULT 12,
public_url TEXT NOT NULL DEFAULT '', intro TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
next_send_at REAL, generation_claim TEXT, generation_until REAL, generation_attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '')""",
"INSERT OR IGNORE INTO newsletter_settings (id, public_url) SELECT 1, public_url FROM email_recap_settings WHERE id=1",
"""CREATE TABLE IF NOT EXISTS newsletter_subscriptions (
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
"""CREATE TABLE IF NOT EXISTS newsletter_editions (
id TEXT PRIMARY KEY, subject TEXT NOT NULL, intro TEXT NOT NULL, content_json TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL DEFAULT 'draft', origin TEXT NOT NULL DEFAULT 'manual',
weekly_key TEXT UNIQUE, send_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL, created_by TEXT NOT NULL)""",
"""CREATE TABLE IF NOT EXISTS newsletter_versions (
edition_id TEXT NOT NULL, revision INTEGER NOT NULL, content_json TEXT NOT NULL,
PRIMARY KEY (edition_id, revision))""",
"""CREATE TABLE IF NOT EXISTS newsletter_deliveries (
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
edition_id TEXT NOT NULL, edition_revision INTEGER NOT NULL, kind TEXT NOT NULL,
email TEXT NOT NULL, subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
"CREATE INDEX IF NOT EXISTS idx_newsletter_queue ON newsletter_deliveries (state, next_attempt_at)",
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_changed AFTER UPDATE OF email, is_blocked ON users
WHEN LOWER(TRIM(COALESCE(NEW.email,''))) != LOWER(TRIM(COALESCE(OLD.email,''))) OR NEW.is_blocked=1
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=NEW.id; END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_deleted AFTER DELETE ON users
BEGIN DELETE FROM newsletter_subscriptions WHERE user_id=OLD.id;
UPDATE newsletter_deliveries SET state='cancelled', detail='Account removed.'
WHERE user_id=OLD.id AND state IN ('queued','retry','preparing'); END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_changed AFTER UPDATE ON jellyfin_user_links
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source OR NEW.local_user_id != OLD.local_user_id
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_deleted AFTER DELETE ON jellyfin_user_links
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
):
conn.execute(sql)
def settings() -> dict:
result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
result['enabled'] = bool(result['enabled'])
return result
def public_settings() -> dict:
return {key: value for key, value in settings().items() if key in
{'enabled', 'weekday', 'hour', 'limit_titles', 'public_url', 'intro', 'revision', 'next_send_at', 'last_error'}}
def next_due(now: datetime, weekday: int, hour: int) -> datetime:
now = now.astimezone(timezone.utc)
due = now.replace(hour=hour, minute=0, second=0, microsecond=0) + timedelta(days=(weekday - now.weekday()) % 7)
return due if due > now else due + timedelta(days=7)
def save_settings(values: dict, now: datetime):
with transaction() as conn:
old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
if old['revision'] != values['revision']:
raise Conflict('The newsletter settings changed. Refresh before saving.')
due = next_due(now, values['weekday'], values['hour']).timestamp() if values['enabled'] else None
conn.execute("""UPDATE newsletter_settings SET enabled=?, weekday=?, hour=?, limit_titles=?, public_url=?, intro=?,
revision=revision+1, next_send_at=?, generation_claim=NULL, generation_until=NULL, generation_attempts=0, last_error='' WHERE id=1""",
(values['enabled'], values['weekday'], values['hour'], values['limit_titles'], values['public_url'], values['intro'], due))
if not values['enabled'] or any(old[key] != values[key] for key in ('weekday', 'hour', 'public_url')):
conn.execute("UPDATE newsletter_editions SET state='cancelled', updated_at=? WHERE origin='weekly' AND state IN ('scheduled','queued')", (now.timestamp(),))
conn.execute("""UPDATE newsletter_deliveries SET state='cancelled', detail='Weekly schedule paused or changed.'
WHERE state IN ('queued','retry','preparing') AND kind='edition'
AND edition_id IN (SELECT id FROM newsletter_editions WHERE state='cancelled')""")
return public_settings()
def subscription(user_id):
return read_one('SELECT * FROM newsletter_subscriptions WHERE user_id=?', (user_id,))
def disable(user_id):
with transaction() as conn:
conn.execute("UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
conn.execute("UPDATE newsletter_deliveries SET state='cancelled', detail='Newsletter subscription turned off.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (user_id,))
def request_confirmation(user, source, identity, now):
token = secrets.token_urlsafe(32)
with transaction() as conn:
old = conn.execute('SELECT requested_at FROM newsletter_subscriptions WHERE user_id=?', (user['id'],)).fetchone()
if old and old[0] > now - 300:
raise Conflict('Please wait five minutes before requesting another confirmation.')
conn.execute("""INSERT INTO newsletter_subscriptions (user_id,state,email,identity_source,identity_id,version,
confirmation_hash,confirmation_expires,requested_at,unsubscribe_token) VALUES (?,'pending',?,?,?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET state='pending',email=excluded.email,identity_source=excluded.identity_source,
identity_id=excluded.identity_id,version=excluded.version,confirmation_hash=excluded.confirmation_hash,
confirmation_expires=excluded.confirmation_expires,requested_at=excluded.requested_at,confirmed_at=NULL,
unsubscribe_token=excluded.unsubscribe_token""",
(user['id'], user['email'].strip(), source, identity, uuid.uuid4().hex,
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
return token
def token_subscription(token, action):
if action == 'confirm':
return read_one('SELECT * FROM newsletter_subscriptions WHERE confirmation_hash=?', (hashlib.sha256(token.encode()).hexdigest(),))
return read_one('SELECT * FROM newsletter_subscriptions WHERE unsubscribe_token=?', (token,))
def confirm(sub, now):
with transaction() as conn:
result = conn.execute("""UPDATE newsletter_subscriptions SET state='enabled',confirmed_at=?,confirmation_hash=NULL
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
AND EXISTS (SELECT 1 FROM users u JOIN jellyfin_user_links j ON j.local_user_id=u.id
WHERE u.id=newsletter_subscriptions.user_id AND u.is_blocked=0
AND LOWER(TRIM(u.email))=LOWER(TRIM(newsletter_subscriptions.email))
AND j.source=identity_source AND j.jellyfin_user_id=identity_id)""", (now, sub['user_id'], sub['version'], now))
return result.rowcount == 1
def unpack(row):
if row is None:
return None
result = dict(row)
result['content'] = json.loads(result.pop('content_json'))
return result
def edition(identity):
return unpack(read_one('SELECT * FROM newsletter_editions WHERE id=?', (identity,)))
def create_edition(content, subject, intro, creator, now):
identity = uuid.uuid4().hex
with transaction() as conn:
conn.execute('''INSERT INTO newsletter_editions (id,subject,intro,content_json,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,?,?)''', (identity, subject, intro, json.dumps(content), now, now, creator))
return edition(identity)
def editable(conn, identity, revision):
row = conn.execute('SELECT * FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
if not row or row['revision'] != revision:
raise Conflict('This edition changed. Reload it before continuing.')
if row['state'] != 'draft':
raise Conflict('This edition is already scheduled or finished. Create a new draft to make changes.')
return unpack(row)
def update_edition(identity, revision, subject, intro, selections, now):
with transaction() as conn:
old = editable(conn, identity, revision)
titles = old['content']['titles']
selected = {entry['id']: entry for entry in selections}
if len(selected) != len(selections) or set(selected) != {entry['id'] for entry in titles}:
raise Conflict('The title selection does not match this draft. Reload the edition.')
if sum(bool(entry['selected']) for entry in selections) > 24 or sum(bool(entry['featured']) for entry in selections) > 3:
raise Conflict('Choose up to 24 titles and three featured picks.')
if any(entry['featured'] and not entry['selected'] for entry in selections):
raise Conflict('Featured picks must be included in the edition.')
for entry in titles:
entry.update(selected=selected[entry['id']]['selected'], featured=selected[entry['id']]['featured'])
conn.execute('UPDATE newsletter_editions SET subject=?,intro=?,content_json=?,revision=revision+1,updated_at=? WHERE id=?',
(subject, intro, json.dumps(old['content']), now, identity))
return edition(identity)
def snapshot(conn, row):
data = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
# Store only included titles; retries of a test retain the exact saved version.
data['titles'] = [entry for entry in data['titles'] if entry['selected']]
conn.execute('INSERT OR IGNORE INTO newsletter_versions (edition_id,revision,content_json) VALUES (?,?,?)',
(row['id'], row['revision'], json.dumps(data)))
def version(delivery):
row = read_one('SELECT content_json FROM newsletter_versions WHERE edition_id=? AND revision=?', (delivery['edition_id'], delivery['edition_revision']))
return json.loads(row['content_json']) if row else None
def publish(identity, revision, send_at, now):
with transaction() as conn:
previous = conn.execute('SELECT revision,state FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
if previous and previous['revision'] == revision and previous['state'] in {'scheduled', 'queued', 'complete'}:
return edition(identity)
row = editable(conn, identity, revision)
if not any(entry['selected'] for entry in row['content']['titles']) and not row['intro'].strip():
raise Conflict('Add an announcement or select a title before sending.')
snapshot(conn, row)
conn.execute("UPDATE newsletter_editions SET state='scheduled',send_at=?,updated_at=? WHERE id=?", (send_at, now, identity))
return edition(identity)
def cancel(identity, now):
with transaction() as conn:
conn.execute("UPDATE newsletter_editions SET state='cancelled',updated_at=? WHERE id=? AND state IN ('draft','scheduled','queued')", (now, identity))
conn.execute("UPDATE newsletter_deliveries SET state='cancelled',detail='Edition cancelled.',updated_at=? WHERE edition_id=? AND state IN ('queued','retry','preparing')", (now, identity))
return edition(identity)
def _enqueue(conn, sub, row, kind, key, public_url, now):
identity = uuid.uuid4().hex
conn.execute('''INSERT OR IGNORE INTO newsletter_deliveries (id,dedupe_key,user_id,edition_id,edition_revision,kind,email,
subscription_version,public_url,created_at,updated_at,next_attempt_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
(identity, key, sub['user_id'], row['id'], row['revision'], kind, sub['email'], sub['version'], public_url, now, now, now))
return conn.execute('SELECT id FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()[0]
def enqueue_test(sub, identity, revision, request_id, public_url, now):
key = f"test:{sub['user_id']}:{request_id}"
with transaction() as conn:
previous = conn.execute('SELECT id,edition_id,edition_revision FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()
if previous:
if previous['edition_id'] != identity or previous['edition_revision'] != revision:
raise Conflict('This test request was already used for another saved version.')
return previous['id']
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE id=? AND revision=?', (identity, revision)).fetchone())
if not row or row['state'] == 'cancelled':
raise Conflict('This edition changed or was cancelled. Reload it first.')
if conn.execute("SELECT 1 FROM newsletter_deliveries WHERE user_id=? AND kind='test' AND created_at>?", (sub['user_id'], now-300)).fetchone():
raise Conflict('Please wait five minutes between newsletter test emails.')
snapshot(conn, row)
return _enqueue(conn, sub, row, 'test', key, public_url, now)
def enqueue_due(now):
with transaction() as conn:
config = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
for raw in rows:
row = unpack(raw)
subs = conn.execute("SELECT * FROM newsletter_subscriptions WHERE state='enabled' AND confirmed_at<=?", (row['send_at'],)).fetchall()
for sub in subs:
_enqueue(conn, sub, row, 'edition', f"edition:{row['id']}:{sub['user_id']}", config['public_url'], now)
conn.execute("UPDATE newsletter_editions SET state=?,updated_at=? WHERE id=?", ('queued' if subs else 'complete', now, row['id']))
def claim_delivery(now):
with transaction() as conn:
return email_queue.claim(conn, 'newsletter_deliveries', now)
def begin_sending(delivery, now):
with transaction() as conn:
result = conn.execute("""UPDATE newsletter_deliveries SET state='sending',updated_at=?,lease_until=?
WHERE id=? AND claim=? AND state='preparing'
AND EXISTS (SELECT 1 FROM newsletter_subscriptions s JOIN users u ON u.id=s.user_id
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
WHERE s.user_id=newsletter_deliveries.user_id AND s.state='enabled'
AND s.version=newsletter_deliveries.subscription_version AND u.is_blocked=0
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
AND EXISTS (SELECT 1 FROM newsletter_settings WHERE id=1 AND public_url=newsletter_deliveries.public_url)
AND EXISTS (SELECT 1 FROM newsletter_editions e WHERE e.id=newsletter_deliveries.edition_id AND e.state!='cancelled')""",
(now, now+1800, delivery['id'], delivery['claim']))
return result.rowcount == 1
def finish(delivery, state, detail, now, delay=0):
with transaction() as conn:
email_queue.finish(conn, 'newsletter_deliveries', delivery, state, detail, now, delay)
def finish_editions(now):
with transaction() as conn:
conn.execute("""UPDATE newsletter_editions SET state='complete',updated_at=? WHERE state='queued'
AND NOT EXISTS (SELECT 1 FROM newsletter_deliveries d WHERE d.edition_id=newsletter_editions.id
AND d.kind='edition' AND d.state IN ('queued','preparing','sending','retry'))""", (now,))
def claim_weekly(now: datetime):
with transaction() as conn:
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
stamp = now.timestamp()
if not config['enabled'] or not config['next_send_at'] or config['next_send_at'] > stamp or (config['generation_until'] or 0) > stamp:
return None
claim = uuid.uuid4().hex
conn.execute('UPDATE newsletter_settings SET generation_claim=?,generation_until=?,generation_attempts=generation_attempts+1 WHERE id=1', (claim, stamp+600))
due = next_due(now, config['weekday'], config['hour']) - timedelta(days=7)
return {**config, 'generation_claim': claim, 'due': due, 'generation_attempts': config['generation_attempts']+1}
def complete_weekly(config, content, now: datetime, failure=''):
with transaction() as conn:
current = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
if not current['enabled'] or current['revision'] != config['revision'] or current['generation_claim'] != config['generation_claim']:
return
if failure:
retry = config['generation_attempts'] < 3
conn.execute('''UPDATE newsletter_settings SET generation_claim=NULL,generation_until=?,last_error=?,next_send_at=?,
generation_attempts=? WHERE id=1''', (now.timestamp()+300 if retry else None, failure,
current['next_send_at'] if retry else next_due(now, config['weekday'], config['hour']).timestamp(),
config['generation_attempts'] if retry else 0))
return
identity = uuid.uuid4().hex
due = config['due']
empty = not content['titles']
conn.execute('''INSERT OR IGNORE INTO newsletter_editions
(id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''',
(identity, f"Whats new on Grizzlyflix · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
'skipped' if empty else 'scheduled', due.isoformat(), due.timestamp(), now.timestamp(), now.timestamp(), 'Weekly schedule'))
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
if not empty:
snapshot(conn, row)
conn.execute('''UPDATE newsletter_settings SET next_send_at=?,generation_claim=NULL,generation_until=NULL,
generation_attempts=0,last_error=? WHERE id=1''',
(next_due(now, config['weekday'], config['hour']).timestamp(), 'No new arrivals for the weekly edition; no email was queued.' if empty else ''))
def overview(offset=0):
with closing(db._connect()) as conn:
import sqlite3
conn.row_factory = sqlite3.Row
rows = conn.execute('SELECT * FROM newsletter_editions ORDER BY created_at DESC,id LIMIT 30').fetchall()
editions = []
for raw in rows:
row = unpack(raw)
content = row.pop('content')
row.update(period_start=content['period_start'], period_end=content['period_end'], titles=sum(entry['selected'] for entry in content['titles']))
editions.append(row)
deliveries = conn.execute('''SELECT d.id,d.edition_id,e.subject,d.kind,d.email,d.state,d.attempts,d.updated_at,d.next_attempt_at,
d.detail,u.username FROM newsletter_deliveries d LEFT JOIN users u ON u.id=d.user_id
LEFT JOIN newsletter_editions e ON e.id=d.edition_id ORDER BY d.created_at DESC,d.id LIMIT 50 OFFSET ?''', (offset,)).fetchall()
subscribers = conn.execute("SELECT COUNT(*) FROM newsletter_subscriptions WHERE state='enabled'").fetchone()[0]
total = conn.execute('SELECT COUNT(*) FROM newsletter_deliveries').fetchone()[0]
return {'editions': editions, 'deliveries': [dict(row) for row in deliveries], 'subscribers': subscribers, 'total': total}
+271
View File
@@ -0,0 +1,271 @@
"""Weekly new-arrival newsletters, manual editions and separate opt-in delivery."""
import asyncio
import logging
import time
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode, urlsplit
from .. import db
from ..runtime import get_runtime_settings
from . import email_recaps, newsletter_catalog as catalog, newsletter_email as template, newsletter_store as store
from . import recap_email as mail, recap_store
from .invite_email import smtp_email_config_ready
from .jellyfin_identity import linked_user_id, source_key
logger = logging.getLogger(__name__)
NewsletterError = email_recaps.RecapError
def playback_url(runtime) -> str:
value = str(runtime.jellyfin_public_url or '').strip().rstrip('/')
try:
parsed = urlsplit(value)
if parsed.scheme in {'https', 'http'} and parsed.hostname and not (parsed.username or parsed.password or parsed.query or parsed.fragment) and not any(c.isspace() or c in '<>"\\' for c in value):
return value
except ValueError:
pass
return ''
def delivery_ready(public_url=None):
config = store.settings()
if not (public_url if public_url is not None else config['public_url']):
return False, 'Set the public Magent address for newsletter email links.'
runtime = get_runtime_settings()
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
return False, 'Connect Jellyfin to collect new arrivals.'
if not playback_url(runtime):
return False, 'Set the public Jellyfin address in Jellyfin settings for Watch links.'
ready, detail = smtp_email_config_ready()
if not ready:
return ready, detail
if not email_recaps.worker_enabled():
return False, 'Background automation is paused on this server.'
return True, 'Newsletter delivery is configured.'
def account_for(user):
account = db.get_user_by_username(user.get('username', ''))
if not account or account.get('is_blocked') or account.get('is_expired'):
raise NewsletterError('This account cannot receive newsletters.', 403)
return account
def active_subscription(account):
sub = store.subscription(account['id'])
if sub and sub['state'] != 'off' and not email_recaps.binding_matches(sub, account):
store.disable(account['id'])
sub = store.subscription(account['id'])
return sub
def preferences(user):
account = account_for(user)
sub = active_subscription(account)
runtime = get_runtime_settings()
ready, detail = delivery_ready()
linked = bool(linked_user_id(account['username'], runtime.jellyfin_base_url))
email = mail.valid_email(account.get('email'))
config = store.settings()
state = sub['state'] if sub else 'off'
if state == 'pending' and sub['confirmation_expires'] <= time.time():
state = 'expired'
return {'state': state, 'email': account.get('email'), 'can_subscribe': ready and linked and bool(email),
'detail': detail if not ready else 'Save a valid profile email address.' if not email else
'Link your Jellyfin account so newsletter titles match your library access.' if not linked else 'New arrivals and featured picks, in your inbox.',
'schedule_enabled': config['enabled'], 'next_send_at': config['next_send_at'], 'weekday': config['weekday'], 'hour': config['hour'],
'resend_after': sub['requested_at'] + 300 if sub else None}
async def subscribe(user):
account = account_for(user)
preference = preferences(user)
if preference['state'] == 'enabled':
return preference
if not preference['can_subscribe']:
raise NewsletterError(preference['detail'])
runtime = get_runtime_settings()
try:
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
linked_user_id(account['username'], runtime.jellyfin_base_url), time.time())
except store.Conflict as exc:
raise NewsletterError(str(exc), 429) from exc
# The click supplies separate newsletter consent. Reuse a still-valid confirmed address if available.
recap = recap_store.subscription(account['id'])
if recap and recap['state'] == 'enabled' and email_recaps.binding_matches(recap, account):
if store.confirm(store.subscription(account['id']), time.time()):
return {**preferences(user), 'message': 'Newsletter subscription is on, using your confirmed profile email.'}
config = store.settings()
url = config['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'confirm', 'token': token})
try:
await asyncio.to_thread(mail.send_email, account['email'].strip(), template.render_confirmation(account['username'], url),
mail.message_id(uuid.uuid4().hex, config['public_url']))
except mail.DeliveryError as exc:
raise NewsletterError('Could not confirm delivery of the verification email. Check your inbox; another can be requested in five minutes.', 502) from exc
return {**preferences(user), 'message': 'Check your inbox and confirm within 24 hours to turn on newsletters.'}
def token_action(token, action, apply=False):
sub = store.token_subscription(token, action)
if not sub:
raise NewsletterError('This newsletter link is invalid or has already been used. Open Profile to manage your subscription.', 410)
if action == 'unsubscribe':
if apply:
store.disable(sub['user_id'])
return {'action': action, 'state': 'off' if apply or sub['state'] == 'off' else 'ready'}
account = db.get_user_by_id(sub['user_id'])
if sub['state'] != 'pending' or sub['confirmation_expires'] <= time.time() or not email_recaps.binding_matches(sub, account):
raise NewsletterError('This confirmation expired or your account changed. Request a new newsletter link in Profile.', 410)
if apply and not store.confirm(sub, time.time()):
raise NewsletterError('This confirmation is no longer available. Request a new newsletter link in Profile.', 410)
return {'action': action, 'state': 'enabled' if apply else 'ready'}
async def collect(start, end, limit):
runtime = get_runtime_settings()
result = await asyncio.wait_for(catalog.collect(runtime, start, end, limit), timeout=180)
return {**result, 'playback_url': playback_url(runtime)}
async def create_draft(user, days):
end = datetime.now(timezone.utc)
config = store.settings()
content = await collect(end - timedelta(days=days), end, config['limit_titles'])
return store.create_edition(content, f"Whats new on Grizzlyflix · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
def require_edition(identity, revision=None):
row = store.edition(identity)
if not row:
raise NewsletterError('Newsletter edition not found.', 404)
if revision is not None and row['revision'] != revision:
raise NewsletterError('This edition changed. Reload it before continuing.')
return row
async def preview(identity, revision):
row = require_edition(identity, revision)
runtime = get_runtime_settings()
config = store.settings()
if not config['public_url'] or not playback_url(runtime):
raise NewsletterError('Set the public Magent and Jellyfin addresses before previewing.')
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
rendered = template.render(content, images, config['public_url'], content['playback_url'], config['public_url'] + '/profile#newsletters', preview=True)
rendered.pop('inline_images')
return {'id': row['id'], 'revision': row['revision'], **rendered}
def queue_test(user, identity, revision, request_id):
ready, detail = delivery_ready()
if not ready:
raise NewsletterError(detail)
account = account_for(user)
sub = active_subscription(account)
if not sub or sub['state'] != 'enabled':
raise NewsletterError('Subscribe to newsletters and confirm your email in Profile before sending yourself a test.')
delivery_id = store.enqueue_test(sub, identity, revision, request_id, store.settings()['public_url'], time.time())
return {'id': delivery_id, 'message': 'Test queued for your confirmed newsletter email. Delivery history will show the result.'}
def publish(identity, revision, send_at):
ready, detail = delivery_ready()
if not ready:
raise NewsletterError(detail)
row = require_edition(identity, revision)
runtime = get_runtime_settings()
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
raise NewsletterError('The Jellyfin connection changed. Create a fresh draft before sending.')
now = datetime.now(timezone.utc)
when = now if send_at is None else send_at
if when.tzinfo is None:
raise NewsletterError('Choose a send time with an explicit timezone.', 422)
when = when.astimezone(timezone.utc)
if send_at is not None and not now + timedelta(seconds=30) <= when <= now + timedelta(days=90):
raise NewsletterError('Schedule the edition at least 30 seconds ahead and within the next 90 days.', 422)
return store.publish(identity, revision, when.timestamp(), now.timestamp())
def eligible(delivery):
account = db.get_user_by_id(delivery['user_id'])
sub = active_subscription(account) if account else None
ready, _ = delivery_ready()
if not ready or not sub or sub['state'] != 'enabled' or sub['version'] != delivery['subscription_version'] or sub['email'] != delivery['email'] or not email_recaps.binding_matches(sub, account) or store.settings()['public_url'] != delivery['public_url']:
raise mail.DeliveryCancelled()
row = store.edition(delivery['edition_id'])
if not row or row['state'] == 'cancelled':
raise mail.DeliveryCancelled()
return account, sub
async def process_delivery(delivery):
state, detail, delay = 'failed', 'Could not prepare this newsletter.', 0
try:
_, sub = eligible(delivery)
content = store.version(delivery)
runtime = get_runtime_settings()
if not content or content['playback_url'] != playback_url(runtime) or content['source'] != source_key(runtime.jellyfin_base_url):
raise mail.DeliveryCancelled()
content = await asyncio.wait_for(catalog.for_recipient(runtime, content, sub['identity_id']), timeout=120)
if content.get('recipient_disabled') or (not content['titles'] and not content['intro'].strip()):
state, detail = 'skipped', 'No selected titles are available to this account.'
else:
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
unsubscribe = delivery['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
rendered = template.render(content, images, delivery['public_url'], content['playback_url'], unsubscribe, test=delivery['kind'] == 'test')
def before_data():
eligible(delivery)
if not store.begin_sending(delivery, time.time()):
raise mail.DeliveryCancelled()
await asyncio.to_thread(mail.send_email, delivery['email'], rendered, mail.message_id(delivery['id'], delivery['public_url']), before_data)
state, detail = 'sent', 'Accepted by the mail server.'
except mail.DeliveryCancelled:
state, detail = 'cancelled', 'Subscription, account, edition or email settings changed.'
except (catalog.CatalogError, TimeoutError):
state, detail = 'retry', 'Jellyfin content or library access could not be checked.'
except mail.DeliveryError as exc:
state, detail = exc.state, exc.detail
except Exception as exc:
logger.error('newsletter delivery error id=%s type=%s', delivery['id'], type(exc).__name__)
current = store.read_one('SELECT state FROM newsletter_deliveries WHERE id=?', (delivery['id'],))
if current and current['state'] == 'sending':
state, detail = 'unknown', 'Delivery outcome is unknown; check the mail server.'
if state == 'retry':
if delivery['attempts'] >= 3:
state, detail = 'failed', detail + ' Stopped after three attempts.'
else:
delay = 300 if delivery['attempts'] == 1 else 1800
store.finish(delivery, state, detail, time.time(), delay)
async def run_once():
if delivery_ready()[0]:
config = store.claim_weekly(datetime.now(timezone.utc))
if config:
try:
content = await collect(config['due'] - timedelta(days=7), config['due'], config['limit_titles'])
store.complete_weekly(config, content, datetime.now(timezone.utc))
except (catalog.CatalogError, TimeoutError):
store.complete_weekly(config, None, datetime.now(timezone.utc), 'Could not collect a complete weekly edition from Jellyfin. No newsletter was queued.')
store.enqueue_due(time.time())
for _ in range(10):
delivery = store.claim_delivery(time.time())
if not delivery:
break
await process_delivery(delivery)
store.finish_editions(time.time())
async def run_newsletter_loop():
while True:
try:
await run_once()
except Exception as exc:
logger.error('newsletter worker failed type=%s', type(exc).__name__)
await asyncio.sleep(30)
+182
View File
@@ -0,0 +1,182 @@
"""Personal recap email rendering and SMTP delivery with explicit acceptance tracking."""
import html
import re
import smtplib
import ssl
from contextlib import suppress
from datetime import datetime
from email.message import EmailMessage
from email.policy import SMTP as SMTP_POLICY
from email.utils import formataddr, formatdate
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
class DeliveryError(Exception):
def __init__(self, state: str, detail: str):
self.state, self.detail = state, detail
super().__init__(detail)
class DeliveryCancelled(Exception):
pass
def valid_email(value: str | None) -> str | None:
value = str(value or "").strip()
if (len(value) <= 254 and re.fullmatch(r"[^@\s<>;,\"\\]+@[^@\s<>;,\"\\]+\.[^@\s<>;,\"\\]+", value)
and all(32 < ord(char) < 127 for char in value)):
return value
return None
def month_label(value: str) -> str:
return datetime.strptime(value, "%Y-%m").strftime("%B %Y")
def number(value: float) -> str:
return f"{value:,.0f}"
def document(*, title: str, intro: str, content: str, action: str, url: str, footer: str, kicker: str = 'YOUR MONTH IN VIEWING') -> str:
esc = html.escape
return f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>{esc(title)}</title><style>@media(max-width:280px){{.email-metrics td{{display:block!important;width:auto!important;padding:16px 0!important}}.email-metrics tr{{display:block!important}}}}</style></head>
<body style="margin:0;padding:0;background:#131315;color:#e5e1e4;font-family:Arial,Helvetica,sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#131315"><tr><td align="center" style="padding:24px 12px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;table-layout:fixed;background:#1c1b1d;border:1px solid #363338;border-radius:16px">
<tr><td style="padding:32px 24px 8px;color:#c7bdff;font-size:12px;letter-spacing:2px;font-weight:bold">MAGENT <span style="color:#918b98;letter-spacing:0">/ {esc(kicker)}</span></td></tr>
<tr><td style="padding:12px 24px"><h1 style="margin:0 0 16px;font-size:32px;line-height:1.2;color:#f3eef6">{esc(title)}</h1><p style="margin:0;color:#bdb6c3;font-size:15px;line-height:1.7;overflow-wrap:anywhere">{esc(intro)}</p></td></tr>
<tr><td style="padding:12px 24px">{content}</td></tr>
<tr><td style="padding:20px 24px 32px"><a href="{esc(url, quote=True)}" style="display:inline-block;padding:15px 22px;border-radius:8px;background:#c7bdff;color:#211b30;text-decoration:none;font-size:14px;font-weight:bold">{esc(action)} &#8599;</a></td></tr>
</table><table role="presentation" width="600" style="width:100%;max-width:600px"><tr><td style="padding:22px 18px;color:#a69fac;font-size:12px;line-height:1.7;text-align:center">{footer}</td></tr></table>
</td></tr></table></body></html>'''
def render_confirmation(username: str, url: str) -> dict:
title = "Your month, delivered."
intro = f"Hi {username}, confirm this email address to receive personal viewing reports from Magent. You choose whether to request them yourself or also receive automatic monthly emails."
text = f"{intro}\n\nConfirm email recaps: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email. No viewing history will be emailed until you confirm."
body = document(title=title, intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">Minutes watched, movies, episodes, your longest run and requests — with a link to your full monthly report.</p>',
action="Confirm email recaps", url=url,
footer="This link expires in 24 hours. If you did not request this, ignore this email.<br>No viewing history will be emailed until you confirm.")
return {"subject": "Confirm your Magent email recaps", "body_text": text, "body_html": body}
def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: str, *, test: bool = False, requested: bool = False) -> dict:
esc = html.escape
month = month_label(report["month"])
previous = month_label(report["comparison_month"])
if report.get('is_partial'):
month += ' so far'
previous += ' (same elapsed period, capped at month end)' if report.get('comparison_capped') else ' (same elapsed period)'
summary = report["summary"]
metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
("Episodes played", "episodes", summary["episodes"]), ("Requests made", "requests", report["requests"]["total"]))
cells, lines = [], []
for label, key, value in metrics:
change = report["changes"][key]
difference = change["difference"]
comparison = ("No change" if difference == 0 else f"{'+' if difference > 0 else ''}{number(abs(difference))}")
if change["percent"] is not None and difference:
comparison += f" ({'+' if difference > 0 else ''}{abs(change['percent']):g}%)"
comparison += f" from {previous}"
lines.append(f"{label}: {number(value)}. {comparison}.")
cells.append(f'<td width="50%" valign="top" style="padding:16px 10px;border-bottom:1px solid #363338"><span style="color:#bdb6c3;font-size:12px">{label}</span><br><strong style="display:block;margin:10px 0;color:#e0d8ff;font-size:30px">{number(value)}</strong><span style="color:#a69fac;font-size:11px;line-height:1.6">{esc(comparison)}</span></td>')
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>'
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:
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>'
report_url = f"{public_url}/insights/reports?month={report['month']}"
intro = f"Hi {username}, heres your {month} in viewing. A little look back at the stories you spent time with."
footer = f'You enabled personal report emails from Magent.<br>Based on retained Jellystat history. Calendar months use UTC; request statuses are current.<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from recaps</a> · <a href="{esc(public_url + "/profile#monthly-recaps", quote=True)}" style="color:#c7bdff">Email preferences</a>'
if requested:
intro = 'You requested this report. ' + intro
if test:
intro = "This is your test recap. " + intro
body = document(title=month, intro=intro, content=content, action="Explore your full report", url=report_url, footer=footer)
text = '\n'.join([intro, '', *lines, '', habit, '', 'Most watched:',
*(f"{item['title']}: {number(item['minutes'])} minutes" for item in top), '',
f"Your full report: {report_url}", '', 'Based on retained Jellystat history. Calendar months use UTC; request statuses are current.',
f"Unsubscribe from recaps: {unsubscribe_url}", f"Email preferences: {public_url}/profile#monthly-recaps"])
return {"subject": f"{'[Test] ' if test else ''}Your {month} in viewing · Magent", "body_text": text, "body_html": body}
def send_email(recipient: str, rendered: dict, message_id: str, before_data=lambda: None) -> None:
"""Return only after SMTP accepts DATA. Never retry an ambiguous DATA disconnect.
A stable Message-ID aids diagnosis; it is not an SMTP deduplication guarantee.
See RFC 5321 §4.5.3.2.6 and Python's smtplib exception definitions.
"""
runtime = get_runtime_settings()
sender = valid_email(runtime.magent_notify_email_from_address)
if not sender or not valid_email(recipient):
raise DeliveryError("failed", "A valid sender and recipient email are required.")
message = EmailMessage(policy=SMTP_POLICY)
message["From"] = formataddr((str(runtime.magent_notify_email_from_name or "Magent").replace('\r', '').replace('\n', ''), sender))
message["To"], message["Subject"] = recipient, rendered["subject"]
message["Date"], message["Message-ID"] = formatdate(localtime=False), message_id
message["Auto-Submitted"], message["X-Auto-Response-Suppress"] = "auto-generated", "All"
message.set_content(rendered["body_text"])
message.add_alternative(rendered["body_html"], subtype="html")
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')
payload = message.as_bytes()
smtp, stage = None, "connect"
try:
kwargs = {"timeout": 30, "local_hostname": sender.split('@', 1)[1]}
if runtime.magent_notify_email_use_ssl:
smtp = smtplib.SMTP_SSL(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port,
context=ssl.create_default_context(), **kwargs)
else:
smtp = smtplib.SMTP(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port, **kwargs)
smtp.ehlo_or_helo_if_needed()
if runtime.magent_notify_email_use_tls and not runtime.magent_notify_email_use_ssl:
smtp.starttls(context=ssl.create_default_context())
smtp.ehlo()
if runtime.magent_notify_email_smtp_username:
smtp.login(runtime.magent_notify_email_smtp_username, runtime.magent_notify_email_smtp_password)
code, reply = smtp.mail(sender)
if code != 250:
raise smtplib.SMTPResponseException(code, reply)
code, reply = smtp.rcpt(recipient)
if code not in (250, 251):
raise smtplib.SMTPResponseException(code, reply)
before_data()
stage = "data"
code, reply = smtp.data(payload)
if code != 250:
raise smtplib.SMTPDataError(code, reply)
stage = "accepted"
except smtplib.SMTPResponseException as exc:
state = "retry" if 400 <= exc.smtp_code < 500 else "failed"
raise DeliveryError(state, f"Mail server returned SMTP {exc.smtp_code}.") from exc
except (ssl.SSLError, smtplib.SMTPNotSupportedError, UnicodeError, ValueError) as exc:
raise DeliveryError("failed", "Check the SMTP security and sender settings.") from exc
except (OSError, smtplib.SMTPException) as exc:
state = "unknown" if stage == "data" else "retry"
detail = "Mail server acceptance is unknown; check its logs before taking further action." if state == "unknown" else "Could not reach or finish connecting to the mail server."
raise DeliveryError(state, detail) from exc
finally:
if smtp:
# A failed QUIT after a 250 DATA response must not turn an accepted email into a retry.
with suppress(Exception):
smtp.quit()
with suppress(Exception):
smtp.close()
def message_id(delivery_id: str, public_url: str) -> str:
host = urlsplit(public_url).hostname or "magent.local"
return f"<magent-recap-{delivery_id}@{host}>"
+243
View File
@@ -0,0 +1,243 @@
"""Durable consent, schedule and delivery records for personal email recaps."""
import hashlib
import secrets
import sqlite3
import uuid
from contextlib import closing, contextmanager
from datetime import datetime
from .. import db
from .monthly_reports import shift_month
from . import email_queue
def init_schema(conn: sqlite3.Connection) -> None:
for statement in (
"""CREATE TABLE IF NOT EXISTS email_recap_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0,
day INTEGER NOT NULL DEFAULT 2, hour INTEGER NOT NULL DEFAULT 9,
public_url TEXT NOT NULL DEFAULT '', next_send_at REAL)""",
"INSERT OR IGNORE INTO email_recap_settings (id) VALUES (1)",
"""CREATE TABLE IF NOT EXISTS email_recap_subscriptions (
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
"""CREATE TABLE IF NOT EXISTS email_recap_deliveries (
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
month TEXT NOT NULL, kind TEXT NOT NULL, email TEXT NOT NULL,
subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
"CREATE INDEX IF NOT EXISTS idx_email_recap_queue ON email_recap_deliveries (state, next_attempt_at)",
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_changed AFTER UPDATE OF email, is_blocked ON users
WHEN LOWER(TRIM(COALESCE(NEW.email, ''))) != LOWER(TRIM(COALESCE(OLD.email, '')))
OR NEW.is_blocked = 1
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = NEW.id; END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_deleted AFTER DELETE ON users
BEGIN DELETE FROM email_recap_subscriptions WHERE user_id = OLD.id;
UPDATE email_recap_deliveries SET state = 'cancelled', detail = 'Account removed.'
WHERE user_id = OLD.id AND state IN ('queued', 'retry', 'preparing'); END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_changed AFTER UPDATE ON jellyfin_user_links
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source
OR NEW.local_user_id != OLD.local_user_id
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_deleted AFTER DELETE ON jellyfin_user_links
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
):
conn.execute(statement)
columns = {row[1] for row in conn.execute('PRAGMA table_info(email_recap_subscriptions)')}
if 'automatic_monthly' not in columns:
conn.execute('ALTER TABLE email_recap_subscriptions ADD COLUMN automatic_monthly INTEGER NOT NULL DEFAULT 1')
@contextmanager
def transaction():
with closing(db._connect()) as conn, conn:
conn.row_factory = sqlite3.Row
conn.execute("BEGIN IMMEDIATE")
yield conn
def read_one(sql: str, args=()) -> dict | None:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(sql, args).fetchone()
return dict(row) if row else None
def settings() -> dict:
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
def next_due(now: datetime, day: int, hour: int) -> datetime:
due = shift_month(now, 0).replace(day=day, hour=hour)
return due if due > now else shift_month(now, 1).replace(day=day, hour=hour)
def save_settings(values: dict, now: datetime) -> dict:
with transaction() as conn:
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
due = old["next_send_at"]
if not values["enabled"]:
due = None
elif not old["enabled"] or changed:
due = next_due(now, values["day"], values["hour"]).timestamp()
conn.execute("UPDATE email_recap_settings SET enabled=?, day=?, hour=?, public_url=?, next_send_at=? WHERE id=1",
(values["enabled"], values["day"], values["hour"], values["public_url"], due))
if not values["enabled"] or changed:
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Schedule paused or changed.', updated_at=?
WHERE kind='scheduled' AND state IN ('queued', 'retry', 'preparing')""", (now.timestamp(),))
return settings()
def subscription(user_id: int) -> dict | None:
return read_one("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user_id,))
def disable(user_id: int) -> None:
with transaction() as conn:
conn.execute("UPDATE email_recap_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Email recaps turned off.'
WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
def request_confirmation(user: dict, source: str, identity: str, now: float, automatic_monthly: bool = True) -> str:
token = secrets.token_urlsafe(32)
with transaction() as conn:
old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
if old and old["requested_at"] > now - 300:
raise ValueError("Please wait five minutes before requesting another confirmation email.")
conn.execute("""INSERT INTO email_recap_subscriptions
(user_id, state, email, identity_source, identity_id, version, confirmation_hash,
confirmation_expires, requested_at, confirmed_at, unsubscribe_token)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT(user_id) DO UPDATE SET state='pending', email=excluded.email,
identity_source=excluded.identity_source, identity_id=excluded.identity_id, version=excluded.version,
confirmation_hash=excluded.confirmation_hash, confirmation_expires=excluded.confirmation_expires,
requested_at=excluded.requested_at, confirmed_at=NULL, unsubscribe_token=excluded.unsubscribe_token""",
(user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (automatic_monthly, user['id']))
return token
def token_subscription(token: str, action: str) -> dict | None:
if action == "confirm":
return read_one("SELECT * FROM email_recap_subscriptions WHERE confirmation_hash=?",
(hashlib.sha256(token.encode()).hexdigest(),))
return read_one("SELECT * FROM email_recap_subscriptions WHERE unsubscribe_token=?", (token,))
def confirm(sub: dict, now: float) -> bool:
with transaction() as conn:
# Recheck address and blocked state in the same transaction as the consent write.
result = conn.execute("""UPDATE email_recap_subscriptions SET state='enabled', confirmed_at=?, confirmation_hash=NULL
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
AND EXISTS (SELECT 1 FROM users WHERE users.id=user_id AND is_blocked=0
AND LOWER(TRIM(users.email))=LOWER(TRIM(email_recap_subscriptions.email)))""",
(now, sub["user_id"], sub["version"], now))
return result.rowcount == 1
def _enqueue(conn, sub: dict, month: str, kind: str, key: str, public_url: str, now: float) -> str:
delivery_id = uuid.uuid4().hex
conn.execute("""INSERT OR IGNORE INTO email_recap_deliveries
(id, dedupe_key, user_id, month, kind, email, subscription_version, public_url,
created_at, updated_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(delivery_id, key, sub["user_id"], month, kind, sub["email"], sub["version"], public_url, now, now, now))
return conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()[0]
def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float, kind: str = "test") -> str:
key = f"{kind}:{sub['user_id']}:{request_id}"
with transaction() as conn:
existing = conn.execute("SELECT id,month,subscription_version FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
if existing:
if existing['month'] != month or existing['subscription_version'] != sub['version']:
raise ValueError('This send request was already used. Refresh before requesting another report.')
return existing[0]
recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind IN ('test','on_demand') AND created_at>?",
(sub["user_id"], now - 300)).fetchone()
if recent:
raise ValueError("Please wait five minutes between report emails.")
return _enqueue(conn, sub, month, kind, key, public_url, now)
def enqueue_due(now: datetime) -> int:
with transaction() as conn:
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
return 0
# After long downtime, send only the latest due recap; never backfill a pile of old emails.
due = shift_month(now, 0).replace(day=config["day"], hour=config["hour"])
if due > now:
due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
month = shift_month(due, -1).strftime("%Y-%m")
subs = conn.execute("SELECT * FROM email_recap_subscriptions WHERE state='enabled' AND automatic_monthly=1 AND confirmed_at<=?", (due.timestamp(),)).fetchall()
before = conn.total_changes
for sub in subs:
_enqueue(conn, dict(sub), month, "scheduled", f"scheduled:{sub['user_id']}:{month}", config["public_url"], now.timestamp())
count = conn.total_changes - before
conn.execute("UPDATE email_recap_settings SET next_send_at=? WHERE id=1",
(next_due(now, config["day"], config["hour"]).timestamp(),))
return count
def claim_delivery(now: float) -> dict | None:
with transaction() as conn:
return email_queue.claim(conn, "email_recap_deliveries", now)
def begin_sending(delivery: dict, now: float) -> bool:
with transaction() as conn:
# Consent may have changed while the report or SMTP connection was being prepared.
result = conn.execute("""UPDATE email_recap_deliveries SET state='sending', updated_at=?, lease_until=?
WHERE id=? AND claim=? AND state='preparing'
AND EXISTS (SELECT 1 FROM email_recap_subscriptions s JOIN users u ON u.id=s.user_id
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
WHERE s.user_id=email_recap_deliveries.user_id AND s.state='enabled'
AND s.version=email_recap_deliveries.subscription_version AND u.is_blocked=0
AND (email_recap_deliveries.kind!='scheduled' OR s.automatic_monthly=1)
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
AND EXISTS (SELECT 1 FROM email_recap_settings c WHERE c.id=1 AND c.public_url=email_recap_deliveries.public_url
AND (email_recap_deliveries.kind IN ('test','on_demand') OR c.enabled=1))""", (now, now + 1800, delivery["id"], delivery["claim"]))
return result.rowcount == 1
def finish(delivery: dict, state: str, detail: str, now: float, delay: int = 0) -> None:
with transaction() as conn:
email_queue.finish(conn, "email_recap_deliveries", delivery, state, detail, now, delay)
def history(limit: int = 50, offset: int = 0) -> dict:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute("""SELECT d.id, d.month, d.kind, d.email, d.state, d.attempts, d.created_at, d.updated_at,
d.next_attempt_at, d.detail, u.username FROM email_recap_deliveries d LEFT JOIN users u ON u.id=d.user_id
ORDER BY d.created_at DESC, d.id LIMIT ? OFFSET ?""", (limit, offset)).fetchall()
total = conn.execute("SELECT COUNT(*) FROM email_recap_deliveries").fetchone()[0]
subscribers = conn.execute("SELECT COUNT(*) FROM email_recap_subscriptions WHERE state='enabled'").fetchone()[0]
return {"deliveries": [dict(row) for row in rows], "total": total, "subscribers": subscribers}
def set_automatic(user_id: int, enabled: bool):
with transaction() as conn:
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (enabled, user_id))
if not enabled:
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled',detail='Automatic monthly emails turned off.'
WHERE user_id=? AND kind='scheduled' AND state IN ('queued','retry','preparing')""", (user_id,))
def personal_history(user_id: int) -> list[dict]:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute("""SELECT id,month,kind,state,created_at,detail
FROM email_recap_deliveries WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT 5""", (user_id,))]
+60
View File
@@ -0,0 +1,60 @@
"""Explicit original-language requests without changing shared quality defaults."""
import asyncio
import copy
import hashlib
import json
import re
import httpx
from fastapi import HTTPException
_profile_lock = asyncio.Lock()
_prefix = "Magent Original "
def language_info(details):
code = str(details.get("originalLanguage") or details.get("original_language") or "").lower()
if not re.fullmatch(r"[a-z]{2}", code) or code in {"en", "xx", "zz"}:
return None
return {"code": code}
def profile_body(profile):
return {key: copy.deepcopy(value) for key, value in profile.items() if key not in {"id", "name"}}
def profile_name(body):
return _prefix + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
def is_original_profile(profile):
return ((profile.get("language") or {}).get("id") == -2
and profile.get("name") == profile_name(profile_body(profile)))
async def original_profile(client, default_id):
# Reuse immutable copies; never edit a profile already used by other titles.
async with _profile_lock:
try:
profiles = await client.get_quality_profiles()
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not load the language profile. Try again.") from exc
if not isinstance(profiles, list):
raise HTTPException(502, "Radarr returned invalid quality profiles.")
default = next((p for p in profiles if p.get("id") == default_id), None)
if not default:
raise HTTPException(409, "The default quality profile changed. Reload the request.")
body = profile_body(default)
body["language"] = {"id": -2, "name": "Original"}
name = profile_name(body)
match = next((p for p in profiles if p.get("name") == name and profile_body(p) == body), None)
if match:
return match["id"]
try:
result = await client.post("/api/v3/qualityprofile", payload={**body, "name": name})
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") from exc
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
return result["id"]
+39 -7
View File
@@ -803,7 +803,7 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result, {"ok": True})
self.assertEqual(captured["payload"], {"mediaType": "movie", "mediaId": 209112})
async def test_request_destination_only_offers_live_sonarr_profiles(self) -> None:
async def test_request_destination_uses_admin_default_before_seerr(self) -> None:
runtime = SimpleNamespace(
sonarr_base_url="http://sonarr.test",
sonarr_api_key="key",
@@ -818,7 +818,7 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
"name": "Main Sonarr",
"isDefault": True,
"is4k": False,
"activeProfileId": 7,
"activeProfileId": 10,
"activeDirectory": "/tv",
}
]
@@ -834,10 +834,10 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
with patch.object(requests_router, "SonarrClient", return_value=sonarr):
destination = await requests_router._resolve_request_destination(
runtime, seerr, "tv", requested_profile_id=10
runtime, seerr, "tv"
)
self.assertEqual(destination["profile_id"], 10)
self.assertEqual(destination["profile_id"], 7)
self.assertEqual(destination["default_profile_id"], 7)
self.assertEqual(destination["root_folder"], "/tv")
self.assertEqual(destination["profiles"], [
@@ -845,11 +845,43 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
{"id": 10, "name": "Optimal"},
])
async def test_request_defaults_inherit_seerr_only_when_unset(self) -> None:
for media_type, service in [('movie', 'radarr'), ('tv', 'sonarr')]:
runtime = SimpleNamespace(**{
service + '_base_url': 'http://collector.test', service + '_api_key': 'key',
service + '_quality_profile_id': None, service + '_root_folder': '/media',
})
seerr = SimpleNamespace(get_service_settings=AsyncMock(return_value=[{
'id': 1, 'isDefault': True, 'activeProfileId': 7, 'activeDirectory': '/media',
}]))
collector = SimpleNamespace(configured=lambda: True,
get_quality_profiles=AsyncMock(return_value=[{'id': 7, 'name': 'HD'}]),
get_root_folders=AsyncMock(return_value=[{'path': '/media'}]))
with patch.object(requests_router, 'RadarrClient' if service == 'radarr' else 'SonarrClient', return_value=collector):
result = await requests_router._resolve_request_destination(runtime, seerr, media_type)
self.assertEqual(result['profile_id'], 7)
seerr.get_service_settings.return_value[0]['activeProfileId'] = 999
with self.assertRaises(HTTPException):
await requests_router._resolve_request_destination(runtime, seerr, media_type)
async def test_request_creation_ignores_browser_quality_override(self) -> None:
runtime = SimpleNamespace(jellyseerr_base_url='http://seerr.test', jellyseerr_api_key='key')
seerr = SimpleNamespace(configured=lambda: True,
get_movie=AsyncMock(return_value={'title': 'Movie'}),
create_request=AsyncMock(return_value={'status': 1}))
destination = {'server_id': 1, 'profile_id': 7, 'root_folder': '/movies'}
with patch.object(requests_router, 'get_runtime_settings', return_value=runtime), \
patch.object(requests_router, 'JellyseerrClient', return_value=seerr), \
patch.object(requests_router, '_resolve_request_destination', new_callable=AsyncMock, return_value=destination) as resolve:
await requests_router.create_request({'mediaType': 'movie', 'tmdbId': 123, 'profileId': 999}, {'username': 'viewer'})
resolve.assert_awaited_once_with(runtime, seerr, 'movie')
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], 7)
async def test_request_destination_rejects_stale_profile_id(self) -> None:
runtime = SimpleNamespace(
radarr_base_url="http://radarr.test",
radarr_api_key="key",
radarr_quality_profile_id=6,
radarr_quality_profile_id=999,
radarr_root_folder="/movies",
)
seerr = SimpleNamespace(
@@ -875,10 +907,10 @@ class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
with patch.object(requests_router, "RadarrClient", return_value=radarr):
with self.assertRaises(HTTPException) as context:
await requests_router._resolve_request_destination(
runtime, seerr, "movie", requested_profile_id=999
runtime, seerr, "movie"
)
self.assertEqual(context.exception.status_code, 400)
self.assertEqual(context.exception.status_code, 409)
self.assertIn("not available in Radarr", context.exception.detail)
+168
View File
@@ -0,0 +1,168 @@
import json
import sqlite3
import unittest
from unittest.mock import AsyncMock, patch
from types import SimpleNamespace
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.feature_access import permissions, update_permissions
from backend.app.routers import identities
from backend.app.services import duplicate_accounts as duplicates, identity_review as review
from backend.app.services.jellyfin_identity import link_user
from backend.tests.test_backend_quality import TempDatabaseMixin
JF, SERVER = 'a' * 32, 'b' * 32
class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
self.keep = db.get_user_by_username('Viewer')['id']
with db._connect() as conn:
self.extra = conn.execute("""INSERT INTO users(username,password_hash,role,auth_provider,
jellyseerr_user_id,created_at) VALUES('viewer ','old-hash','user','jellyfin',42,'2026-01-01')""").lastrowid
self.runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test',
jellyseerr_base_url='http://seerr', jellyseerr_api_key='test', jellystat_base_url='http://stats', jellystat_api_key='test')
link_user('Viewer', JF, 'http://jf')
self.jf = {'state': 'available', 'server_id': SERVER, 'users': [{'id': JF, 'name': 'Viewer'}]}
self.seerr = {'state': 'available', 'users': [{'id': 42, 'name': 'Viewer', 'jellyfin_id': JF}]}
for name, value in [('get_runtime_settings', self.runtime), ('jellyfin_directory', self.jf), ('seerr_directory', self.seerr)]:
mocked = patch.object(review, name, return_value=value)
mocked.start(); self.addCleanup(mocked.stop)
mocked = patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock,
return_value={JF: {'state': 'matched', 'id': JF}})
mocked.start(); self.addCleanup(mocked.stop)
async def test_consolidation_preserves_history_and_restrictive_access(self):
with db._connect() as conn:
conn.execute('UPDATE users SET auto_search_enabled=0,expires_at=? WHERE id=?', ('2026-01-01T00:00:00+00:00', self.extra))
conn.execute('INSERT INTO user_feature_permissions VALUES(?,?,?)', (self.extra, 'issues', 0))
db.upsert_user_activity('Viewer', '127.0.0.1', 'test')
db.upsert_user_activity('viewer ', '127.0.0.1', 'test')
item = db.create_portal_item(kind='issue', title='Issue', description='History', created_by_username='viewer ', created_by_id=42)
before = review.read_snapshot()
preview = await duplicates.repair_duplicates(self.extra)
self.assertEqual(review.read_snapshot(), before, 'Preview must not mutate accounts')
self.assertTrue(preview['can_confirm'], preview['issues'])
self.assertEqual(preview['keep_id'], self.keep)
self.assertNotIn('old-hash', json.dumps(preview))
result = await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
self.assertEqual(result['consolidated'], 1)
self.assertIsNone(db.get_user_by_id(self.extra))
user = db.get_user_by_username('Viewer')
self.assertEqual(user['id'], self.keep)
self.assertFalse(user['auto_search_enabled'])
self.assertFalse(permissions(user)['issues'])
self.assertTrue(user['is_expired'])
self.assertEqual(db.get_portal_item(item['id'])['created_by_username'], 'Viewer')
self.assertEqual(db.get_portal_item(item['id'])['created_by_id'], 42, 'IDs here belong to Seerr')
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT SUM(hit_count) FROM user_activity').fetchone()[0], 2)
archive = json.loads(conn.execute('SELECT archive_json FROM user_duplicate_repairs').fetchone()[0])
self.assertEqual(len(archive['users']), 2)
self.assertEqual(conn.execute('SELECT local_user_id FROM jellyfin_user_links').fetchone()[0], self.keep)
report, _, _ = await review.review_identities()
self.assertEqual(next(row for row in report['rows'] if row['user']['id'] == self.keep)['state'], 'confirmed')
self.assertFalse(db.create_user_if_missing('VIEWER ', 'unused', auth_provider='jellyfin'))
async def test_choose_other_row_retains_its_settings_and_moves_link(self):
with db._connect() as conn:
conn.execute('UPDATE users SET email=? WHERE id=?', ('chosen@example.test', self.extra))
preview = await duplicates.repair_duplicates(self.keep, self.extra)
self.assertEqual(preview['proposed']['email'], 'chosen@example.test')
await duplicates.repair_duplicates(self.keep, self.extra, preview['revision'], {'username': 'admin'})
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.extra)
self.assertEqual(db.get_user_by_id(self.extra)['username'], 'Viewer')
async def test_changed_permission_or_identity_rejects_stale_preview(self):
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
update_permissions({'stats': False}, 'Viewer')
with self.assertRaises(HTTPException) as caught:
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
self.assertEqual(caught.exception.status_code, 409)
self.assertIsNotNone(db.get_user_by_id(self.extra))
self.seerr['users'][0]['jellyfin_id'] = 'c' * 32
with self.assertRaises(HTTPException):
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
async def test_conflicting_identities_admins_and_other_owners_are_blocked(self):
with db._connect() as conn:
conn.execute("UPDATE users SET role='admin' WHERE id=?", (self.extra,))
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
with db._connect() as conn:
conn.execute("UPDATE users SET role='user',jellyseerr_user_id=99 WHERE id=?", (self.extra,))
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
with db._connect() as conn:
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
preview, report, local, runtime, state = await duplicates.prepare(self.keep)
with db._connect() as conn:
conn.execute("CREATE TRIGGER prevent_test_delete BEFORE DELETE ON users BEGIN SELECT RAISE(ABORT,'fixture failure'); END")
with self.assertRaises(sqlite3.IntegrityError):
duplicates.consolidate(preview, report, local, runtime, state, {'username': 'admin'})
self.assertIsNotNone(db.get_user_by_id(self.extra))
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_duplicate_repairs').fetchone()[0], 0)
async def test_creation_rejects_case_and_whitespace_variants(self):
for name in ('viewer', 'VIEWER', ' Viewer '):
self.assertFalse(db.create_user_if_missing(name, 'unused'))
with self.assertRaises(sqlite3.IntegrityError):
db.create_user(name, 'unused')
async def test_unresolved_whitespace_accounts_keep_distinct_lookup(self):
self.assertEqual(db.get_user_by_username('Viewer')['id'], self.keep)
self.assertEqual(db.get_user_by_username('viewer ')['id'], self.extra)
self.assertIsNone(db.get_user_by_username(' Viewer '), 'Do not guess between unresolved identities')
async def test_concurrent_imports_create_only_one_normalized_account(self):
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda name: db.create_user_if_missing(name, 'Password-123456!'), ['New viewer', 'NEW VIEWER ']))
self.assertEqual(sorted(results), [False, True])
def seed_delivery(self, state='queued'):
with db._connect() as conn:
for prefix in ('email_recap', 'newsletter'):
for identity in (self.keep, self.extra):
conn.execute(f'''INSERT INTO {prefix}_subscriptions(user_id,state,email,identity_source,identity_id,
version,requested_at,unsubscribe_token) VALUES(?,?,?,?,?,?,?,?)''',
(identity, 'enabled', 'viewer@example.test', review.source_key('http://jf'), JF, str(identity), 1, prefix + str(identity)))
period = {'month': '2026-08'} if prefix == 'email_recap' else {'edition_id': 'edition', 'edition_revision': 1}
values = {'id': prefix, 'dedupe_key': prefix, 'user_id': self.extra, **period, 'kind': 'test',
'email': 'viewer@example.test', 'subscription_version': str(self.extra), 'public_url': 'https://example.test',
'state': state, 'created_at': 1, 'updated_at': 1, 'next_attempt_at': 1}
conn.execute(f"INSERT INTO {prefix}_deliveries({','.join(values)}) VALUES({','.join('?' for _ in values)})", tuple(values.values()))
async def test_email_history_retained_pending_cancelled_and_consent_not_inherited(self):
self.seed_delivery()
preview = await duplicates.repair_duplicates(self.extra)
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
with db._connect() as conn:
for prefix in ('email_recap', 'newsletter'):
delivery = conn.execute(f'SELECT user_id,state FROM {prefix}_deliveries').fetchone()
self.assertEqual(delivery, (self.keep, 'cancelled'))
subs = conn.execute(f'SELECT user_id,state FROM {prefix}_subscriptions').fetchall()
self.assertEqual(subs, [(self.keep, 'enabled')])
async def test_sending_email_blocks_repair_without_removing_accounts(self):
self.seed_delivery('sending')
preview = await duplicates.repair_duplicates(self.extra)
with self.assertRaises(HTTPException) as caught:
await duplicates.repair_duplicates(self.extra, self.keep, preview['revision'], {'username': 'admin'})
self.assertEqual(caught.exception.status_code, 409)
self.assertIsNotNone(db.get_user_by_id(self.extra))
async def test_duplicate_endpoints_are_admin_only(self):
app = FastAPI(); app.include_router(identities.router)
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
with TestClient(app) as client:
for path in ('check', 'confirm'):
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
+575
View File
@@ -0,0 +1,575 @@
import asyncio
import json
import re
import smtplib
import socketserver
import threading
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from email import policy
from email.parser import BytesParser
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlsplit
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.clients.jellystat import HistoryLimitError, JellystatError
from backend.app.routers import recaps as router
from backend.app.services import email_recaps as recaps, recap_email as mail, recap_store as store
from backend.app.services.jellyfin_identity import link_user, source_key
from backend.app.services.monthly_reports import change, month_periods, shift_month
from backend.tests.test_backend_quality import TempDatabaseMixin
def fixture_report():
periods = month_periods(None, datetime.now(timezone.utc))
summary = dict(minutes=1500, movies=8, episodes=24, plays=35, active_days=20, longest_streak=6)
changes = {key: change(value, round(value / 2)) for key, value in summary.items()}
changes['requests'] = change(3, 2)
return {**periods, 'state': 'ready', 'summary': summary, 'changes': changes, 'requests': {'total': 3},
'top_titles': [{'title': 'Severance', 'type': 'series', 'minutes': 460, 'plays': 10},
{'title': 'Arrival', 'type': 'movie', 'minutes': 116, 'plays': 1}],
'recent': [{'artwork_url': '/insights/artwork/SECRET?token=PRIVATE-TOKEN'}]}
def runtime():
return SimpleNamespace(jellyfin_base_url='http://jellyfin', jellystat_base_url='http://jellystat',
jellystat_api_key='PRIVATE-STATS-KEY', magent_notify_enabled=True, magent_notify_email_enabled=True,
magent_notify_email_smtp_host='127.0.0.1', magent_notify_email_smtp_port=1,
magent_notify_email_smtp_username='', magent_notify_email_smtp_password='',
magent_notify_email_from_address='magent@example.test', magent_notify_email_from_name='Magent',
magent_notify_email_use_tls=False, magent_notify_email_use_ssl=False)
class RecapFixture(TempDatabaseMixin):
def setUp(self):
super().setUp()
db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
link_user('viewer', 'jf-viewer', 'http://jellyfin')
self.user = db.get_user_by_username('viewer')
self.runtime = runtime()
for target, name, value in [(recaps, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime),
(recaps, 'smtp_email_config_ready', (True, 'ok'))]:
mocked = patch.object(target, name, return_value=value)
mocked.start(); self.addCleanup(mocked.stop)
env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
env.start(); self.addCleanup(env.stop)
self.config = dict(enabled=False, day=2, hour=9, public_url='https://beta.example.test')
store.save_settings(self.config, datetime.now(timezone.utc))
self.report = fixture_report()
def subscribe(self, timestamp=None):
now = time.time() if timestamp is None else timestamp
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', now)
sub = store.subscription(self.user['id'])
self.assertTrue(store.confirm(sub, now + 1))
return store.subscription(self.user['id']), token
def queue(self, sub=None, request_id='request-1'):
if sub is None:
sub, _ = self.subscribe()
return store.enqueue_test(sub, self.report['month'], request_id, self.config['public_url'], time.time())
def delivery(self, delivery_id):
return store.read_one('SELECT * FROM email_recap_deliveries WHERE id=?', (delivery_id,))
class RecapConsentTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def test_opt_in_only_emails_confirmation_and_check_link_does_not_confirm(self):
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
result = await recaps.subscribe(self.user)
self.assertEqual(result['state'], 'pending')
report.assert_not_called()
recipient, rendered, _ = sender.call_args.args
self.assertEqual(recipient, 'viewer@example.test')
self.assertNotIn('Severance', rendered['body_html'])
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
token = parse_qs(urlsplit(url).fragment)['token'][0]
self.assertNotIn(token, store.subscription(self.user['id'])['confirmation_hash'])
self.assertEqual(recaps.token_action(token, 'confirm')['state'], 'ready')
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
self.assertEqual(recaps.token_action(token, 'confirm', apply=True)['state'], 'enabled')
with self.assertRaises(recaps.RecapError):
recaps.token_action(token, 'confirm', apply=True)
with self.assertRaises(recaps.RecapError):
recaps.token_action(token, 'unsubscribe', apply=True)
async def test_confirmation_failure_is_pending_and_resend_is_rate_limited(self):
with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'unknown')):
with self.assertRaises(recaps.RecapError) as exc:
await recaps.subscribe(self.user)
self.assertEqual(exc.exception.status, 502)
self.assertEqual(recaps.preferences(self.user)['state'], 'pending')
with patch.object(mail, 'send_email') as sender:
with self.assertRaises(recaps.RecapError) as exc:
await recaps.subscribe(self.user)
self.assertEqual(exc.exception.status, 429)
sender.assert_not_called()
def test_unsubscribe_is_public_idempotent_and_cancels_queued_email(self):
sub, _ = self.subscribe()
delivery_id = self.queue(sub)
token = sub['unsubscribe_token']
self.assertEqual(recaps.token_action(token, 'unsubscribe')['state'], 'ready')
self.assertEqual(self.delivery(delivery_id)['state'], 'queued')
recaps.token_action(token, 'unsubscribe', apply=True)
self.assertEqual(recaps.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
def test_expired_confirmation_does_not_subscribe(self):
token = store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time() - 90000)
self.assertEqual(recaps.preferences(self.user)['state'], 'expired')
with self.assertRaises(recaps.RecapError):
recaps.token_action(token, 'confirm', apply=True)
def test_email_change_back_does_not_restore_consent(self):
self.subscribe()
db.set_user_email('viewer', 'changed@example.test')
db.set_user_email('viewer', 'viewer@example.test')
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
def test_changed_link_or_source_requires_new_consent(self):
self.subscribe()
with store.transaction() as conn:
conn.execute("UPDATE jellyfin_user_links SET jellyfin_user_id='new-identity' WHERE local_user_id=?", (self.user['id'],))
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
with store.transaction() as conn:
conn.execute("UPDATE email_recap_subscriptions SET state='enabled'")
self.runtime.jellyfin_base_url = 'http://other-jellyfin'
self.assertEqual(recaps.preferences(self.user)['state'], 'off')
def test_missing_email_or_stored_identity_cannot_subscribe(self):
db.set_user_email('viewer', None)
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
db.set_user_email('viewer', 'viewer@example.test')
with store.transaction() as conn:
conn.execute('DELETE FROM jellyfin_user_links')
self.assertFalse(recaps.preferences(self.user)['can_subscribe'])
def test_confirmation_rechecks_email_atomically(self):
store.request_confirmation(self.user, source_key('http://jellyfin'), 'jf-viewer', time.time())
old = store.subscription(self.user['id'])
db.set_user_email('viewer', 'different@example.test')
self.assertFalse(store.confirm(old, time.time()))
class RecapScheduleTests(RecapFixture, unittest.TestCase):
def test_defaults_are_paused_and_no_users_are_opted_in(self):
self.assertFalse(store.settings()['enabled'])
self.assertEqual(store.history()['subscribers'], 0)
self.assertEqual(store.enqueue_due(datetime.now(timezone.utc)), 0)
def test_utc_next_send_month_end_leap_year_and_new_year(self):
for now, expected in [
(datetime(2026, 12, 31, tzinfo=timezone.utc), '2027-01-02T09:00:00+00:00'),
(datetime(2024, 2, 29, tzinfo=timezone.utc), '2024-03-02T09:00:00+00:00'),
(datetime(2026, 9, 2, 8, tzinfo=timezone.utc), '2026-09-02T09:00:00+00:00'),
(datetime(2026, 9, 2, 9, tzinfo=timezone.utc), '2026-10-02T09:00:00+00:00')]:
self.assertEqual(store.next_due(now, 2, 9).isoformat(), expected)
def test_schedule_catches_up_once_and_excludes_late_subscribers(self):
before = datetime(2026, 8, 30, tzinfo=timezone.utc)
self.subscribe(before.timestamp())
config = store.save_settings({**self.config, 'enabled': True}, before)
self.assertEqual(config['next_send_at'], datetime(2026, 9, 2, 9, tzinfo=timezone.utc).timestamp())
db.create_user('late', 'Example-Password123!', email='late@example.test')
late = db.get_user_by_username('late')
store.request_confirmation(late, 'source', 'late-id', datetime(2026, 9, 2, 10, tzinfo=timezone.utc).timestamp())
store.confirm(store.subscription(late['id']), datetime(2026, 9, 2, 11, tzinfo=timezone.utc).timestamp())
now = datetime(2026, 9, 5, tzinfo=timezone.utc)
with ThreadPoolExecutor(max_workers=4) as pool:
counts = list(pool.map(store.enqueue_due, [now] * 4))
self.assertEqual(sum(counts), 1)
rows = store.history()['deliveries']
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['month'], '2026-08')
self.assertEqual(rows[0]['email'], 'viewer@example.test')
# Revisit the same due date after a restart: the durable unique key still wins.
with store.transaction() as conn:
conn.execute('UPDATE email_recap_settings SET next_send_at=?', (config['next_send_at'],))
self.assertEqual(store.enqueue_due(now), 0)
def test_long_downtime_does_not_backfill_multiple_months(self):
before = datetime(2026, 5, 1, tzinfo=timezone.utc)
self.subscribe(before.timestamp())
store.save_settings({**self.config, 'enabled': True}, before)
self.assertEqual(store.enqueue_due(datetime(2026, 9, 9, tzinfo=timezone.utc)), 1)
self.assertEqual(store.history()['deliveries'][0]['month'], '2026-08')
def test_enable_after_due_date_waits_and_pause_cancels_pending_monthlies(self):
now = datetime(2026, 9, 9, tzinfo=timezone.utc)
self.subscribe(now.timestamp())
result = store.save_settings({**self.config, 'enabled': True}, now)
self.assertEqual(result['next_send_at'], datetime(2026, 10, 2, 9, tzinfo=timezone.utc).timestamp())
self.assertEqual(store.enqueue_due(now), 0)
store.enqueue_due(datetime(2026, 10, 3, tzinfo=timezone.utc))
store.save_settings(self.config, now)
self.assertEqual(store.history()['deliveries'][0]['state'], 'cancelled')
self.assertIsNone(store.settings()['next_send_at'])
class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def run_claim(self):
delivery = store.claim_delivery(time.time())
self.assertIsNotNone(delivery)
await recaps.process_delivery(delivery)
async def test_private_report_is_delivered_once_using_confirmed_account(self):
delivery_id = self.queue()
sent = []
def capture(recipient, rendered, message_id, before_data):
before_data()
self.assertEqual(self.delivery(delivery_id)['state'], 'sending')
sent.append((recipient, rendered, message_id))
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email', side_effect=capture):
await recaps.run_once()
await recaps.run_once()
self.assertEqual(len(sent), 1)
self.assertEqual(sent[0][0], 'viewer@example.test')
self.assertIn(f'?month={self.report["month"]}', sent[0][1]['body_html'])
self.assertNotIn('PRIVATE-TOKEN', json.dumps(sent))
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
self.assertEqual(self.delivery(delivery_id)['state'], 'sent')
self.assertNotIn('unsubscribe_token', json.dumps(store.history()))
def test_concurrent_claim_and_test_deduplication(self):
sub, _ = self.subscribe()
with ThreadPoolExecutor(max_workers=4) as pool:
ids = list(pool.map(lambda _: self.queue(sub), range(4)))
rows = list(pool.map(lambda _: store.claim_delivery(time.time()), range(4)))
self.assertEqual(len(set(ids)), 1)
self.assertEqual(sum(row is not None for row in rows), 1)
with self.assertRaises(ValueError):
self.queue(sub, 'another-click')
async def test_unsubscribe_or_email_change_during_report_prevents_sending(self):
delivery_id = self.queue()
async def report(*args):
db.set_user_email('viewer', 'other@example.test')
return self.report
def transport(recipient, rendered, message_id, before_data):
before_data()
self.fail('Private data must not reach SMTP DATA after an address change')
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
async def test_stats_permission_revoked_during_report_cancels_email(self):
from backend.app.feature_access import update_permissions
delivery_id = self.queue()
db.set_user_role('viewer', 'user')
async def report(*args):
update_permissions({'stats': False}, 'viewer')
return self.report
def transport(recipient, rendered, message_id, before_data):
before_data()
self.fail('Report must not be sent after stats permission is revoked')
with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
for kind in ['blocked', 'expired', 'deleted']:
with self.subTest(kind=kind):
# Each subcase starts with a fresh account and confirmed subscription.
db.create_user(kind, 'Example-Password123!', email=f'{kind}@example.test')
account = db.get_user_by_username(kind)
link_user(kind, f'jf-{kind}', 'http://jellyfin')
store.request_confirmation(account, source_key('http://jellyfin'), f'jf-{kind}', time.time())
store.confirm(store.subscription(account['id']), time.time())
delivery_id = self.queue(store.subscription(account['id']), kind)
with store.transaction() as conn:
if kind == 'blocked': conn.execute('UPDATE users SET is_blocked=1 WHERE id=?', (account['id'],))
elif kind == 'expired': conn.execute("UPDATE users SET expires_at='2000-01-01T00:00:00+00:00' WHERE id=?", (account['id'],))
else: conn.execute('DELETE FROM users WHERE id=?', (account['id'],))
with patch.object(mail, 'send_email') as sender, patch.object(recaps, 'get_monthly_report') as report:
await recaps.run_once()
sender.assert_not_called(); report.assert_not_called()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
async def test_known_temporary_failure_retries_three_times_with_stable_id(self):
delivery_id = self.queue()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('retry', 'SMTP 451')) as sender:
for attempt in range(1, 4):
await self.run_claim()
row = self.delivery(delivery_id)
self.assertEqual(row['attempts'], attempt)
self.assertEqual(row['state'], 'failed' if attempt == 3 else 'retry')
if attempt < 3:
self.assertGreater(row['next_attempt_at'], time.time() + 250)
with store.transaction() as conn:
conn.execute('UPDATE email_recap_deliveries SET next_attempt_at=0 WHERE id=?', (delivery_id,))
self.assertEqual(len(set(call.args[2] for call in sender.call_args_list)), 1)
self.assertIsNone(store.claim_delivery(time.time()))
async def test_ambiguous_smtp_failure_never_automatically_retries(self):
delivery_id = self.queue()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)), patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'Check mail logs')):
await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
self.assertIsNone(store.claim_delivery(time.time() + 86400))
def test_stale_worker_claims_are_recovered_without_resending_uncertain_mail(self):
delivery_id = self.queue()
first = store.claim_delivery(time.time())
second = store.claim_delivery(time.time() + 1801)
self.assertNotEqual(first['claim'], second['claim'])
self.assertFalse(store.begin_sending(first, time.time()))
self.assertTrue(store.begin_sending(second, time.time()))
store.claim_delivery(time.time() + 1801)
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
store.finish(first, 'sent', 'Old worker', time.time())
self.assertEqual(self.delivery(delivery_id)['state'], 'unknown')
async def test_partial_or_over_limit_report_is_not_emailed(self):
delivery_id = self.queue()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(side_effect=HistoryLimitError('limit'))), patch.object(mail, 'send_email') as sender:
await self.run_claim()
sender.assert_not_called()
self.assertEqual(self.delivery(delivery_id)['state'], 'failed')
class RecapApiTests(RecapFixture, unittest.TestCase):
def setUp(self):
super().setUp()
app = FastAPI()
app.include_router(router.router)
self.app = app
self.client = TestClient(app)
self.addCleanup(self.client.close)
def login(self, role='admin'):
self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role, 'features': {'stats': True}}
def test_authentication_roles_and_recipient_override(self):
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401)
self.assertEqual(self.client.get('/profile/email-recaps').status_code, 401)
self.login('user')
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 403)
self.assertEqual(self.client.get('/admin/email-recaps/preview').status_code, 403)
self.assertEqual(self.client.post('/admin/email-recaps/test', json={}).status_code, 403)
self.login()
result = self.client.get('/admin/email-recaps')
self.assertEqual(result.status_code, 200)
self.assertEqual(result.headers['cache-control'], 'no-store')
self.assertNotIn('PRIVATE-STATS-KEY', result.text)
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'recipient_email': 'other@example.test'})
self.assertEqual(result.status_code, 422)
result = self.client.put('/profile/email-recaps', json={'enabled': False, 'user_id': 5})
self.assertEqual(result.status_code, 422)
def test_url_and_schedule_validation_do_not_write_partial_settings(self):
self.login()
for value in ['javascript:alert(1)', 'https://user:secret@example.test', 'https://example.test/path', 'https://example.test?token=secret', 'https://example.test#token', 'https://example.test:0', 'https://example.test\\evil']:
result = self.client.put('/admin/email-recaps', json={**self.config, 'public_url': value})
self.assertEqual(result.status_code, 422, value)
for field, value in [('day', 0), ('day', 29), ('hour', 24)]:
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, field: value}).status_code, 422)
with patch.object(recaps, 'smtp_email_config_ready', return_value=(False, 'Email is disabled.')):
self.assertEqual(self.client.put('/admin/email-recaps', json={**self.config, 'enabled': True}).status_code, 409)
self.assertEqual(store.settings()['public_url'], self.config['public_url'])
self.assertFalse(store.settings()['enabled'])
def test_preview_uses_own_report_and_test_requires_confirmed_email(self):
self.login()
with patch.object(recaps, 'get_monthly_report', new=AsyncMock(return_value=self.report)) as report, patch.object(mail, 'send_email') as sender:
result = self.client.get('/admin/email-recaps/preview')
self.assertEqual(result.status_code, 200)
self.assertEqual(report.await_args.args[0]['id'], self.user['id'])
self.assertNotIn('PRIVATE-TOKEN', result.text)
sender.assert_not_called()
payload = {'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': self.report['month']}
self.assertEqual(self.client.post('/admin/email-recaps/test', json=payload).status_code, 409)
self.subscribe()
with patch.object(mail, 'send_email') as sender:
first = self.client.post('/admin/email-recaps/test', json=payload)
second = self.client.post('/admin/email-recaps/test', json=payload)
self.assertEqual(first.status_code, 202)
self.assertEqual(first.json()['id'], second.json()['id'])
sender.assert_not_called()
def test_partial_month_test_rejected_and_public_get_does_not_mutate(self):
self.login(); sub, token = self.subscribe()
result = self.client.post('/admin/email-recaps/test', json={'request_id': 'c49b0c52-4528-4c1d-8c78-57aafeb24f58', 'month': datetime.now(timezone.utc).strftime('%Y-%m')})
self.assertEqual(result.status_code, 422)
self.assertEqual(self.client.get('/email-recaps/confirm').status_code, 405)
result = self.client.post('/email-recaps/check', json={'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
self.assertEqual(result.status_code, 200)
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
class RecapEmailTests(unittest.TestCase):
def setUp(self):
self.runtime = runtime()
patched = patch.object(mail, 'get_runtime_settings', return_value=self.runtime)
patched.start(); self.addCleanup(patched.stop)
self.rendered = mail.render_recap(fixture_report(), 'Viewer', 'https://beta.example.test', 'https://beta.example.test/email-recaps#action=unsubscribe&token=fixture')
def fake_smtp(self):
smtp = MagicMock()
smtp.mail.return_value = (250, b'OK')
smtp.rcpt.return_value = (250, b'OK')
smtp.data.return_value = (250, b'Accepted')
return smtp
def test_render_escapes_names_and_titles_and_includes_no_artwork_credentials(self):
report = fixture_report()
report['top_titles'][0]['title'] = '<img src=x onerror=alert(1)>'
rendered = mail.render_recap(report, '<script>alert(1)</script>', 'https://beta.example.test', 'https://beta.example.test/email-recaps#token=example')
self.assertNotIn('<script>', rendered['body_html'])
self.assertNotIn('<img src=x', rendered['body_html'])
self.assertIn('&lt;script&gt;', rendered['body_html'])
self.assertNotIn('PRIVATE-TOKEN', str(rendered))
self.assertIn('Unsubscribe', rendered['body_text'])
self.assertIn('UTC', rendered['body_text'])
self.assertIn('1,500', rendered['body_html'])
def test_mailbox_validation_rejects_injection_and_multiple_recipients(self):
for value in ['a@example.test\r\nBcc:b@example.test', 'a@example.test,b@example.test', 'Name <a@example.test>', 'x@', 'a;b@example.test']:
self.assertIsNone(mail.valid_email(value))
def test_smtp_acceptance_survives_quit_error_and_preserves_mime_message_id(self):
smtp = self.fake_smtp()
smtp.quit.side_effect = smtplib.SMTPServerDisconnected('after acceptance')
before = MagicMock()
with patch.object(mail.smtplib, 'SMTP', return_value=smtp):
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', before)
before.assert_called_once()
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
self.assertEqual(message['Message-ID'], '<stable@example.test>')
self.assertEqual(message['To'], 'viewer@example.test')
self.assertIsNone(message['Bcc'])
self.assertIn('1,500', message.get_body(('plain',)).get_content())
self.assertIn('<!doctype html>', message.get_body(('html',)).get_content())
def test_temporary_permanent_and_ambiguous_delivery_failures(self):
for operation, failure, expected in [
('mail', (451, b'temporary PRIVATE-KEY'), 'retry'), ('rcpt', (550, b'bad recipient'), 'failed'),
('data', (451, b'retry'), 'retry'), ('data', smtplib.SMTPServerDisconnected('lost after DATA'), 'unknown'),
('rcpt', smtplib.SMTPServerDisconnected('lost before DATA'), 'retry')]:
smtp = self.fake_smtp()
if isinstance(failure, Exception): getattr(smtp, operation).side_effect = failure
else: getattr(smtp, operation).return_value = failure
with self.subTest(operation=operation, expected=expected), patch.object(mail.smtplib, 'SMTP', return_value=smtp):
with self.assertRaises(mail.DeliveryError) as exc:
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>')
self.assertEqual(exc.exception.state, expected)
self.assertNotIn('PRIVATE-KEY', exc.exception.detail)
def test_consent_cancellation_happens_before_smtp_data(self):
smtp = self.fake_smtp()
with patch.object(mail.smtplib, 'SMTP', return_value=smtp), self.assertRaises(mail.DeliveryCancelled):
mail.send_email('viewer@example.test', self.rendered, '<stable@example.test>', MagicMock(side_effect=mail.DeliveryCancelled))
smtp.data.assert_not_called()
def test_real_smtp_is_captured_locally_without_external_delivery(self):
messages = []
class Capture(socketserver.StreamRequestHandler):
def handle(self):
self.wfile.write(b'220 local capture\r\n')
while line := self.rfile.readline():
command = line.split(b' ', 1)[0].strip().upper()
if command in (b'EHLO', b'HELO'):
self.wfile.write(b'250-localhost\r\n250 SIZE 1000000\r\n')
elif command == b'DATA':
self.wfile.write(b'354 Send content\r\n')
data = []
while (part := self.rfile.readline()) != b'.\r\n':
if not part: return
data.append(part[1:] if part.startswith(b'..') else part)
messages.append(b''.join(data))
self.wfile.write(b'250 Captured\r\n')
elif command == b'QUIT':
self.wfile.write(b'221 Bye\r\n'); return
else:
self.wfile.write(b'250 OK\r\n')
with socketserver.TCPServer(('127.0.0.1', 0), Capture) as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.runtime.magent_notify_email_smtp_port = server.server_address[1]
try:
mail.send_email('viewer@example.test', self.rendered, '<local-capture@example.test>')
finally:
server.shutdown(); thread.join(timeout=5)
self.assertEqual(len(messages), 1)
parsed = BytesParser(policy=policy.default).parsebytes(messages[0])
self.assertEqual(parsed['Message-ID'], '<local-capture@example.test>')
self.assertIn('Severance', parsed.get_body(('html',)).get_content())
if __name__ == '__main__':
unittest.main()
class OnDemandReportTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def test_new_confirmation_defaults_to_manual_without_changing_schedule(self):
with patch.object(mail, 'send_email'):
result = await recaps.subscribe(self.user)
self.assertFalse(result['automatic_monthly'])
self.assertFalse(store.settings()['enabled'])
self.assertEqual(result['state'], 'pending')
with self.assertRaises(recaps.RecapError):
recaps.queue_personal(self.user, None, 'pending')
async def test_manual_current_month_delivers_with_monthly_schedule_off(self):
sub, _ = self.subscribe()
store.set_automatic(self.user['id'], False)
month = datetime.now(timezone.utc).strftime('%Y-%m')
queued = recaps.queue_personal(self.user, month, 'manual-1')
self.assertEqual(recaps.queue_personal(self.user, month, 'manual-1')['id'], queued['id'])
report = {**self.report, **month_periods(month, datetime.now(timezone.utc))}
def send(recipient, rendered, message_id, before_data):
before_data()
self.assertEqual(recipient, self.user['email'])
self.assertIn('so far', rendered['subject'])
self.assertNotIn('[Test]', rendered['subject'])
with patch.object(recaps, 'get_monthly_report', new_callable=AsyncMock, return_value=report), patch.object(mail, 'send_email', side_effect=send):
await recaps.process_delivery(store.claim_delivery(time.time()))
self.assertEqual(self.delivery(queued['id'])['state'], 'sent')
self.assertFalse(store.settings()['enabled'])
self.assertFalse(store.subscription(self.user['id'])['automatic_monthly'])
with self.assertRaises(recaps.RecapError) as error:
recaps.queue_personal(self.user, month, 'manual-2')
self.assertEqual(error.exception.status, 429)
async def test_automatic_opt_out_cancels_scheduled_but_keeps_manual(self):
sub, _ = self.subscribe()
with store.transaction() as conn:
scheduled = store._enqueue(conn, sub, self.report['month'], 'scheduled', 'scheduled-fixture', self.config['public_url'], time.time())
manual = recaps.queue_personal(self.user, None, 'manual')
store.set_automatic(self.user['id'], False)
self.assertEqual(self.delivery(scheduled)['state'], 'cancelled')
self.assertEqual(self.delivery(manual['id'])['state'], 'queued')
self.assertEqual(store.subscription(self.user['id'])['state'], 'enabled')
now = datetime.now(timezone.utc)
store.save_settings({**self.config, 'enabled': True}, now)
self.assertEqual(store.enqueue_due(now + timedelta(days=40)), 0)
async def test_changed_identity_cancels_manual_delivery(self):
self.subscribe()
queued = recaps.queue_personal(self.user, None, 'manual')
delivery = store.claim_delivery(time.time())
db.set_user_email('viewer', 'changed@example.test')
with patch.object(mail, 'send_email') as send:
await recaps.process_delivery(delivery)
send.assert_not_called()
self.assertEqual(self.delivery(queued['id'])['state'], 'cancelled')
async def test_regular_user_can_only_send_to_self(self):
self.subscribe()
app = FastAPI(); app.include_router(router.router)
app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'stats': True}}
client = TestClient(app)
body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'}
for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]:
self.assertEqual(client.post('/profile/email-recaps/send', json={**body, **extra}).status_code, 422)
self.assertEqual(client.post('/profile/email-recaps/send', json=body).status_code, 202)
response = client.get('/profile/email-recaps')
self.assertEqual(response.headers['cache-control'], 'no-store')
self.assertEqual(len(response.json()['deliveries']), 1)
+120
View File
@@ -0,0 +1,120 @@
import unittest
from unittest.mock import patch
from backend.app.config import settings
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.feature_access import FEATURES, permissions, update_permissions
from backend.app.routers import admin, auth, events, insights, portal, recaps, requests
from backend.app.security import create_access_token
from backend.tests.test_backend_quality import TempDatabaseMixin
class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
def setUp(self):
super().setUp()
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
secret.start()
self.addCleanup(secret.stop)
db.create_user('feature-viewer', 'Example-password123!', role='user')
db.create_user('feature-admin', 'Example-password123!', role='admin')
self.user = db.get_user_by_username('feature-viewer')
app = FastAPI()
for module in (admin, auth, events, insights, portal, recaps, requests):
app.include_router(module.router)
self.client = TestClient(app)
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
def test_defaults_persist_and_invites_share_existing_setting(self):
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False))
update_permissions({'stats': False, 'invites': True}, self.user['username'])
db.init_db()
fresh = db.get_user_by_username(self.user['username'])
self.assertTrue(fresh['invite_management_enabled'])
self.assertFalse(permissions(fresh)['stats'])
db.set_user_invite_management_enabled(self.user['username'], False)
self.assertFalse(permissions(db.get_user_by_username(self.user['username']))['invites'])
def test_all_feature_apis_reject_disabled_access_with_existing_token(self):
update_permissions(dict.fromkeys(FEATURES, False), self.user['username'])
endpoints = [
('GET', '/insights', None), ('GET', '/insights/reports/monthly', None),
('GET', '/insights/reports/monthly.csv', None), ('GET', '/insights/artwork/item?token=x', None),
('GET', '/profile/email-recaps', None), ('POST', '/profile/email-recaps/send', {}),
('GET', '/requests/recent', None), ('GET', '/requests/search?query=Movie', None),
('GET', '/requests/request-options?mediaType=movie&tmdbId=1', None),
('POST', '/requests/create', {'mediaType': 'movie', 'tmdbId': 1}),
('GET', '/requests/1/snapshot', None), ('POST', '/requests/1/actions/search', {}),
('GET', '/requests/1/issue-options', None), ('POST', '/requests/1/actions/replace', {}),
('GET', '/portal/items?kind=issue', None), ('GET', '/portal/requests', None),
('POST', '/portal/items', {'kind': 'issue'}), ('POST', '/portal/items', {'kind': 'request'}),
('GET', '/portal/issues/media-status', None), ('POST', '/portal/requests/1/issues', {}),
('GET', '/auth/profile/invites', None), ('POST', '/auth/profile/invites', {}),
('PUT', '/auth/profile/invites/1', {}), ('DELETE', '/auth/profile/invites/1', None),
('GET', '/events/stream', None), ('GET', '/events/requests/1/stream', None),
]
for method, path, payload in endpoints:
with self.subTest(path=path, method=method):
self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
self.assertEqual(self.client.get('/auth/me').json()['features'], dict.fromkeys(FEATURES, False))
self.assertEqual(self.client.get('/auth/profile').status_code, 200)
def test_bulk_is_admin_only_strict_and_leaves_other_features_untouched(self):
self.assertEqual(self.client.put('/admin/users/features/bulk', json={'issues': False}).status_code, 403)
self.client.headers['Authorization'] = 'Bearer ' + create_access_token('feature-admin', 'admin')
for invalid in ({'issues': 'false'}, {'unknown': True}, {}):
self.assertEqual(self.client.put('/admin/users/features/bulk', json=invalid).status_code, 400)
response = self.client.put('/admin/users/features/bulk', json={'issues': False})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['updated'], 1)
self.assertFalse(permissions(self.user)['issues'])
self.assertTrue(permissions(self.user)['requests'])
self.assertTrue(all(permissions(db.get_user_by_username('feature-admin')).values()))
self.assertEqual(self.client.put('/admin/users/feature-admin/features', json={'stats': False}).status_code, 400)
self.assertEqual(self.client.put('/admin/users/missing/features', json={'stats': False}).status_code, 404)
def test_issue_and_request_item_routes_cannot_bypass_disabled_feature(self):
issue = db.create_portal_item(kind='issue', title='Problem', description='Problem', created_by_username=self.user['username'], created_by_id=self.user['id'])
update_permissions({'issues': False}, self.user['username'])
for path in (f'/portal/items/{issue["id"]}', f'/portal/items/{issue["id"]}/comments', '/portal/items', '/portal/overview'):
self.assertEqual(self.client.get(path).status_code, 403)
self.assertEqual(self.client.get('/portal/requests').status_code, 200)
self.assertEqual(self.client.get('/portal/items?kind=request').status_code, 200)
update_permissions({'issues': True, 'requests': False, 'new_requests': False}, self.user['username'])
self.assertEqual(self.client.get(f'/portal/items/{issue["id"]}').status_code, 200)
self.assertEqual(self.client.get('/portal/items?kind=issue').status_code, 200)
overview = self.client.get('/portal/overview?kind=issue')
self.assertEqual(overview.status_code, 200)
self.assertEqual(overview.json()['overview']['by_kind'], {'issue': 1})
self.assertEqual(self.client.post('/requests/create', json={'mediaType': 'movie', 'tmdbId': 1}).status_code, 403)
def test_deleted_account_does_not_leave_permissions_for_reused_id(self):
update_permissions({'stats': False}, self.user['username'])
db.delete_user_by_username(self.user['username'])
with db._connect() as conn:
self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_feature_permissions').fetchone()[0], 0)
def test_open_request_stream_closes_after_permission_revocation(self):
import asyncio
from unittest.mock import AsyncMock
from types import SimpleNamespace
async def scenario():
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
response = await events.events_stream(request, user={**self.user, "features": permissions(self.user)})
iterator = response.body_iterator
self.assertIn('retry', await anext(iterator))
update_permissions({'requests': False}, self.user['username'])
with self.assertRaises(StopAsyncIteration):
await anext(iterator)
asyncio.run(scenario())
def test_legacy_portal_kind_normalization_cannot_bypass_permissions(self):
update_permissions({'requests': False, 'new_requests': False, 'issues': True}, self.user['username'])
for kind in ['request', 'REQUEST', ' Request ', ' ', '']:
with self.subTest(kind=kind):
self.assertEqual(self.client.get('/portal/items', params={'kind': kind}).status_code, 403)
self.assertEqual(self.client.get('/portal/overview', params={'kind': kind}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
+472
View File
@@ -0,0 +1,472 @@
import json
import unittest
from contextlib import closing
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.clients.jellystat import JellystatClient
from backend.app.routers import identities
from backend.app.services import identity_review as review
from backend.app.services.jellyfin_identity import link_user, linked_user_id
from backend.tests.test_backend_quality import TempDatabaseMixin
JF = "a" * 32
OTHER = "b" * 32
SERVER = "c" * 32
ADMIN = {"username": "admin", "role": "admin"}
class IdentityReviewTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
db.create_user("Georgia", "jellyfin-user", auth_provider="jellyfin")
self.user_id = db.get_user_by_username("Georgia")["id"]
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="SECRET-JF",
jellyseerr_base_url="http://seerr", jellyseerr_api_key="SECRET-SEERR",
jellystat_base_url="http://jellystat", jellystat_api_key="SECRET-STATS")
runtime_patch = patch.object(review, "get_runtime_settings", return_value=self.runtime)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
self.jf = {"state": "available", "server_id": SERVER, "users": [{"id": JF, "name": "Georgia"}]}
self.seerr = {"state": "available", "users": [{"id": 20, "name": "An unrelated display name", "jellyfin_id": JF}]}
self.js = {JF: {"state": "matched", "id": JF, "name": "Georgia"}}
def build(self):
local = review.read_snapshot()
return review.build_report(local, self.jf, self.seerr, self.js, self.runtime), local
def row(self, report):
return next(row for row in report["rows"] if row["user"]["id"] == self.user_id)
async def test_manual_selection_resolves_different_username_without_guessing(self):
self.jf['users'][0]['name'] = 'Different Jellyfin name'
before = review.read_snapshot()
report, _ = self.build()
self.assertEqual(self.row(report)['state'], 'unlinked')
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertTrue(self.row(report)['can_confirm'])
self.assertEqual(review.read_snapshot(), before)
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN)
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
self.assertEqual(self.row(self.build()[0])['state'], 'confirmed')
async def test_manual_selection_cannot_replace_stored_or_confirmed_identity(self):
self.jf['users'].append({'id': OTHER, 'name': 'Other'})
self.seerr['users'].append({'id': 21, 'name': 'Other', 'jellyfin_id': OTHER})
self.js[OTHER] = {'state': 'matched', 'id': OTHER}
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(review.read_snapshot(), local)
async def test_manual_selection_checks_missing_ids_and_duplicate_owners(self):
self.jf['users'][0]['name'] = 'Different'
for state in ['missing', 'unavailable', 'not_configured']:
self.js[JF] = {'state': state}
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertFalse(self.row(report)['can_confirm'])
self.js[JF] = {'state': 'matched', 'id': JF}
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF})
self.assertEqual(self.row(report)['state'], 'conflict')
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: OTHER})
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException) as error:
review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {999: JF})
self.assertEqual(error.exception.status_code, 404)
async def test_resolution_rechecks_live_services_and_rejects_changed_selection(self):
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
before = review.read_snapshot()
preview = await review.resolve_identity(self.user_id, JF)
self.assertEqual(review.read_snapshot(), before)
with self.assertRaises(HTTPException) as error:
await review.resolve_identity(self.user_id, OTHER, preview['revision'], ADMIN)
self.assertEqual(error.exception.status_code, 409)
self.assertEqual(review.read_snapshot(), before)
result = await review.resolve_identity(self.user_id, JF, preview['revision'], ADMIN)
self.assertEqual(result['confirmed'], 1)
async def test_repair_replaces_wrong_local_link_and_records_before_after(self):
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
db.set_user_jellyseerr_id('Georgia', 999)
before = review.read_snapshot()
report = review.build_report(before, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertTrue(self.row(report)['can_confirm'])
self.assertEqual(review.read_snapshot(), before)
review.save_confirmations(report, before, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(linked_user_id('Georgia', self.runtime.jellyfin_base_url), JF)
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 20)
with closing(db._connect()) as conn:
audit = conn.execute('SELECT before_json,after_json,repaired_by FROM user_identity_repairs').fetchone()
self.assertEqual(json.loads(audit[0])['seerr_user_id'], 999)
self.assertEqual(json.loads(audit[1])['jellyfin_user_id'], JF)
self.assertEqual(audit[2], 'admin')
async def test_repair_preserves_duplicate_ownership_and_server_guards(self):
db.create_user('Owner', 'password', auth_provider='local', jellyseerr_user_id=20)
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertFalse(self.row(report)['can_confirm'])
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
with closing(db._connect()) as conn, conn:
conn.execute('DELETE FROM users WHERE username=?', ('Owner',))
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf['server_id'] = OTHER
report = review.build_report(review.read_snapshot(), self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertFalse(self.row(report)['can_confirm'])
async def test_repair_does_not_invent_missing_seerr_identity(self):
self.seerr['users'][0]['jellyfin_id'] = OTHER
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
self.assertEqual(self.row(report)['state'], 'unlinked')
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(local, review.read_snapshot())
async def test_repair_audit_failure_rolls_back_links(self):
link_user('Georgia', OTHER, self.runtime.jellyfin_base_url)
with closing(db._connect()) as conn, conn:
conn.execute("CREATE TRIGGER fail_identity_audit BEFORE INSERT ON user_identity_repairs BEGIN SELECT RAISE(ABORT, 'fixture'); END")
local = review.read_snapshot()
report = review.build_report(local, self.jf, self.seerr, self.js, self.runtime, {self.user_id: JF}, repair=True)
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN, repair=True)
self.assertEqual(local, review.read_snapshot())
async def test_repair_rechecks_revision_and_updates_confirmed_ids(self):
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf['users'][0]['id'] = OTHER
self.seerr['users'][0]['jellyfin_id'] = OTHER
self.js = {OTHER: {'state': 'matched', 'id': OTHER}}
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js):
preview = await review.repair_identity(self.user_id, OTHER)
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, OTHER, 'f' * 64, ADMIN)
await review.repair_identity(self.user_id, OTHER, preview['revision'], ADMIN)
self.assertEqual(review.read_snapshot()['confirmations'][0]['jellyfin_user_id'], OTHER)
async def test_single_account_import_is_explicit_and_rechecked_before_local_save(self):
self.seerr['users'] = []
async def imported(*args, **kwargs):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
return []
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported) as post:
before = review.read_snapshot()
blocked = await review.repair_identity(self.user_id, JF)
self.assertFalse(blocked['row']['can_confirm'])
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
self.assertEqual(preview['action'], 'import_seerr')
self.assertTrue(preview['row']['can_confirm'])
post.assert_not_called()
self.assertEqual(review.read_snapshot(), before)
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, False)
post.assert_not_called()
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
post.assert_awaited_once_with('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [JF]})
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 25)
async def test_failed_import_never_writes_local_links_or_retries(self):
self.seerr['users'] = []
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=httpx.ReadTimeout('fixture')) as post:
before = review.read_snapshot()
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
with self.assertRaises(HTTPException) as error:
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
self.assertEqual(error.exception.status_code, 502)
self.assertEqual(post.await_count, 1)
self.assertEqual(review.read_snapshot(), before)
async def test_import_preserves_upstream_account_when_local_save_is_blocked(self):
self.seerr['users'] = []
async def imported(*args, **kwargs):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': JF}]
db.set_user_jellyseerr_id('Georgia', 999)
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock, side_effect=imported), \
patch.object(review.JellyseerrClient, 'delete_user', new_callable=AsyncMock) as delete:
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
with self.assertRaises(HTTPException) as error:
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
self.assertIn('Seerr import completed', error.exception.detail)
self.assertEqual(db.get_user_by_username('Georgia')['jellyseerr_user_id'], 999)
self.assertEqual(review.read_snapshot()['confirmations'], [])
delete.assert_not_called()
async def test_import_blocks_existing_name_with_different_jellyfin_id(self):
self.seerr['users'] = [{'id': 25, 'name': 'Georgia', 'jellyfin_id': OTHER}]
with patch.object(review, 'jellyfin_directory', new_callable=AsyncMock, return_value=self.jf), \
patch.object(review, 'seerr_directory', new_callable=AsyncMock, return_value=self.seerr), \
patch.object(review.JellystatClient, 'check_user_ids', new_callable=AsyncMock, return_value=self.js), \
patch.object(review.JellyseerrClient, 'post', new_callable=AsyncMock) as post:
preview = await review.repair_identity(self.user_id, JF, create_seerr=True)
self.assertFalse(preview['row']['can_confirm'])
with self.assertRaises(HTTPException):
await review.repair_identity(self.user_id, JF, preview['revision'], ADMIN, True)
post.assert_not_called()
async def test_georgia_preview_is_read_only_and_uses_seerr_jellyfin_id(self):
before = review.read_snapshot()
report, _ = self.build()
row = self.row(report)
self.assertEqual(row["basis"], "suggested_username")
self.assertEqual(row["state"], "ready")
self.assertEqual(row["seerr"][0]["id"], 20)
self.assertEqual(before, review.read_snapshot())
serialized = json.dumps(report)
for private in ["SECRET", "password_hash", "jellyfin_api_key", "email"]:
self.assertNotIn(private, serialized)
async def test_confirmation_persists_both_links_and_survives_legacy_sync(self):
report, local = self.build()
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
self.assertEqual(linked_user_id("Georgia", self.runtime.jellyfin_base_url), JF)
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
saved = review.read_snapshot()["confirmations"][0]
self.assertEqual(saved["jellyfin_server_id"], SERVER)
self.assertEqual(saved["confirmed_by"], "admin")
db.set_user_jellyseerr_id("Georgia", 999)
link_user("Georgia", OTHER, "http://other-server")
self.assertEqual(db.get_user_by_username("Georgia")["jellyseerr_user_id"], 20)
self.assertIsNone(linked_user_id("Georgia", "http://other-server"))
refreshed, _ = self.build()
self.assertEqual(self.row(refreshed)["state"], "confirmed")
self.assertFalse(self.row(refreshed)["can_confirm"])
async def test_hidden_duplicate_seerr_and_jellyfin_rows_block_confirmation(self):
db.set_user_jellyseerr_id("Georgia", 20)
db.create_user("georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr", jellyseerr_user_id=20)
report, local = self.build()
self.assertEqual(len(db.get_all_users()), 1)
self.assertEqual(len(report["rows"]), 2)
self.assertTrue(all(row["state"] == "conflict" for row in report["rows"]))
with self.assertRaises(HTTPException):
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(review.read_snapshot()["confirmations"], [])
async def test_whitespace_accounts_and_wrong_seerr_mapping_are_conflicts(self):
self.jf["users"].append({"id": OTHER, "name": "Georgia "})
self.seerr["users"].append({"id": 21, "name": "Georgia ", "jellyfin_id": OTHER})
db.set_user_jellyseerr_id("Georgia", 21)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertFalse(self.row(report)["can_confirm"])
async def test_case_duplicates_in_magent_remain_visible_and_blocked(self):
# Legacy duplicate predates the normalized-name creation guard.
with db._connect() as conn:
conn.execute("INSERT INTO users(username,password_hash,role,auth_provider,created_at) VALUES('georgia','unused','user','jellyfin','2026-01-01')")
report, _ = self.build()
self.assertEqual(report["counts"]["conflict"], 2)
self.assertEqual(report["counts"]["ready"], 0)
async def test_email_prefix_and_local_username_do_not_claim_an_identity(self):
db.create_user("Georgia@example.com", "jellyseerr-user", auth_provider="jellyseerr")
self.jf["users"].append({"id": OTHER, "name": "local"})
db.create_user("local", "password", auth_provider="local")
report, _ = self.build()
for row in report["rows"]:
if row["user"]["id"] != self.user_id:
self.assertIsNone(row["candidate_jellyfin_id"])
self.assertFalse(row["can_confirm"])
async def test_missing_or_unavailable_services_never_confirm(self):
for state in ["missing", "unavailable", "not_configured"]:
self.js[JF] = {"state": state}
report, _ = self.build()
self.assertFalse(self.row(report)["can_confirm"])
self.js = {JF: {"state": "matched", "id": JF}}
self.seerr = {"state": "unavailable", "users": []}
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "unavailable")
async def test_duplicate_upstream_id_and_orphaned_reservations_are_blocked(self):
self.seerr["users"].append({"id": 21, "name": "Other", "jellyfin_id": JF})
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.seerr["users"].pop()
with closing(db._connect()) as conn, conn:
conn.execute("INSERT INTO jellyfin_user_links VALUES (?,?,?)", (review.source_key(self.runtime.jellyfin_base_url), 9999, JF))
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_wrong_stored_id_is_not_silently_replaced(self):
link_user("Georgia", OTHER, self.runtime.jellyfin_base_url)
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
self.assertEqual(self.row(report)["candidate_jellyfin_id"], OTHER)
async def test_account_changes_reject_whole_save(self):
report, local = self.build()
db.set_user_jellyseerr_id("Georgia", 99)
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
self.assertEqual(review.read_snapshot()["confirmations"], [])
self.assertIsNone(linked_user_id("Georgia", self.runtime.jellyfin_base_url))
async def test_settings_changes_reject_save(self):
report, local = self.build()
db.set_setting("jellyfin_base_url", "http://changed")
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
async def test_save_reads_real_runtime_settings_inside_transaction(self):
from backend.app.runtime import get_runtime_settings
for key in review.CONFIG_KEYS:
db.set_setting(key, getattr(self.runtime, key))
report, local = self.build()
with patch.object(review, "get_runtime_settings", get_runtime_settings):
result = review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.assertEqual(result["confirmed"], 1)
async def test_batch_rolls_back_all_links_if_a_later_write_fails(self):
db.create_user("Second", "jellyfin-user", auth_provider="jellyfin")
second_id = db.get_user_by_username("Second")["id"]
self.jf["users"].append({"id": OTHER, "name": "Second"})
self.seerr["users"].append({"id": 21, "name": "Second", "jellyfin_id": OTHER})
self.js[OTHER] = {"state": "matched", "id": OTHER}
with closing(db._connect()) as conn, conn:
conn.execute(f"""CREATE TRIGGER fail_second_confirmation BEFORE INSERT ON user_identity_confirmations
WHEN NEW.local_user_id={second_id} BEGIN SELECT RAISE(ABORT, 'fixture conflict'); END""")
report, local = self.build()
with self.assertRaises(HTTPException) as raised:
review.save_confirmations(report, local, self.runtime, [self.user_id, second_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
after = review.read_snapshot()
self.assertEqual(after["confirmations"], [])
self.assertEqual(after["links"], [])
self.assertTrue(all(row["jellyseerr_user_id"] is None for row in after["users"]))
async def test_confirmation_rechecks_live_report_and_rejects_stale_revision(self):
report, local = self.build()
changed = {**report, "revision": "f" * 64}
with patch.object(review, "review_identities", new_callable=AsyncMock, return_value=(changed, local, self.runtime)), \
patch.object(review, "save_confirmations") as save:
with self.assertRaises(HTTPException) as raised:
await review.confirm_identities(report["revision"], [self.user_id], ADMIN)
self.assertEqual(raised.exception.status_code, 409)
save.assert_not_called()
async def test_different_server_cannot_reuse_confirmed_id(self):
report, local = self.build()
review.save_confirmations(report, local, self.runtime, [self.user_id], ADMIN)
self.jf["server_id"] = OTHER
report, _ = self.build()
self.assertEqual(self.row(report)["state"], "conflict")
async def test_report_revision_ignores_time_but_detects_mapping_changes(self):
a, _ = self.build()
b, _ = self.build()
self.assertEqual(a["revision"], b["revision"])
self.seerr["users"][0]["id"] = 99
c, _ = self.build()
self.assertNotEqual(a["revision"], c["revision"])
async def test_seerr_directory_requires_complete_unique_pages(self):
users = [{"id": i, "jellyfinUserId": f"{i:032x}", "displayName": f"User {i}"} for i in range(1, 102)]
pages = [{"pageInfo": {"results": 101}, "results": users[:100]}, {"pageInfo": {"results": 101}, "results": users[100:]}]
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, side_effect=pages) as get:
result = await review.seerr_directory(self.runtime)
self.assertEqual(result["state"], "available")
self.assertEqual(len(result["users"]), 101)
self.assertEqual(get.await_args.kwargs["skip"], 100)
for broken in [[], users[:2], users[:1] * 100]:
with patch.object(review.JellyseerrClient, "get_users", new_callable=AsyncMock, return_value={"pageInfo": {"results": 101}, "results": broken}):
self.assertEqual((await review.seerr_directory(self.runtime))["state"], "unavailable")
async def test_jellyfin_server_and_directory_must_agree(self):
with patch.object(review.JellyfinClient, "get_system_info", new_callable=AsyncMock, return_value={"Id": SERVER}), \
patch.object(review.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": JF, "Name": "Georgia", "ServerId": OTHER}]):
self.assertEqual((await review.jellyfin_directory(self.runtime))["state"], "unavailable")
class JellystatIdentityTests(unittest.IsolatedAsyncioTestCase):
async def test_unconfigured_client_never_calls_upstream(self):
with patch("backend.app.clients.jellystat.httpx.AsyncClient") as http:
result = await JellystatClient(None, None).check_user_ids([JF])
http.assert_not_called()
self.assertEqual(result[JF]["state"], "not_configured")
async def test_missing_wrong_and_failed_ids_are_distinguished_without_details(self):
ids = [f"{i:032x}" for i in range(1, 6)]
def handler(request):
self.assertEqual(request.headers["x-api-token"], "PRIVATE")
user_id = json.loads(request.content)["userid"]
if user_id == ids[0]: return httpx.Response(200, json={"Id": user_id, "Name": "Georgia", "PRIVATE": "hidden"})
if user_id == ids[1]: return httpx.Response(200, content=b"")
if user_id == ids[2]: return httpx.Response(200, json={"Id": OTHER})
if user_id == ids[3]: return httpx.Response(401, text="PRIVATE error")
return httpx.Response(503, text="PRIVATE error")
real = httpx.AsyncClient
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
data = await JellystatClient("http://jellystat", "PRIVATE").check_user_ids(ids)
self.assertEqual([data[key]["state"] for key in ids], ["matched", "missing", "unavailable", "unavailable", "unavailable"])
self.assertNotIn("PRIVATE", json.dumps(data))
class IdentityRouteTests(unittest.TestCase):
def client(self, role=None):
app = FastAPI()
app.include_router(identities.router)
if role:
app.dependency_overrides[get_current_user] = lambda: {"username": "viewer", "role": role}
return TestClient(app)
def test_admin_only_read_and_write(self):
for role, status in [(None, 401), ("user", 403)]:
client = self.client(role)
self.assertEqual(client.get("/admin/identities").status_code, status)
self.assertEqual(client.post("/admin/identities/confirm", json={"revision": "a" * 64, "user_ids": [1]}).status_code, status)
def test_resolution_requires_admin_and_strict_ids(self):
for endpoint in ['check', 'confirm']:
body = {'user_id': 1, 'jellyfin_user_id': JF}
if endpoint == 'confirm': body['revision'] = 'a' * 64
for role, status in [(None, 401), ('user', 403)]:
self.assertEqual(self.client(role).post('/admin/identities/resolve/' + endpoint, json=body).status_code, status)
self.assertEqual(self.client(role).post('/admin/identities/repair/' + endpoint, json=body).status_code, status)
for invalid in [{'user_id': True}, {'jellyfin_user_id': 'invalid'}, {'seerr_user_id': 22}]:
self.assertEqual(self.client('admin').post('/admin/identities/resolve/' + endpoint, json={**body, **invalid}).status_code, 422)
self.assertEqual(self.client('admin').post('/admin/identities/repair/' + endpoint, json={**body, **invalid}).status_code, 422)
with patch.object(identities, 'resolve_identity', new_callable=AsyncMock, return_value={'row': {}}):
result = self.client('admin').post('/admin/identities/resolve/check', json={'user_id': 1, 'jellyfin_user_id': JF})
self.assertEqual(result.headers['cache-control'], 'no-store')
def test_no_store_and_no_browser_supplied_identity(self):
with patch.object(identities, "review_identities", new_callable=AsyncMock, return_value=({"rows": []}, {}, None)):
response = self.client("admin").get("/admin/identities")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
for body in [{"revision": "a" * 64, "user_ids": [1, 1]}, {"revision": "a" * 64, "user_ids": []},
{"revision": "a" * 64, "user_ids": [1], "jellyfin_id": OTHER}]:
self.assertEqual(self.client("admin").post("/admin/identities/confirm", json=body).status_code, 422)
+201
View File
@@ -0,0 +1,201 @@
import json
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
from backend.app.routers import admin, insights as router
from backend.app.services import insights
from backend.app.services.jellyfin_identity import link_user, linked_user_id
from backend.tests.test_backend_quality import TempDatabaseMixin
NOW = datetime(2026, 9, 7, 12, tzinfo=timezone.utc)
USER = {"username": "viewer", "role": "user", "auth_provider": "jellyfin", "jellyseerr_user_id": 42}
LIBRARIES = [{"Id": "movies", "CollectionType": "movies"}, {"Id": "music", "CollectionType": "music"}]
def play(id="play-1", **extra):
return {"Id": id, "UserId": "jf-viewer", "UserName": "PRIVATE NAME", "NowPlayingItemId": "movie-1",
"NowPlayingItemName": "Arrival", "ParentId": "movies", "PlaybackDuration": 3600,
"ActivityDateInserted": NOW.isoformat(), "RemoteEndPoint": "PRIVATE IP", "DeviceId": "PRIVATE DEVICE",
"PlayState": {"secret": "PRIVATE STATE"}, "Client": "Jellyfin Web", "PlayMethod": "DirectPlay", **extra}
class JellystatClientTests(unittest.IsolatedAsyncioTestCase):
async def history(self, handler, **kwargs):
original = httpx.AsyncClient
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **options: original(transport=httpx.MockTransport(handler), **options)):
return await JellystatClient("http://jellystat/base", "secret-api-key").get_user_history(
kwargs.get("user_id", "jf-viewer"), NOW - timedelta(days=7), NOW)
async def test_paginates_and_sends_only_backend_identity_and_header_credential(self):
calls = []
def handler(request):
calls.append(request)
self.assertEqual(request.headers["x-api-token"], "secret-api-key")
self.assertNotIn("secret-api-key", str(request.url))
if request.url.path == "/base/api/getLibraries":
return httpx.Response(200, json=LIBRARIES)
self.assertEqual(request.method, "POST")
self.assertEqual(request.url.path, "/base/api/getUserHistory")
self.assertEqual(json.loads(request.content), {"userid": "jf-viewer"})
self.assertNotIn("search", request.url.params)
self.assertEqual(json.loads(request.url.params["filters"])[0]["field"], "ActivityDateInserted")
return httpx.Response(200, json={"pages": 2, "results": [play(request.url.params["page"])]})
history, libraries = await self.history(handler)
self.assertEqual(len(calls), 3)
self.assertEqual(len(history), 2)
self.assertEqual(libraries, LIBRARIES)
async def test_rejects_foreign_history_malformed_responses_and_overflow(self):
for payload, exception in [
({"pages": 1, "results": [play(UserId="someone-else")]}, JellystatError),
({"pages": 1, "results": [play(UserId=None)]}, JellystatError),
({"results": []}, JellystatError),
({"pages": 51, "results": []}, HistoryLimitError),
({"pages": 2, "results": []}, JellystatError),
({"pages": 0, "results": [play()]}, JellystatError),
]:
with self.subTest(payload=payload):
def handler(request):
return httpx.Response(200, json=LIBRARIES if request.method == "GET" else payload)
with self.assertRaises(exception):
await self.history(handler)
async def test_empty_history_is_valid(self):
result, _ = await self.history(lambda request: httpx.Response(200, json=LIBRARIES if request.method == "GET" else {"pages": 0, "results": []}))
self.assertEqual(result, [])
async def test_upstream_failure_is_sanitized(self):
with self.assertRaises(JellystatError) as error:
await self.history(lambda _: httpx.Response(401, text="private upstream error"))
self.assertNotIn("private", str(error.exception))
self.assertNotIn("secret-api-key", str(error.exception))
class SummaryTests(unittest.TestCase):
def test_units_media_counts_deduplication_ranges_streaks_and_privacy(self):
rows = [play(), play(), play("rewatch"),
play("episode", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration="1200",
ActivityDateInserted=(NOW - timedelta(days=1)).isoformat()),
play("episode-rewatch", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration=1200,
ActivityDateInserted=(NOW - timedelta(days=2)).isoformat()),
play("song", ParentId="music", NowPlayingItemId="song-1", PlaybackDuration=180),
play("old", ActivityDateInserted=(NOW - timedelta(days=8)).isoformat()),
play("zero", PlaybackDuration=0)]
data = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
self.assertEqual(data["summary"], {"minutes": 163, "plays": 5, "movies": 1, "episodes": 1,
"active_days": 3, "current_streak": 3, "longest_streak": 3})
self.assertAlmostEqual(sum(day["minutes"] for day in data["daily"]), 163)
self.assertEqual(data["top_titles"][0]["title"], "Arrival")
self.assertEqual(len(data["recent"]), 5)
self.assertNotIn("PRIVATE", json.dumps(data))
def test_invalid_durations_do_not_become_zero_or_nan(self):
for value in [-1, "NaN", "Infinity", "nonsense"]:
with self.subTest(value=value), self.assertRaises(JellystatError):
insights.summarize([play(PlaybackDuration=value)], LIBRARIES, NOW - timedelta(days=7), NOW)
def test_empty_history_has_zero_filled_days(self):
result = insights.summarize([], LIBRARIES, NOW - timedelta(days=7), NOW)
self.assertEqual(result["summary"]["minutes"], 0)
self.assertEqual(len(result["daily"]), 8)
self.assertEqual(result["summary"]["current_streak"], 0)
class InsightsIntegrationTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
insights._cache.clear()
db.create_user("viewer", "Test-Password123!", auth_provider="jellyfin")
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="jf-key",
jellystat_base_url="http://jellystat", jellystat_api_key="stats-key")
async def test_identity_does_not_change_with_username_reuse_or_server_changes(self):
link_user("viewer", "jf-original", "http://jellyfin/")
link_user("viewer", "jf-replacement", "http://jellyfin")
self.assertEqual(linked_user_id("viewer", "http://jellyfin"), "jf-original")
self.assertIsNone(linked_user_id("viewer", "http://other-server"))
async def test_local_account_cannot_claim_same_name_and_verified_user_can_bootstrap(self):
with patch.object(insights.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": "jf-viewer", "Name": "viewer"}]) as remote:
self.assertIsNone(await insights.resolve_identity({**USER, "auth_provider": "local"}, self.runtime))
remote.assert_not_called()
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
self.assertEqual(remote.await_count, 1)
async def test_requests_use_seerr_id_even_when_name_matches_another_user(self):
for request_id, seerr_id in [(1, 42), (2, 99)]:
db.upsert_request_cache(request_id, request_id, "movie", 2, "Request", 2026,
"viewer", "viewer", seerr_id, NOW.isoformat(), NOW.isoformat(), "{}")
report = insights.request_summary(USER, NOW - timedelta(days=7), NOW)
self.assertEqual(report["total"], 1)
self.assertEqual(report["recent"][0]["request_id"], 1)
async def test_cache_isolated_by_identity_period_and_configuration(self):
link_user("viewer", "jf-viewer", "http://jellyfin")
db.create_user("second", "Test-Password123!", auth_provider="jellyfin")
link_user("second", "jf-second", "http://jellyfin")
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
await insights.get_insights(USER, 7)
await insights.get_insights(USER, 7)
self.assertEqual(remote.await_count, 1)
await insights.get_insights({**USER, "username": "second"}, 7)
await insights.get_insights(USER, 30)
self.runtime.jellystat_api_key = "rotated-key"
await insights.get_insights(USER, 7)
self.assertEqual(remote.await_count, 4)
async def test_disabled_integration_never_calls_upstream(self):
self.runtime.jellystat_api_key = None
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock) as remote:
result = await insights.get_insights(USER, 30)
self.assertEqual(result["state"], "not_configured")
self.assertIsNone(result["summary"])
remote.assert_not_called()
async def test_settings_mask_jellystat_credential(self):
db.set_setting("jellystat_api_key", "private-stats-key")
result = await admin.list_settings()
setting = next(row for row in result["settings"] if row["key"] == "jellystat_api_key")
self.assertTrue(setting["sensitive"])
self.assertTrue(setting["isSet"])
self.assertNotIn("private-stats-key", json.dumps(result))
class InsightsRouteTests(unittest.TestCase):
def app(self, authenticated=True):
app = FastAPI()
app.include_router(router.router)
if authenticated:
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
return TestClient(app)
def test_requires_authentication(self):
self.assertEqual(self.app(False).get("/insights").status_code, 401)
def test_query_accepts_period_and_forbids_identity_and_scope_overrides(self):
with patch.object(router, "get_insights", new_callable=AsyncMock, return_value={"state": "ready"}) as report:
client = self.app()
for days in [7, 30, 90, 365]:
response = client.get(f"/insights?days={days}")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["cache-control"], "no-store")
report.assert_awaited_with({**USER, "features": {"stats": True}}, 365)
for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]:
self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
def test_errors_do_not_leak_upstream_details(self):
with patch.object(router, "get_insights", new_callable=AsyncMock, side_effect=JellystatError("PRIVATE key and URL")):
response = self.app().get("/insights")
self.assertEqual(response.status_code, 502)
self.assertNotIn("PRIVATE", response.text)
+165
View File
@@ -0,0 +1,165 @@
import asyncio
import base64
import copy
import unittest
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlsplit
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app.routers import insights as router
from backend.app.services import insights, insights_artwork as artwork
from backend.tests.test_insights import NOW, LIBRARIES, USER, play
ITEM = "a" * 32
OTHER = "b" * 32
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jK1sAAAAASUVORK5CYII=")
def transcode(activity, minutes, video_direct=False, audio_direct=False, hardware="nvenc", **kwargs):
return play(activity, NowPlayingItemId=ITEM, PlaybackDuration=minutes * 60, PlayMethod="Transcode",
TranscodingInfo={"IsVideoDirect": video_direct, "IsAudioDirect": audio_direct,
"HardwareAccelerationType": hardware, "VideoCodec": "h264", "AudioCodec": "aac"}, **kwargs)
class TranscodingSummaryTests(unittest.TestCase):
def test_gpu_audio_software_and_unknown_time_are_separate_and_deduplicated(self):
gpu = transcode("gpu", 10)
rows = [gpu, dict(gpu), transcode("audio-only", 5, video_direct=True),
transcode("software", 3, audio_direct=True, hardware="none"),
transcode("remux", 2, video_direct=True, audio_direct=True),
{**transcode("stale", 1), "PlayMethod": "DirectPlay"},
transcode("unknown-hardware", 2, audio_direct=True, hardware=None),
{**transcode("unknown-streams", 1), "TranscodingInfo": None},
transcode("old", 100, ActivityDateInserted=(NOW - timedelta(days=31)).isoformat())]
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=30), NOW)
stats = result["transcoding"]
self.assertEqual(stats["hardware_video_minutes"], 10)
self.assertEqual(stats["audio_minutes"], 15)
self.assertEqual(stats["video_minutes"], 15)
self.assertEqual(stats["software_video_minutes"], 3)
self.assertEqual(stats["unknown_hardware_minutes"], 2)
self.assertEqual(stats["unknown_video_minutes"], 1)
self.assertEqual(stats["unknown_audio_minutes"], 1)
self.assertEqual(stats["hardware"], [{"name": "NVIDIA NVENC", "minutes": 10}])
self.assertIsNone(stats["gpu_busy_minutes"])
def test_audio_media_cannot_accumulate_video_gpu_time(self):
row = transcode("music", 4, ParentId="music")
row["TranscodingInfo"]["VideoCodec"] = None
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["audio_minutes"], 4)
self.assertEqual(result["video_minutes"], 0)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_direct_stream_counts_audio_but_does_not_claim_video_encoding(self):
row = {**transcode("stream", 5), "PlayMethod": "DirectStream"}
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["audio_minutes"], 5)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_numeric_hardware_enum_and_legacy_json_are_supported(self):
import json
row = transcode("enum", 5, hardware=3)
row["TranscodingInfo"] = json.dumps(row["TranscodingInfo"])
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["hardware_video_minutes"], 5)
row["TranscodingInfo"] = "invalid JSON"
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["unknown_video_minutes"], 5)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_episode_artwork_uses_series_id_and_invalid_ids_are_ignored(self):
rows = [play("episode", EpisodeId=OTHER, NowPlayingItemId=ITEM),
play("invalid", NowPlayingItemId="../../secret")]
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
indexed = {row["id"]: row for row in result["recent"]}
self.assertEqual(indexed["episode"]["artwork_item_id"], ITEM)
self.assertIsNone(indexed["invalid"]["artwork_item_id"])
class ArtworkTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="PRIVATE-API-KEY")
self.secret = patch.object(artwork.settings, "jwt_secret", "test-artwork-signing-secret")
self.secret.start()
self.addCleanup(self.secret.stop)
artwork._cache.clear()
artwork._downloads = asyncio.Semaphore(6)
def url_and_token(self, user=USER):
data = {"recent": [{"id": "play-1", "artwork_item_id": ITEM}]}
before = copy.deepcopy(data)
result = artwork.with_artwork(data, user, self.runtime)
self.assertEqual(data, before)
row = result["recent"][0]
self.assertNotIn("artwork_item_id", row)
self.assertNotIn("PRIVATE-API-KEY", row["artwork_url"])
return row["artwork_url"], parse_qs(urlsplit(row["artwork_url"]).query)["token"][0]
async def test_ticket_is_bound_to_user_item_server_credentials_and_time(self):
with patch.object(artwork.time, "time", return_value=1000):
_, token = self.url_and_token()
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
for user, runtime, media_id in [({**USER, "username": "different"}, self.runtime, ITEM),
(USER, self.runtime, OTHER), (USER, SimpleNamespace(jellyfin_base_url="http://other", jellyfin_api_key="PRIVATE-API-KEY"), ITEM),
(USER, SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="changed"), ITEM)]:
with self.assertRaises(HTTPException) as raised:
artwork.verify_artwork_token(user, runtime, media_id, token)
self.assertEqual(raised.exception.status_code, 403)
with patch.object(artwork.time, "time", return_value=5000), self.assertRaises(HTTPException):
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
for invalid in ["invalid", "1.", "1." + "\u2603" * 64]:
with self.assertRaises(HTTPException):
artwork.verify_artwork_token(USER, self.runtime, ITEM, invalid)
async def test_private_proxy_returns_image_and_validates_before_cache_access(self):
calls = []
def handler(request):
calls.append(request)
self.assertEqual(request.url.path, f"/Items/{ITEM}/Images/Primary")
self.assertNotIn("PRIVATE", str(request.url))
self.assertEqual(request.headers["X-Emby-Token"], "PRIVATE-API-KEY")
return httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"})
real = httpx.AsyncClient
_, token = self.url_and_token()
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
with self.assertRaises(HTTPException):
await artwork.get_artwork({**USER, "username": "someone-else"}, self.runtime, ITEM, token)
self.assertEqual(len(calls), 1)
async def test_non_images_missing_images_and_large_images_fail_closed(self):
_, token = self.url_and_token()
real = httpx.AsyncClient
for status, body, mime in [(404, b"PRIVATE", "text/plain"), (200, b"<svg>PRIVATE</svg>", "image/svg+xml"),
(200, b"x" * (artwork.MAX_IMAGE_BYTES + 1), "image/png")]:
transport = httpx.MockTransport(lambda request: httpx.Response(status, content=body, headers={"Content-Type": mime}))
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=transport, **kwargs)):
with self.assertRaises(HTTPException) as raised:
await artwork.get_artwork(USER, self.runtime, ITEM, token)
self.assertEqual(raised.exception.status_code, 404)
self.assertNotIn("PRIVATE", raised.exception.detail)
self.assertEqual(len(artwork._cache), 0)
class ArtworkRouteTests(unittest.TestCase):
def test_authentication_and_private_response_headers(self):
app = FastAPI()
app.include_router(router.router)
client = TestClient(app)
self.assertEqual(client.get(f"/insights/artwork/{ITEM}?token=invalid").status_code, 401)
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
with patch.object(router, "get_runtime_settings", return_value=None), \
patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")):
result = client.get(f"/insights/artwork/{ITEM}?token=fixture")
self.assertEqual(result.status_code, 200)
self.assertEqual(result.content, PNG)
self.assertEqual(result.headers["cache-control"], "private, max-age=600")
self.assertEqual(result.headers["vary"], "Cookie, Authorization")
self.assertEqual(result.headers["x-content-type-options"], "nosniff")
+209
View File
@@ -0,0 +1,209 @@
import csv
import io
import json
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
from backend.app.routers import insights as router
from backend.app.services import insights, monthly_reports as reports
from backend.app.services.jellyfin_identity import link_user
from backend.tests.test_backend_quality import TempDatabaseMixin
from backend.tests.test_insights import LIBRARIES, NOW, USER, play
class MonthlyPeriodTests(unittest.TestCase):
def test_default_is_last_complete_calendar_month(self):
period = reports.month_periods(None, NOW)
self.assertEqual(period['month'], '2026-08')
self.assertEqual(period['period_start'], '2026-08-01T00:00:00+00:00')
self.assertEqual(period['period_end'], '2026-09-01T00:00:00+00:00')
self.assertEqual(period['comparison_start'], '2026-07-01T00:00:00+00:00')
self.assertEqual(period['comparison_end'], period['period_start'])
self.assertFalse(period['is_partial'])
self.assertEqual(len(period['available_months']), 24)
def test_leap_year_and_year_rollover(self):
period = reports.month_periods('2024-02', datetime(2024, 3, 2, tzinfo=timezone.utc))
data = insights.summarize([], LIBRARIES, datetime.fromisoformat(period['period_start']),
datetime.fromisoformat(period['period_end']), end_exclusive=True)
self.assertEqual(len(data['daily']), 29)
self.assertEqual(data['daily'][-1]['date'], '2024-02-29')
period = reports.month_periods(None, datetime(2026, 1, 1, tzinfo=timezone.utc))
self.assertEqual(period['month'], '2025-12')
self.assertEqual(period['comparison_month'], '2025-11')
def test_partial_month_matches_elapsed_time_and_caps_short_month(self):
period = reports.month_periods('2026-09', NOW)
self.assertTrue(period['is_partial'])
self.assertEqual(period['comparison_end'], '2026-08-07T12:00:00+00:00')
self.assertFalse(period['comparison_capped'])
period = reports.month_periods('2026-03', datetime(2026, 3, 31, 12, tzinfo=timezone.utc))
self.assertEqual(period['comparison_end'], '2026-03-01T00:00:00+00:00')
self.assertTrue(period['comparison_capped'])
def test_utc_month_is_used_near_local_month_boundary(self):
local = datetime(2026, 9, 1, 0, 30, tzinfo=timezone(timedelta(hours=12)))
self.assertEqual(reports.month_periods(None, local)['month'], '2026-07')
def test_invalid_future_and_out_of_range_months_are_rejected(self):
for month in ['', '2026-9', '2026-00', '2026-13', '2026-10', '2024-09', '2026-08\r\nheader', '../../file']:
with self.subTest(month=month), self.assertRaises(ValueError):
reports.month_periods(month, NOW)
def test_changes_handle_zero_baselines_and_decreases(self):
self.assertEqual(reports.change(0, 0), {'current': 0, 'previous': 0, 'difference': 0, 'percent': 0})
self.assertIsNone(reports.change(5, 0)['percent'])
self.assertEqual(reports.change(30, 60)['percent'], -50)
def test_adjacent_months_never_double_count_boundary_play(self):
start = datetime(2026, 8, 1, tzinfo=timezone.utc)
end = datetime(2026, 9, 1, tzinfo=timezone.utc)
row = play(ActivityDateInserted=start.isoformat())
previous = insights.summarize([row], LIBRARIES, reports.shift_month(start, -1), start, end_exclusive=True)
current = insights.summarize([row, row, play('next-month', ActivityDateInserted=end.isoformat())], LIBRARIES, start, end, end_exclusive=True)
self.assertEqual(previous['summary']['plays'], 0)
self.assertEqual(current['summary']['plays'], 1)
self.assertEqual(current['daily'][-1]['date'], '2026-08-31')
class MonthlyReportTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
reports._cache.clear()
db.create_user('viewer', 'Test-Password123!', auth_provider='jellyfin')
link_user('viewer', 'jf-viewer', 'http://jellyfin')
self.runtime = SimpleNamespace(jellyfin_base_url='http://jellyfin', jellyfin_api_key='PRIVATE-JF-KEY',
jellystat_base_url='http://jellystat', jellystat_api_key='PRIVATE-STATS-KEY')
self.runtime_patch = patch.object(reports, 'get_runtime_settings', return_value=self.runtime)
self.runtime_patch.start()
self.addCleanup(self.runtime_patch.stop)
self.clock = patch.object(reports, 'datetime', wraps=datetime)
self.clock.start().now.return_value = NOW
self.addCleanup(self.clock.stop)
def add_request(self, request_id, date, owner=42, status=2):
db.upsert_request_cache(request_id, request_id, 'movie', status, 'Request', 2026,
'viewer', 'viewer', owner, date, date, '{}')
async def test_report_uses_linked_identity_and_separates_periods_and_request_owners(self):
rows = [play('previous', PlaybackDuration=1800, ActivityDateInserted='2026-07-31T23:59:59Z'),
play('current', ActivityDateInserted='2026-08-01T00:00:00Z'),
play('future', ActivityDateInserted='2026-09-01T00:00:00Z')]
for request_id, date, owner in [(1, '2026-07-31T23:59:59Z', 42), (2, '2026-08-01T00:00:00Z', 42),
(3, '2026-08-15T00:00:00Z', 99), (4, '2026-09-01T00:00:00Z', 42)]:
self.add_request(request_id, date, owner)
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)) as remote, \
patch.object(insights.JellyfinClient, 'get_users', new_callable=AsyncMock) as directory:
result = await reports.get_monthly_report(USER, '2026-08')
self.assertEqual(result['summary']['minutes'], 60)
self.assertEqual(result['previous_summary']['minutes'], 30)
self.assertEqual(result['changes']['minutes']['percent'], 100)
self.assertEqual(result['requests']['total'], 1)
self.assertEqual(result['previous_requests']['total'], 1)
self.assertEqual(result['requests']['recent'][0]['request_id'], 2)
self.assertEqual(remote.await_args.args[0], 'jf-viewer')
directory.assert_not_called()
self.assertNotIn('PRIVATE', json.dumps(result))
self.assertNotIn('artwork_item_id', json.dumps(result))
self.assertNotIn('jf-viewer', json.dumps(result))
async def test_partial_comparison_ignores_later_days_in_previous_month(self):
rows = [play('previous', ActivityDateInserted='2026-08-07T11:59:59Z'),
play('cutoff', ActivityDateInserted='2026-08-07T12:00:00Z'),
play('later', ActivityDateInserted='2026-08-30T12:00:00Z'),
play('current', ActivityDateInserted='2026-09-01T12:00:00Z')]
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
result = await reports.get_monthly_report(USER, '2026-09')
self.assertEqual(result['previous_summary']['minutes'], 60)
self.assertEqual(result['summary']['minutes'], 60)
async def test_cache_isolated_by_identity_month_and_connections_but_requests_refresh(self):
db.create_user('second', 'Test-Password123!', auth_provider='jellyfin')
link_user('second', 'jf-second', 'http://jellyfin')
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
await reports.get_monthly_report(USER, '2026-08')
self.add_request(1, '2026-08-15T12:00:00Z')
result = await reports.get_monthly_report(USER, '2026-08')
self.assertEqual(result['requests']['total'], 1)
self.assertEqual(remote.await_count, 1)
await reports.get_monthly_report({**USER, 'username': 'second'}, '2026-08')
await reports.get_monthly_report(USER, '2026-07')
self.runtime.jellystat_api_key = 'rotated-key'
await reports.get_monthly_report(USER, '2026-08')
self.runtime.jellystat_base_url = 'http://other-jellystat'
await reports.get_monthly_report(USER, '2026-08')
self.assertEqual(remote.await_count, 5)
async def test_unlinked_and_unconfigured_never_fetch_history(self):
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock) as remote:
result = await reports.get_monthly_report({**USER, 'username': 'unlinked', 'auth_provider': 'local'})
self.assertEqual(result['state'], 'unlinked')
self.runtime.jellystat_api_key = None
result = await reports.get_monthly_report(USER)
self.assertEqual(result['state'], 'not_configured')
remote.assert_not_called()
async def test_failed_history_is_not_cached_or_returned_as_a_partial_report(self):
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, side_effect=HistoryLimitError()):
with self.assertRaises(HistoryLimitError):
await reports.get_monthly_report(USER)
self.assertEqual(reports._cache, {})
async def test_csv_preserves_unicode_and_quotes_but_blocks_formulas_and_private_fields(self):
rows = [play('csv', ActivityDateInserted='2026-08-10T12:00:00Z', NowPlayingItemName='=HYPERLINK("x")', Client='\t\ufeff@SUM(1,2)'),
play('unicode', NowPlayingItemId='second-film', ActivityDateInserted='2026-08-11T12:00:00Z', NowPlayingItemName='Amélie, "Paris"')]
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
report = await reports.get_monthly_report(USER)
exported = reports.report_csv(report)
cells = [cell for row in csv.reader(io.StringIO(exported.lstrip('\ufeff'))) for cell in row]
self.assertIn('\'=HYPERLINK("x")', cells)
self.assertIn("'\t\ufeff@SUM(1,2)", cells)
self.assertIn('Amélie, "Paris"', cells)
for private in ['PRIVATE', 'jf-viewer', 'artwork/', 'token=', 'http://jellystat']:
self.assertNotIn(private, exported)
class MonthlyReportRouteTests(unittest.TestCase):
def client(self, authenticated=True):
app = FastAPI()
app.include_router(router.router)
if authenticated:
app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
return TestClient(app)
def test_both_formats_require_auth_and_reject_scope_overrides(self):
for path in ['/insights/reports/monthly', '/insights/reports/monthly.csv']:
self.assertEqual(self.client(False).get(path).status_code, 401)
for query in ['userid=other', 'scope=server', 'user_id=1', 'month=2026-9', 'month=2026-08%0D%0Ax']:
self.assertEqual(self.client().get(path+'?'+query).status_code, 422)
def test_report_no_store_and_export_attachment_headers(self):
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'ready', 'month':'2026-08'}) as report, \
patch.object(router, 'report_csv', return_value='\ufeffMetric,Value\r\nMinutes,60\r\n'):
response = self.client().get('/insights/reports/monthly?month=2026-08')
self.assertEqual(response.headers['cache-control'], 'no-store')
report.assert_awaited_with({**USER, "features": {"stats": True}}, '2026-08')
response = self.client().get('/insights/reports/monthly.csv?month=2026-08')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers['content-type'], 'text/csv; charset=utf-8')
self.assertEqual(response.headers['cache-control'], 'no-store')
self.assertEqual(response.headers['x-content-type-options'], 'nosniff')
self.assertEqual(response.headers['content-disposition'], 'attachment; filename="magent-monthly-report-2026-08.csv"')
self.assertTrue(response.content.startswith(b'\xef\xbb\xbf'))
def test_errors_are_sanitized_and_unlinked_export_is_blocked(self):
for exception, status in [(JellystatError('PRIVATE'), 502), (HistoryLimitError('PRIVATE'), 422), (ValueError('PRIVATE'), 422)]:
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, side_effect=exception):
for suffix in ['', '.csv']:
response = self.client().get('/insights/reports/monthly'+suffix)
self.assertEqual(response.status_code, status)
self.assertNotIn('PRIVATE', response.text)
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'unlinked'}):
self.assertEqual(self.client().get('/insights/reports/monthly.csv').status_code, 409)
+505
View File
@@ -0,0 +1,505 @@
import io
import re
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from email import policy
from email.parser import BytesParser
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlsplit
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
from PIL import Image
from backend.app import db
from backend.app.auth import get_current_user
from backend.app.routers import newsletters as router
from backend.app.services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
from backend.app.services import newsletter_email as template, recap_store, recap_email as mail, email_recaps
from backend.app.services.jellyfin_identity import link_user, source_key
from backend.tests.test_backend_quality import TempDatabaseMixin
from backend.tests.test_email_recaps import runtime
USER_ID, SERVER_ID, VIEW_ID = 'a' * 32, 'b' * 32, 'c' * 32
MOVIE_ID, SERIES_ID, EPISODE_ID, SECOND_EPISODE = '1' * 32, '2' * 32, '3' * 32, '4' * 32
START = datetime(2026, 9, 4, 9, tzinfo=timezone.utc)
END = START + timedelta(days=7)
def arrivals():
return [dict(Id=MOVIE_ID, Name='Arrival', Type='Movie', DateCreated='2026-09-10T10:00:00Z', ProductionYear=2016, ImageTags={'Primary': 'art'}, Overview='A story <with> meaning.'),
dict(Id=EPISODE_ID, Name='Episode one', Type='Episode', SeriesId=SERIES_ID, SeriesName='Severance', SeriesPrimaryImageTag='art', DateCreated='2026-09-09T10:00:00Z', ParentIndexNumber=2, IndexNumber=1),
dict(Id=SECOND_EPISODE, Name='Episode two', Type='Episode', SeriesId=SERIES_ID, SeriesName='Severance', SeriesPrimaryImageTag='art', DateCreated='2026-09-08T10:00:00Z', ParentIndexNumber=2, IndexNumber=2)]
def content():
titles = catalog.group_arrivals(arrivals(), START, END)
for title in titles:
title['selected'] = True
return dict(titles=titles, total_titles=len(titles), source=source_key('http://jellyfin'), server_id=SERVER_ID,
playback_url='https://watch.example.test', period_start=START.isoformat(), period_end=END.isoformat())
def jpeg():
image = Image.new('RGB', (100, 150), '#69508c')
target = io.BytesIO()
image.save(target, format='JPEG')
return target.getvalue()
class NewsletterFixture(TempDatabaseMixin):
def setUp(self):
super().setUp()
db.create_user('viewer', 'Example-Password123!', role='admin', email='viewer@example.test')
link_user('viewer', USER_ID, 'http://jellyfin')
self.user = db.get_user_by_username('viewer')
self.runtime = runtime()
self.runtime.jellyfin_api_key = 'PRIVATE-JF-KEY'
self.runtime.jellyfin_public_url = 'https://watch.example.test'
for target, name, result in [(service, 'get_runtime_settings', self.runtime), (email_recaps, 'get_runtime_settings', self.runtime),
(router, 'get_runtime_settings', self.runtime), (mail, 'get_runtime_settings', self.runtime), (service, 'smtp_email_config_ready', (True, 'ok'))]:
mocked = patch.object(target, name, return_value=result)
mocked.start(); self.addCleanup(mocked.stop)
env = patch.dict('os.environ', {'BACKGROUND_TASKS_ENABLED': 'true'})
env.start(); self.addCleanup(env.stop)
self.config = store.save_settings({**store.public_settings(), 'public_url': 'https://beta.example.test'}, datetime.now(timezone.utc))
def subscribe(self, when=None, table=store):
now = time.time() - 10 if when is None else when
token = table.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, now)
self.assertTrue(table.confirm(table.subscription(self.user['id']), now+1))
return table.subscription(self.user['id']), token
def draft(self):
return store.create_edition(content(), 'Weekend discoveries', '', 'viewer', time.time())
def queue(self, sub=None, edition=None, request='test-request'):
sub = sub or self.subscribe()[0]
edition = edition or self.draft()
return store.enqueue_test(sub, edition['id'], edition['revision'], request, self.config['public_url'], time.time())
def delivery(self, identity):
return store.read_one('SELECT * FROM newsletter_deliveries WHERE id=?', (identity,))
class NewsletterConsentTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
async def test_confirmation_only_then_explicit_public_post(self):
with patch.object(mail, 'send_email') as sender:
result = await service.subscribe(self.user)
self.assertEqual(result['state'], 'pending')
rendered = sender.call_args.args[1]
self.assertNotIn('Arrival', rendered['body_html'])
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
self.assertEqual(urlsplit(url).path, '/newsletter-subscription')
token = parse_qs(urlsplit(url).fragment)['token'][0]
self.assertEqual(service.token_action(token, 'confirm')['state'], 'ready')
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
self.assertEqual(service.token_action(token, 'confirm', apply=True)['state'], 'enabled')
with self.assertRaises(service.NewsletterError):
service.token_action(token, 'confirm', apply=True)
async def test_monthly_consent_is_not_automatic_newsletter_consent(self):
recap, _ = self.subscribe(table=recap_store)
self.assertEqual(service.preferences(self.user)['state'], 'off')
with patch.object(mail, 'send_email') as sender:
result = await service.subscribe(self.user)
self.assertEqual(result['state'], 'enabled')
sender.assert_not_called()
sub = store.subscription(self.user['id'])
service.token_action(sub['unsubscribe_token'], 'unsubscribe', apply=True)
self.assertEqual(recap_store.subscription(self.user['id'])['state'], 'enabled')
with self.assertRaises(service.NewsletterError):
service.token_action(recap['unsubscribe_token'], 'unsubscribe', apply=True)
def test_unsubscribe_read_only_check_and_repeated_apply(self):
sub, _ = self.subscribe()
identity = self.queue(sub)
token = sub['unsubscribe_token']
self.assertEqual(service.token_action(token, 'unsubscribe')['state'], 'ready')
self.assertEqual(self.delivery(identity)['state'], 'queued')
for _ in range(2):
self.assertEqual(service.token_action(token, 'unsubscribe', apply=True)['state'], 'off')
self.assertEqual(self.delivery(identity)['state'], 'cancelled')
def test_address_and_identity_changes_revoke_consent(self):
for index, change in enumerate(['email', 'identity', 'blocked', 'source']):
with self.subTest(change=change):
self.subscribe(time.time() - 1600 + index * 400)
if change == 'email':
db.set_user_email('viewer', 'changed@example.test')
db.set_user_email('viewer', 'viewer@example.test')
elif change == 'identity':
with store.transaction() as conn:
conn.execute('UPDATE jellyfin_user_links SET jellyfin_user_id=?', ('d' * 32,))
conn.execute('UPDATE jellyfin_user_links SET jellyfin_user_id=?', (USER_ID,))
elif change == 'blocked':
with store.transaction() as conn:
conn.execute('UPDATE users SET is_blocked=1')
conn.execute('UPDATE users SET is_blocked=0')
else:
self.runtime.jellyfin_base_url = 'http://changed-jellyfin'
self.assertEqual(service.preferences(self.user)['state'], 'off')
async def test_failed_confirmation_stays_pending_and_rate_limited(self):
with patch.object(mail, 'send_email', side_effect=mail.DeliveryError('unknown', 'fixture')):
with self.assertRaises(service.NewsletterError) as raised:
await service.subscribe(self.user)
self.assertEqual(raised.exception.status, 502)
with self.assertRaises(service.NewsletterError) as raised:
await service.subscribe(self.user)
self.assertEqual(raised.exception.status, 429)
self.assertEqual(service.preferences(self.user)['state'], 'pending')
def test_expired_or_changed_confirmation_cannot_enable(self):
token = store.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, time.time()-90000)
self.assertEqual(service.preferences(self.user)['state'], 'expired')
with self.assertRaises(service.NewsletterError):
service.token_action(token, 'confirm', apply=True)
token = store.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, time.time())
previous = store.subscription(self.user['id'])
db.set_user_email('viewer', 'changed@example.test')
self.assertFalse(store.confirm(previous, time.time()))
with self.assertRaises(service.NewsletterError):
service.token_action(token, 'confirm', apply=True)
class NewsletterEditionTests(NewsletterFixture, unittest.TestCase):
def test_defaults_paused_no_users_opted_in(self):
self.assertFalse(store.settings()['enabled'])
self.assertEqual(store.overview()['subscribers'], 0)
self.assertIsNone(store.claim_weekly(datetime.now(timezone.utc)))
def test_conflicts_and_title_injection_fail(self):
row = self.draft()
selections = [{key: title[key] for key in ('id', 'selected', 'featured')} for title in row['content']['titles']]
for values in [[], [*selections, selections[0]], [{**selections[0], 'id': 'f'*32}, selections[1]], [{**selections[0], 'selected': False, 'featured': True}, selections[1]]]:
with self.assertRaises(store.Conflict):
store.update_edition(row['id'], 1, 'Updated', '', values, time.time())
result = store.update_edition(row['id'], 1, 'Updated', 'An announcement', selections, time.time())
self.assertEqual(result['revision'], 2)
with self.assertRaises(store.Conflict):
store.update_edition(row['id'], 1, 'Stale tab', '', selections, time.time())
def test_test_version_is_frozen_and_request_idempotent(self):
sub, _ = self.subscribe()
row = self.draft()
identity = self.queue(sub, row)
self.assertEqual(self.queue(sub, row), identity)
selections = [{key: title[key] for key in ('id', 'selected', 'featured')} for title in row['content']['titles']]
changed = store.update_edition(row['id'], 1, 'Changed after test queued', '', selections, time.time())
self.assertEqual(store.version(self.delivery(identity))['subject'], 'Weekend discoveries')
with self.assertRaises(store.Conflict):
self.queue(sub, changed)
with self.assertRaises(store.Conflict):
self.queue(sub, changed, 'different-request')
def test_publish_is_immutable_and_delivery_deduplicated_under_concurrency(self):
self.subscribe()
row = self.draft()
now = time.time()
store.publish(row['id'], 1, now, now)
self.assertEqual(store.publish(row['id'], 1, now+1, now+1)['send_at'], now)
with self.assertRaises(store.Conflict):
store.update_edition(row['id'], 1, 'No longer editable', '', [], now)
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(store.enqueue_due, [now+5]*4))
self.assertEqual(store.overview()['total'], 1)
with ThreadPoolExecutor(max_workers=4) as pool:
claims = list(pool.map(store.claim_delivery, [now+5]*4))
self.assertEqual(sum(claim is not None for claim in claims), 1)
store.cancel(row['id'], now+6)
self.assertEqual(store.overview()['deliveries'][0]['state'], 'cancelled')
self.assertFalse(store.begin_sending(next(claim for claim in claims if claim), now+6))
def test_empty_edition_needs_announcement(self):
row = store.create_edition({**content(), 'titles': []}, 'Announcement', '', 'viewer', time.time())
with self.assertRaises(store.Conflict):
store.publish(row['id'], 1, time.time(), time.time())
row = store.update_edition(row['id'], 1, 'Announcement', 'Welcome to the weekend.', [], time.time())
self.assertEqual(store.publish(row['id'], 2, time.time(), time.time())['state'], 'scheduled')
def test_schedule_boundary_utc_and_no_past_time(self):
self.assertEqual(store.next_due(END, 4, 9), END+timedelta(days=7))
self.assertEqual(store.next_due(END-timedelta(seconds=1), 4, 9), END)
row = self.draft()
for when in [datetime.now(), datetime.now(timezone.utc)-timedelta(days=1), datetime.now(timezone.utc)+timedelta(days=91)]:
with self.assertRaises(service.NewsletterError):
service.publish(row['id'], 1, when)
def test_weekly_catchup_once_not_every_missed_week_and_late_subscriber_excluded(self):
config = store.save_settings({**self.config, 'enabled': True}, START-timedelta(days=40))
self.subscribe(END.timestamp()+30)
claimed = store.claim_weekly(END+timedelta(hours=1))
self.assertEqual(claimed['due'], END)
self.assertIsNone(store.claim_weekly(END+timedelta(hours=1)))
store.complete_weekly(claimed, content(), END+timedelta(hours=1))
store.complete_weekly(claimed, content(), END+timedelta(hours=1))
store.enqueue_due((END+timedelta(hours=1)).timestamp())
self.assertEqual(len(store.overview()['editions']), 1)
self.assertEqual(store.overview()['total'], 0)
self.assertEqual(store.settings()['next_send_at'], (END+timedelta(days=7)).timestamp())
self.assertTrue(config['enabled'])
def test_empty_week_is_skipped_and_generation_failure_retries_three_times(self):
store.save_settings({**self.config, 'enabled': True}, START)
now = END
for attempt in range(3):
claimed = store.claim_weekly(now)
self.assertEqual(claimed['generation_attempts'], attempt+1)
store.complete_weekly(claimed, None, now, 'Jellyfin unavailable')
self.assertIsNone(store.claim_weekly(now+timedelta(seconds=1)))
now += timedelta(seconds=301)
self.assertEqual(store.overview()['editions'], [])
claimed = store.claim_weekly(END+timedelta(days=7))
store.complete_weekly(claimed, {**content(), 'titles': []}, END+timedelta(days=7))
self.assertEqual(store.overview()['editions'][0]['state'], 'skipped')
self.assertEqual(store.overview()['total'], 0)
def test_pause_cancels_weekly_but_preserves_manual_and_stale_generation_cannot_publish(self):
self.subscribe(START.timestamp()-10)
manual = self.draft()
store.publish(manual['id'], 1, END.timestamp()+3600, START.timestamp())
config = store.save_settings({**self.config, 'enabled': True}, START)
claimed = store.claim_weekly(END)
store.complete_weekly(claimed, content(), END)
store.enqueue_due(END.timestamp())
store.save_settings({**config, 'enabled': False}, END)
self.assertEqual(store.edition(manual['id'])['state'], 'scheduled')
self.assertEqual(store.overview()['deliveries'][0]['state'], 'cancelled')
store.complete_weekly(claimed, content(), END)
self.assertEqual(len(store.overview()['editions']), 2)
with self.assertRaises(store.Conflict):
store.save_settings(config, END)
def test_stale_smtp_claim_becomes_unknown_and_never_retried(self):
identity = self.queue()
now = time.time()
claimed = store.claim_delivery(now)
self.assertTrue(store.begin_sending(claimed, now))
self.assertIsNone(store.claim_delivery(now+1801))
self.assertEqual(self.delivery(identity)['state'], 'unknown')
self.assertIsNone(store.claim_delivery(now+7200))
class NewsletterCatalogTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.runtime = runtime()
self.runtime.jellyfin_api_key = 'private'
def test_grouped_tv_boundary_deduplication_virtual_and_missing_names(self):
rows = arrivals()
rows += [rows[0], {**rows[0], 'Id': '5'*32, 'DateCreated': END.isoformat()},
{**rows[0], 'Id': '6'*32, 'DateCreated': START.isoformat()},
{**rows[0], 'Id': '7'*32, 'LocationType': 'Virtual'},
{**rows[1], 'Id': '8'*32, 'SeriesId': '9'*32, 'SeriesName': None}]
result = catalog.group_arrivals(rows, START, END)
self.assertEqual(len(result), 3)
tv = next(title for title in result if title['type'] == 'series')
self.assertEqual(tv['id'], SERIES_ID)
self.assertEqual(len(tv['items']), 2)
self.assertTrue(tv['has_artwork'])
self.assertIn('2 new episodes', template.description(tv))
async def test_collect_pages_and_fails_closed_on_incomplete_or_changed_library(self):
async def collect_pages(pages):
with patch.object(catalog, 'PAGE_SIZE', 2), patch.object(catalog, 'MAX_ITEMS', 4), patch.object(catalog, 'get_json', new=AsyncMock(side_effect=[{'Id': SERVER_ID}, *pages])):
return await catalog.collect(self.runtime, START, END, 1)
rows = arrivals()
result = await collect_pages([{'Items': rows[:2], 'TotalRecordCount': 3}, {'Items': rows[2:], 'TotalRecordCount': 3}])
self.assertEqual(result['total_titles'], 2)
self.assertEqual(sum(title['selected'] for title in result['titles']), 1)
cases = [
[{'Items': rows[:1], 'TotalRecordCount': 3}],
[{'Items': rows[:2], 'TotalRecordCount': None}],
[{'Items': rows[:2], 'TotalRecordCount': 3}, {'Items': rows[:1], 'TotalRecordCount': 3}],
[{'Items': [rows[1], rows[0]], 'TotalRecordCount': 2}],
[{'Items': [{**rows[0], 'DateCreated': None}], 'TotalRecordCount': 1}],
[{'Items': rows[:2], 'TotalRecordCount': 5}, {'Items': [rows[2], {**rows[2], 'Id': '5'*32}], 'TotalRecordCount': 5}],
]
for pages in cases:
with self.subTest(pages=pages), self.assertRaises(catalog.CatalogError):
await collect_pages(pages)
async def test_recipient_scope_requires_permitted_parent_even_with_ids(self):
async def jellyfin(client, runtime, path, params=None):
if path == '/System/Info': return {'Id': SERVER_ID}
if path.startswith('/Users/'): return {'Id': USER_ID, 'Policy': {'IsDisabled': False}}
if path == '/UserViews':
self.assertEqual(params['UserId'], USER_ID)
return {'Items': [{'Id': VIEW_ID}]}
self.assertEqual(path, '/Items')
self.assertEqual(params['ParentId'], VIEW_ID)
self.assertEqual(params['UserId'], USER_ID)
self.assertEqual(params['Recursive'], 'true')
# The movie and second episode belong to a restricted library.
return {'Items': [{'Id': SERIES_ID}, {'Id': EPISODE_ID}]}
with patch.object(catalog, 'get_json', side_effect=jellyfin):
result = await catalog.for_recipient(self.runtime, content(), USER_ID)
self.assertEqual(len(result['titles']), 1)
self.assertEqual(len(result['titles'][0]['items']), 1)
self.assertIn('1 new episode', template.description(result['titles'][0]))
async def test_no_views_does_not_fall_back_to_unrestricted_lookup(self):
responses = [{'Id': SERVER_ID}, {'Id': USER_ID, 'Policy': {}}, {'Items': []}]
with patch.object(catalog, 'get_json', new=AsyncMock(side_effect=responses)) as get:
result = await catalog.for_recipient(self.runtime, content(), USER_ID)
self.assertEqual(result['titles'], [])
self.assertEqual(get.await_count, 3)
async def test_changed_server_or_user_and_disabled_account_stop_delivery(self):
for responses in [[{'Id': 'f'*32}], [{'Id': SERVER_ID}, {'Id': 'f'*32, 'Policy': {}}], [{'Id': SERVER_ID}, {}]]:
with patch.object(catalog, 'get_json', new=AsyncMock(side_effect=responses)), self.assertRaises(catalog.CatalogError):
await catalog.for_recipient(self.runtime, content(), USER_ID)
with patch.object(catalog, 'get_json', new=AsyncMock(side_effect=[{'Id': SERVER_ID}, {'Id': USER_ID, 'Policy': {'IsDisabled': True}}])):
result = await catalog.for_recipient(self.runtime, content(), USER_ID)
self.assertTrue(result['recipient_disabled'])
async def test_posters_are_bounded_reencoded_cached_and_keys_stay_server_side(self):
original = httpx.AsyncClient
requests = []
def handler(request):
requests.append(request)
self.assertEqual(request.headers['X-Emby-Token'], 'private')
self.assertNotIn('private', str(request.url))
return httpx.Response(200, content=jpeg(), headers={'Content-Type': 'image/jpeg'})
catalog._posters.clear()
with patch.object(catalog.httpx, 'AsyncClient', side_effect=lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs)):
result = await catalog.poster(self.runtime, MOVIE_ID)
self.assertEqual(await catalog.poster(self.runtime, MOVIE_ID), result)
self.assertEqual(len(requests), 1)
with Image.open(io.BytesIO(result)) as image:
self.assertEqual(image.format, 'JPEG')
self.assertLessEqual(image.width, 160)
for data in [b'not an image', b'x' * (512*1024+1)]:
catalog._posters.clear()
with patch.object(catalog.httpx, 'AsyncClient', side_effect=lambda **kwargs: original(transport=httpx.MockTransport(lambda request: httpx.Response(200, content=data)), **kwargs)):
self.assertIsNone(await catalog.poster(self.runtime, MOVIE_ID))
class NewsletterDeliveryTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
async def test_weekly_worker_collects_once_and_delivers_to_confirmed_subscriber(self):
now = datetime.now(timezone.utc)
self.subscribe((now-timedelta(days=14)).timestamp())
store.save_settings({**self.config, 'enabled': True}, now-timedelta(days=10))
def scoped(runtime, snapshot, identity):
self.assertEqual(identity, USER_ID)
return snapshot
def captured(recipient, rendered, message_id, before_data):
before_data()
with patch.object(service, 'collect', new=AsyncMock(return_value=content())) as collect, patch.object(catalog, 'for_recipient', new=AsyncMock(side_effect=scoped)), patch.object(catalog, 'posters', new=AsyncMock(return_value={})), patch.object(mail, 'send_email', side_effect=captured) as send:
await service.run_once()
await service.run_once()
self.assertEqual(collect.await_count, 1)
self.assertEqual(send.call_count, 1)
self.assertEqual(store.overview()['total'], 1)
self.assertEqual(store.overview()['deliveries'][0]['state'], 'sent')
self.assertEqual(store.overview()['editions'][0]['state'], 'complete')
self.assertGreater(store.settings()['next_send_at'], now.timestamp())
async def test_captured_smtp_contains_inline_posters_and_only_accessible_titles(self):
identity = self.queue()
scoped = {**content(), 'titles': content()['titles'][1:]}
scoped.update(subject='Weekend discoveries', intro='A <b>plain text</b> welcome')
smtp = MagicMock()
smtp.mail.return_value = smtp.rcpt.return_value = smtp.data.return_value = (250, b'ok')
with patch.object(catalog, 'for_recipient', new=AsyncMock(return_value=scoped)) as scope, patch.object(catalog, 'posters', new=AsyncMock(return_value={SERIES_ID: jpeg()})), patch.object(mail.smtplib, 'SMTP', return_value=smtp):
await service.process_delivery(store.claim_delivery(time.time()))
self.assertEqual(scope.call_args.args[2], USER_ID)
self.assertEqual(self.delivery(identity)['state'], 'sent')
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
body = message.get_body(preferencelist=('html',)).get_content()
self.assertIn('Severance', body)
self.assertNotIn('Arrival</h3>', body)
self.assertIn('&lt;b&gt;plain text&lt;/b&gt;', body)
self.assertNotIn('PRIVATE-', body)
self.assertIn('cid:newsletter-', body)
self.assertNotIn('data:image', body)
self.assertIn('serverId=' + SERVER_ID, body)
images = [part for part in message.walk() if part.get_content_type() == 'image/jpeg']
self.assertEqual(len(images), 1)
self.assertEqual(images[0].get_payload(decode=True), jpeg())
self.assertIn('/newsletter-subscription#action=unsubscribe', body)
self.assertEqual(str(message['To']), 'viewer@example.test')
async def test_cancel_between_preparation_and_smtp_data_stops_send(self):
identity = self.queue()
scoped = {**content(), 'subject': 'Subject', 'intro': ''}
def send(recipient, rendered, message_id, before_data):
store.disable(self.user['id'])
before_data()
self.fail('SMTP DATA must not run after unsubscribe')
with patch.object(catalog, 'for_recipient', new=AsyncMock(return_value=scoped)), patch.object(catalog, 'posters', new=AsyncMock(return_value={})), patch.object(mail, 'send_email', side_effect=send):
await service.process_delivery(store.claim_delivery(time.time()))
self.assertEqual(self.delivery(identity)['state'], 'cancelled')
async def test_access_failure_retries_but_empty_access_skips(self):
identity = self.queue()
with patch.object(catalog, 'for_recipient', new=AsyncMock(side_effect=catalog.CatalogError('fixture'))), patch.object(mail, 'send_email') as send:
await service.process_delivery(store.claim_delivery(time.time()))
self.assertEqual(self.delivery(identity)['state'], 'retry')
send.assert_not_called()
with patch.object(catalog, 'for_recipient', new=AsyncMock(return_value={**content(), 'titles': [], 'intro': ''})), patch.object(mail, 'send_email') as send:
await service.process_delivery(store.claim_delivery(time.time()+301))
self.assertEqual(self.delivery(identity)['state'], 'skipped')
send.assert_not_called()
def test_multiple_images_share_one_related_mime_container(self):
rendered = template.render({**content(), 'subject': 'Subject', 'intro': ''}, {MOVIE_ID: jpeg(), SERIES_ID: jpeg()}, self.config['public_url'], self.runtime.jellyfin_public_url, 'https://beta.example.test/profile#newsletters')
smtp = MagicMock()
smtp.mail.return_value = smtp.rcpt.return_value = smtp.data.return_value = (250, b'ok')
with patch.object(mail.smtplib, 'SMTP', return_value=smtp):
mail.send_email('viewer@example.test', rendered, '<test@example.test>')
message = BytesParser(policy=policy.default).parsebytes(smtp.data.call_args.args[0])
related = [part for part in message.walk() if part.get_content_type() == 'multipart/related']
self.assertEqual(len(related), 1)
self.assertEqual(len(related[0].get_payload()), 3)
class NewsletterApiTests(NewsletterFixture, unittest.TestCase):
def setUp(self):
super().setUp()
app = FastAPI()
app.include_router(router.router)
self.actor = {'username': 'viewer', 'role': 'admin'}
app.dependency_overrides[get_current_user] = lambda: self.actor
self.client = TestClient(app)
self.addCleanup(self.client.close)
def test_admin_endpoints_and_personal_preference_do_not_accept_other_identity(self):
identity = self.draft()['id']
self.actor['role'] = 'user'
for method, path, payload in [('GET', '/admin/newsletters', None), ('PUT', '/admin/newsletters', self.config),
('POST', '/admin/newsletters/drafts', {'days': 7}), ('GET', '/admin/newsletters/editions/'+identity, None),
('POST', f'/admin/newsletters/editions/{identity}/publish', {'revision': 1}), ('GET', '/admin/newsletters/artwork/'+MOVIE_ID, None)]:
self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
response = self.client.get('/profile/newsletters')
self.assertEqual(response.status_code, 200)
self.assertIn('no-store', response.headers['cache-control'])
for extra in [{'email': 'other@example.test'}, {'user_id': 99}, {'jellyfin_id': USER_ID}]:
self.assertEqual(self.client.put('/profile/newsletters', json={'enabled': True, **extra}).status_code, 422)
def test_admin_settings_validate_origin_revision_and_subject(self):
payload = {key: self.config[key] for key in ('enabled', 'weekday', 'hour', 'limit_titles', 'public_url', 'intro', 'revision')}
for invalid in ['https://beta.example.test/path', 'javascript:alert(1)', 'https://user:pass@example.test']:
self.assertEqual(self.client.put('/admin/newsletters', json={**payload, 'public_url': invalid}).status_code, 422)
self.assertEqual(self.client.put('/admin/newsletters', json=payload).status_code, 200)
self.assertEqual(self.client.put('/admin/newsletters', json=payload).status_code, 409)
row = self.draft()
values = [{'id': entry['id'], 'selected': entry['selected'], 'featured': entry['featured']} for entry in row['content']['titles']]
for subject in [' ', 'Subject\r\nBcc: someone@example.test']:
self.assertEqual(self.client.put(f"/admin/newsletters/editions/{row['id']}", json={'revision': 1, 'subject': subject, 'intro': '', 'titles': values}).status_code, 422)
def test_confirmation_get_never_changes_consent_and_invalid_action_rejected(self):
token = store.request_confirmation(self.user, source_key('http://jellyfin'), USER_ID, time.time())
self.assertEqual(self.client.get('/newsletter-subscription/confirm').status_code, 405)
self.assertEqual(store.subscription(self.user['id'])['state'], 'pending')
self.assertEqual(self.client.post('/newsletter-subscription/check', json={'token': token, 'action': 'confirm'}).json()['state'], 'ready')
self.assertEqual(self.client.post('/newsletter-subscription/confirm', json={'token': token, 'action': 'subscribe'}).status_code, 422)
self.assertEqual(self.client.post('/newsletter-subscription/confirm', json={'token': token, 'action': 'confirm'}).json()['state'], 'enabled')
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -15,7 +15,7 @@ class PortalPrivacyTests(unittest.TestCase):
'message': 'Sent to secret@example.com for private-reporter', 'is_internal': False}
app = FastAPI()
app.include_router(portal.router)
app.dependency_overrides[portal.get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
app.dependency_overrides[portal.get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'issues': True, 'requests': True, 'new_requests': True}}
with patch.object(portal, 'get_portal_item', return_value=item), \
patch.object(portal, '_list_portal_comments', return_value=[comment]), \
patch.object(portal, 'list_portal_item_activity', return_value=[]), \
+67
View File
@@ -0,0 +1,67 @@
import copy
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from backend.app.services.request_language import language_info, original_profile, is_original_profile
from backend.app.routers import requests
class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
def test_metadata_is_not_audio_evidence(self):
for code in ('en', '', 'xx', 'invalid'):
self.assertIsNone(language_info({'originalLanguage': code}))
self.assertEqual(language_info({'original_language': 'es'}), {'code': 'es'})
async def test_copy_preserves_quality_and_reuses_verified_profile(self):
default = {'id': 6, 'name': 'HD', 'language': {'id': 1, 'name': 'English'},
'items': [{'quality': {'id': 7}, 'allowed': True}], 'minFormatScore': 50,
'formatItems': [{'format': 10, 'score': -1000}], 'upgradeAllowed': True}
original = copy.deepcopy(default)
client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[default]), post=AsyncMock(return_value={'id': 20}))
self.assertEqual(await original_profile(client, 6), 20)
payload = client.post.await_args.kwargs['payload']
self.assertEqual(payload['language']['id'], -2)
self.assertEqual(payload['items'], default['items'])
self.assertEqual(payload['formatItems'], default['formatItems'])
self.assertEqual(payload['minFormatScore'], 50)
self.assertEqual(default, original)
self.assertTrue(is_original_profile(payload))
client.get_quality_profiles.return_value.append({**payload, 'id': 20})
self.assertEqual(await original_profile(client, 6), 20)
self.assertEqual(client.post.await_count, 1)
payload['minFormatScore'] = 0
self.assertFalse(is_original_profile(payload))
async def test_missing_default_never_creates_profile(self):
client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[]), post=AsyncMock())
with self.assertRaises(HTTPException):
await original_profile(client, 6)
client.post.assert_not_awaited()
async def test_only_explicit_verified_movie_consent_changes_destination(self):
runtime = SimpleNamespace(jellyseerr_base_url='http://seerr', jellyseerr_api_key='key',
radarr_base_url='http://radarr', radarr_api_key='key')
seerr = SimpleNamespace(configured=lambda: True, get_movie=AsyncMock(), get_tv=AsyncMock(),
create_request=AsyncMock(return_value={'status': 1}))
for code, consent, media_type, expected in [('es', True, 'movie', 20), ('es', False, 'movie', 6),
('en', True, 'movie', None), ('es', 'yes', 'movie', None),
('ja', True, 'tv', 6)]:
details = {'title': 'Title', 'originalLanguage': code, 'seasons': [{'seasonNumber': 1}]}
seerr.get_movie.return_value = seerr.get_tv.return_value = details
seerr.create_request.reset_mock()
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
patch.object(requests, 'JellyseerrClient', return_value=seerr), \
patch.object(requests, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
if expected is None:
with self.assertRaises(HTTPException):
await requests.create_request(payload, {'username': 'viewer'})
seerr.create_request.assert_not_awaited()
clone.assert_not_awaited()
else:
await requests.create_request(payload, {'username': 'viewer'})
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
self.assertEqual(clone.await_count, int(expected == 20))
+22
View File
@@ -0,0 +1,22 @@
# Duplicate account repair
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a same-name conflict. An individual user's management overlay also links to this view with their username prefilled.
The preview recommends the Magent row that already owns the Jellyfin link, or the oldest row if none does. Administrators can select a different row from the group. Confirmation requires an explicit acknowledgement that the rows belong to the same person.
Eligibility requires a single current Jellyfin account for the normalized name, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
The transaction:
- Archives the account records, settings, subscriptions and identity links in `user_duplicate_repairs`, recording the administrator and timestamp. This internal archive includes credential fields and is never returned through the preview API.
- Keeps the selected Magent ID, email and profile, and uses the current verified Jellyfin username.
- Preserves the most restrictive feature permissions, automatic-search setting, invitation access, any block and the earliest expiry.
- Consolidates username references for requests, issues, comments, invitations and login activity. Seerr request IDs and Seerr author IDs remain unchanged.
- Retains email delivery history, cancels outstanding deliveries from retired rows, and does not inherit their subscriptions. The retained account's own subscriptions remain subject to the normal identity and access checks. Sending emails block repair until they finish.
- Invalidates existing password-reset links, removes the extra active Magent rows, and confirms the retained account's verified service links. Affected users may need to sign in again.
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no automatic bulk merge or self-service undo. The archive supports administrative investigation; unrelated or renamed identities require separate review.
Both preview and confirmation recheck live service mappings. A transaction rechecks local identity state, permissions, subscriptions and connection settings before writing. Stale previews fail with HTTP 409. Account creation/import checks normalized usernames under a SQLite write lock to prevent concurrent case/whitespace duplicates from recurring.
Validation: temporary-database tests cover history, permissions, consent, rollback, concurrent creation, stale previews and ownership conflicts. `scripts/review_duplicate_accounts_ui.cjs` checks desktop/mobile UI and confirmation using intercepted API fixtures only.
+22
View File
@@ -0,0 +1,22 @@
# Personal report emails
## On-demand personal reports
Users can email the month shown on **My stats > Monthly report**, including the
current month to date or any available previous month. Delivery uses the confirmed
profile email and the existing private report generator. The API does not accept
another user, recipient, or delivery kind. The request UUID is idempotent, and
manual/test report requests share a five-minute per-user cooldown.
**Profile > Your reports, your choice** offers on-demand-only delivery or on-demand
plus automatic monthly emails. New confirmations default to on-demand-only;
existing confirmed subscribers retain their previous automatic monthly preference.
Turning automatic delivery off cancels pending scheduled emails, but preserves
verified-email consent and explicitly requested emails. A full unsubscribe, email
change, identity change or blocked account prevents pending personal delivery.
On-demand requests work while the monthly schedule is paused, provided SMTP,
Jellystat and the delivery worker are available. Delivery status is available to
the requesting user and in the admin recap history. New-arrivals newsletters are
unchanged and keep their independent subscriptions and schedule.
+93
View File
@@ -0,0 +1,93 @@
# Jellystat in Magent Beta
Magent's **My Stats** page (`/insights`) reads personal viewing history from an existing Jellystat instance. Jellystat owns playback collection, history and retention. Magent does not install Jellystat, collect sessions or keep a second playback database.
## Setup
1. Run Jellystat and connect it to the same Jellyfin server Magent uses. Let its initial sync finish.
2. Create an API key in Jellystat's settings.
3. In Magent, open **Configuration → Jellystat**, enter its internal URL and API key, save, and test the connection. Include any reverse-proxy base path in the URL.
4. Sign in using Jellyfin. Existing Jellyfin accounts can also be linked by **Configuration → Jellyfin → Import Jellyfin users**. First use of My Stats resolves an existing Jellyfin account against Jellyfin's user directory using its exact username.
Alternatively, set these backend environment variables:
```dotenv
JELLYSTAT_URL=http://jellystat:3000
JELLYSTAT_API_KEY=your-jellystat-api-key
```
`JELLYSTAT_BASE_URL` is also accepted. Docker deployments already load the backend environment through `.env`. These are server settings; no `NEXT_PUBLIC_` variables or browser credentials are needed. Saved Configuration values override environment values.
## What users see
- Past 7, 30, 90 or 365 days of watch time, distinct movies and episodes played, and total plays.
- Watch-time chart, current/longest streak within the chosen period, active days, favourite titles, players and streaming methods.
- Latest 20 plays in the chosen period and personal request totals from Magent's Seerr cache.
- Clear setup, account-link, no-history and temporary-unavailability states.
The page is personal for admins as well as ordinary users. There is no arbitrary user-ID parameter or server-wide history endpoint in this version. Monthly reports are available from My Stats; admin reporting and newsletters can build on this integration later.
## Data semantics and boundaries
History comes from Jellystat's `POST /api/getUserHistory`, with the backend's linked Jellyfin ID in `userid`, and a fixed date filter. `GET /api/getLibraries` supplies movie-library classification and the connection test. Authentication uses the `x-api-token` header. The adapter follows the [upstream API routes](https://github.com/CyferShepard/Jellystat/blob/main/backend/routes/api.js) and [playback model](https://github.com/CyferShepard/Jellystat/blob/main/backend/models/jf_playback_activity.js); the installed instance exposes its API at `/swagger`.
- Playback duration is in seconds and displayed as minutes. Positive-duration history entries count as plays, including unfinished watches. Repeat plays add time without inflating distinct movie/episode counts.
- Episodes are identified by `EpisodeId`. Movies are identified by their movie library. Mixed libraries or deleted library metadata may leave an item classified as other media; that time still contributes to totals.
- Ranges cover a rolling number of days. Charts and streaks use UTC and Jellystat's `ActivityDateInserted`, so the first/last chart days can be partial. A streak day requires at least one minute. Streaks are bounded by the selected period. Long charts group days for readability.
- Requests use their creation date and the authenticated account's canonical Seerr ID. Exact usernames are only used for legacy requests without an owner ID; conflicting IDs never fall back to a name.
- Pages are fetched at 200 rows per request, up to 50 pages, with a 30-second total timeout. Excess history asks the user to choose a shorter period; it is never presented as a complete partial total.
- A normalized, per-identity response is cached in memory for up to 60 seconds, with a 128-entry bound. The cache is separated by Jellystat URL/key, Jellyfin URL, user ID and period. HTTP responses are marked `no-store`.
- Browser output excludes raw Jellystat responses, usernames from playback data, user/device IDs, IP addresses, tokens and media stream details. Unexpected account IDs in upstream history are rejected.
- The only new database table is the stable Magent-to-Jellyfin identity mapping. It is scoped to the configured Jellyfin URL and does not automatically transfer ownership after account replacement. A changed Jellyfin URL needs identity resolution again.
## Validation
Backend coverage is in `backend/tests/test_insights.py` and `backend/tests/test_insights_media.py`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification, artwork authorization, transcoding attribution and empty/error states.
After building the frontend, `scripts/review_insights_ui.cjs` checks the page and configuration using fixture-only requests. Set `REVIEW_BASE`, `REVIEW_PLAYWRIGHT`, and optionally `REVIEW_DIR` to save screenshots outside the repository. Live Jellystat verification requires configuring the actual instance.
## Monthly reports
Open **My Stats → Monthly reports** (`/insights/reports`). The default is the most recent complete calendar month. The month picker covers the current month and the previous 23 months. Reports include viewing and request totals, changes against the preceding month, daily viewing, active days, longest streak, favourite titles, players, transcoding and the latest 20 plays in that month. The chart and streaming cards are shared with the Stats overview.
- Completed months compare full UTC calendar months, even when their lengths differ. The current month compares the same elapsed time in the previous month, capped at that month's end when it is shorter. The page identifies reports that are still in progress.
- Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage.
- Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules.
- `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials.
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata.
- One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result.
`backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only.
## Personal monthly email recaps
New-arrival emails are managed separately in [Grizzlyflix newsletters](newsletters.md). They use Jellyfin library additions and have their own Profile subscription.
**Settings → Monthly email recaps** (`/admin/recaps`) controls the public Magent address, monthly schedule, personal preview, test emails and delivery history. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email.
New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token.
Users choose **Email me my monthly recap** in Profile and confirm ownership of their profile email through a link that expires in 24 hours. The confirmation email contains no viewing data. Opening a confirmation or unsubscribe link only checks it; the user must press the action button. Unsubscribe works without signing in and is also available in Profile. Link tokens travel in URL fragments, then in a redacted JSON `token` field. Confirmation tokens are stored as hashes and consumed on use. Unsubscribe tokens are random, scoped to the current subscription and rotated on a new opt-in.
Subscriptions are bound to the Magent account, confirmed email and stored Jellyfin source/user ID. The background worker does not infer links from emails or playback names. Email changes (even if later changed back), blocked accounts, deleted/replaced identity links and changed Jellyfin sources invalidate consent. Expired and deleted accounts are excluded. The worker checks the current binding again immediately before handing a message to SMTP; unsubscribing cancels queued/preparing messages. Email already handed to the mail server cannot be recalled.
The schedule uses a selected day from 128 and an hour in **UTC**, defaulting to day 2 at 09:00. Starting, resuming or changing a schedule begins at its next future occurrence; it does not immediately email an old report. Each occurrence covers the preceding complete UTC calendar month and includes subscribers confirmed by that scheduled time. After an outage, only the latest due occurrence is caught up. Earlier missed months and late subscribers are not backfilled. Pausing cancels queued scheduled deliveries. `BACKGROUND_TASKS_ENABLED=false` also disables recap automation. The worker checks the durable queue every 30 seconds.
SQLite stores the schedule, consent and delivery metadata in `email_recap_settings`, `email_recap_subscriptions` and `email_recap_deliveries`. Report bodies are generated at delivery and are not stored in the queue. Keep the existing Magent database persistent across deployments and back it up with the application's other data. The migration is additive; no existing user is opted in and no identity is merged.
- A unique account/month key prevents duplicate scheduled recaps across workers, refreshes and restarts. Test requests carry an idempotency key and have a five-minute account cooldown. Confirmation requests also have a five-minute account cooldown.
- Queue claims are transactional. Report preparation has a three-minute timeout. Known temporary SMTP rejections and temporary history failures retry after five minutes, then thirty minutes, with at most three attempts. Permanent failures and history limits stop without sending a partial report.
- The worker records SMTP acceptance separately from connection teardown. A failed QUIT after acceptance does not cause a retry. A disconnect while submitting DATA, or an interrupted worker that had begun sending, is marked **Needs review** and is not automatically resent. Inspect the mail server for the stable `magent-recap-<delivery ID>` Message-ID before deciding whether any follow-up is needed. A stable Message-ID helps investigation; SMTP does not promise deduplication. See [RFC 5321 §4.5.3.2.6](https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.2.6) and the [Python SMTP exception definitions](https://docs.python.org/3/library/smtplib.html).
- Delivery history contains recipient, month, type, attempts, timestamps and a sanitized outcome. It reports mail-server acceptance, not inbox placement or read receipts. No automatic retry button is offered for uncertain deliveries.
The APIs are `/profile/email-recaps`, `/admin/email-recaps`, `/admin/email-recaps/preview`, `/admin/email-recaps/test`, and the public token-only `/email-recaps/check` and `/email-recaps/confirm` actions. Admin APIs enforce the administrator role; personal APIs use the signed-in account. Payloads reject recipient/user overrides.
`backend/tests/test_email_recaps.py` covers consent, identity changes, scheduling boundaries, concurrent claims, duplicate suppression, retries, interruption recovery, SMTP acceptance, access control and escaping. Its SMTP capture listens only on localhost and never delivers external mail. `scripts/review_email_recaps_ui.cjs` intercepts every API request and checks desktop/mobile layouts, preview isolation, preferences, scheduling, delivery history and public links. Set `REVIEW_EMAIL_FIXTURE` to a JSON file returned by `recap_email.render_recap`, with `month` and `email` added, plus the usual `REVIEW_BASE`, `REVIEW_PLAYWRIGHT` and optional `REVIEW_DIR`.
## Artwork and transcoding
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
How you streamed includes audio transcoding minutes and hardware-assisted video transcoding minutes. These are playback durations attributed to the recorded transcode flags, not GPU busy time or encoder runtime. Audio and video durations can overlap. Video must actually be transcoded for hardware-assisted minutes to count; a hardware label on an audio-only conversion does not count as GPU video work. Direct-play records ignore residual transcoding metadata, and missing details remain unknown.
Jellyfin exposes separate [video/audio passthrough flags and hardware type](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Session/TranscodingInfo.cs), with [hardware type names](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Entities/HardwareAccelerationType.cs). The existing Jellystat history does not contain GPU utilization or GPU busy-time samples, so Magent does not calculate those figures.
+34
View File
@@ -0,0 +1,34 @@
# Grizzlyflix newsletters
Open **Config → Newsletters** (`/admin/newsletters`) to create an edition from the last 7, 14 or 30 days of Jellyfin additions. Select up to 24 titles, feature up to three picks, edit the subject and add a plain-text announcement. Episodes are grouped by show. The editor offers the newest 60 titles in the chosen period and displays the total found.
Save and preview the edition, then send a test to your own confirmed newsletter email. Choose **Send now** or an explicit UTC date and time within the next 90 days. Scheduled editions retain their saved content. To change a scheduled edition, cancel it and create another draft. Cancellation stops pending delivery; messages already accepted by SMTP cannot be recalled.
The weekly schedule starts paused, defaults to Friday at 09:00 UTC and selects the 12 newest titles from the previous seven days. The default announcement applies to future weekly editions and new drafts. Starting or changing settings moves the schedule to its next future occurrence. Empty weeks are skipped. After downtime, only the latest due week is generated; Magent does not backfill every missed week. Pausing cancels pending automatic editions; custom schedules continue independently.
## Setup and subscriptions
- Configure Jellyfin and its **public** address for Watch links, plus the existing SMTP email settings. Background automation must be enabled. Jellystat is not required for newsletters.
- Save this environment's public Magent address in Weekly schedule. On first migration it inherits the monthly recap address, if configured. No schedule or subscriptions are enabled by migration.
- Users opt in at **Profile → New on Grizzlyflix**. This consent is separate from monthly viewing recaps. A current email already confirmed for monthly recaps can be reused after the user explicitly subscribes to newsletters. Otherwise a confirmation email is sent, with a 24-hour expiry and a five-minute resend limit.
- Each subscriber needs a stored Jellyfin account link. Email or identity changes, blocking and account removal invalidate consent. Confirmation and unsubscribe tokens are specific to newsletters. Opening a public link checks it; changing the preference requires pressing its confirmation button.
## Content and artwork
Arrivals use Jellyfin's `DateCreated`, not premiere dates. How Jellyfin assigns this timestamp depends on the server's library configuration and imported metadata. Magent scans descending pages, validates the date range and rejects incomplete or changing results. A 5,000-item bound prevents an unbounded library scan; shorten the period if the editor reports this limit.
Immediately before preparation, Magent checks the stored Jellyfin server and user identities. Each item lookup supplies both the recipient's `UserId` and a `ParentId` from that user's permitted views. This matters because Jellyfin 10.11 skips its default library filter when `Ids` is supplied. Restricted or removed titles are excluded and episode counts reflect only permitted episodes. Administrators preview the full selection; their test email uses their own library access. Announcements are shared text and should be written for the whole audience.
This behaviour was checked against Jellyfin 10.11.11's [item queries](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Jellyfin.Api/Controllers/ItemsController.cs), [library query scoping](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Emby.Server.Implementations/Library/LibraryManager.cs) and [user views](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Jellyfin.Api/Controllers/UserViewsController.cs).
Posters are fetched on the server, decoded and resized to bounded JPEGs. Emails embed them as CID attachments; recipients do not need a Magent session to load them. Admin previews embed data images. Missing artwork uses a placeholder. API keys never appear in email or artwork URLs. Watch links open the movie or show in the configured public Jellyfin web client and require its normal sign-in.
## Delivery behaviour
Subscriptions, editions, immutable versions and delivery history use independent `newsletter_*` SQLite tables. Both newsletter and monthly recap queues share atomic claim/lease handling and the existing SMTP transport.
An edition queues once per eligible subscriber, with durable deduplication. Subscribers must have confirmed before the edition's send time. Tests require a request UUID, retain the requested saved version and have a five-minute cooldown. Account, subscription and cancellation checks run again immediately before SMTP DATA.
Temporary preparation or SMTP failures retry after five and thirty minutes, up to three attempts. If acceptance becomes uncertain after DATA begins, history shows **Needs review** and automatic retries stop. **Accepted by mail server** records SMTP acceptance, not inbox placement. An edition marked **Finished** has no pending deliveries; check individual history rows for sent, skipped or failed outcomes.
No test recipient overrides or bulk subscription actions are exposed. UI review scripts intercept all API requests. Backend tests use disposable SQLite databases and captured mail, never the live SMTP service.
+15
View File
@@ -0,0 +1,15 @@
# Original-language requests
New Requests displays a language notice when Seerr reports a known, non-English original language. This is title metadata, not proof that a particular release lacks an English audio track or includes subtitles. Unknown and English original languages do not produce the notice.
Users can leave the normal request settings or explicitly accept original-language audio. The choice resets when changing titles and is verified against fresh Seerr metadata during submission; browser-supplied profile IDs remain ignored.
For movies, consent creates or reuses a `Magent Original …` Radarr quality profile. It copies the current default's quality ordering, allowed qualities, cutoff, upgrade rules and custom-format scores, changing only the language to Original. The existing default is never edited. The copy is selected for this new Seerr request only. Magent's subsequent Search and auto-download action preserves a verified copy instead of resetting it to English. Copies are content-addressed so later default changes do not silently change earlier requests.
TV requests show the same notice and retain their configured Sonarr profile. The inspected Sonarr configuration has no language custom formats. This feature does not bypass custom-format rejection, indexer restrictions, availability or permissions; it does not guarantee that a download is available. Existing requests are not silently modified by selecting an already-requested search result.
The first opted-in movie request creates a profile in Radarr. Failed request submission can leave an unused copy, which is reused on retry. Do not rename/edit managed copies if they should retain Magent's recognition during subsequent searches.
Radarr's API represents Original as language ID -2: [language source](https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Languages/Language.cs). Profile fields are defined in its [quality profile resource](https://github.com/Radarr/Radarr/blob/develop/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs).
Validation: backend consent/profile isolation tests and `scripts/review_request_language_ui.cjs` with intercepted APIs; no live requests or downloads are created by these tests.
+27
View File
@@ -0,0 +1,27 @@
# User feature access
In **Configuration → User management → Manage users**, the Feature access checkboxes apply to all existing non-admin accounts. A mixed checkbox means some accounts have access. Only changed checkboxes are saved; search filters do not restrict the bulk operation. New accounts retain the default access described below.
Open a user and choose **Manage this user** to change individual permissions, contact email, role, automatic search/download, profile defaults or expiry. Request statistics remain on the main profile page. Administrators always have all features.
| Feature | Access controlled |
| --- | --- |
| My Stats | Viewing statistics, report exports, report email preferences and delivery |
| My Requests | Existing requests, progress, request actions and live request streams |
| New Requests | Media request options and submission |
| Issues | Issue lists, reporting, comments, resolution responses and issue repair actions |
| Invites | Creating, viewing and managing personal invitations; existing limits still apply |
Media search is shared by New Requests and the issue picker. Either permission allows search; only New Requests permits submission. The existing automatic search/download permission still applies in addition to feature access.
Navigation and direct-page access use the authenticated account's permissions. APIs enforce them independently on each request. Open request streams recheck access, and report emails recheck Stats access before sending. Removing a permission does not erase existing records or unsend emails. The switches apply inside Magent and do not change Jellyfin or Seerr permissions.
Existing users retain Stats, My Requests, New Requests and Issues access when upgrading. Invite access uses the existing `users.invite_management_enabled` column. Other overrides are stored by Magent user ID in `user_feature_permissions`; deletion of the account removes its overrides. The old site-wide navigation visibility setting is no longer used by the menus.
The red account section distinguishes:
- **Block Magent access:** prevent Magent sign-in and keep the account.
- **Disable Magent and Jellyfin access:** block Magent, attempt to disable the same-name Jellyfin account, disable issued invitations and attempt a notification email. Seerr relies on Jellyfin sign-in; its account is not directly banned. Restoring access does not reactivate invitations.
- **Delete Magent, Jellyfin and Seerr accounts:** remove Magent and local activity, attempt deletion of the same-name Jellyfin account and linked Seerr account, disable invitations and attempt notification. Media files and Jellystat history are retained. External actions can partially fail.
Validation: backend permission tests use temporary databases and real signed tokens. `scripts/review_feature_access_ui.cjs` checks desktop/mobile profiles, dialogs, bulk scope and denied routes with intercepted API fixtures. Set `PLAYWRIGHT_PACKAGE` when Playwright is installed outside the project, and optionally `REVIEW_BASE` to target a deployed frontend.
+59
View File
@@ -0,0 +1,59 @@
# Confirming user identities
Open **Users → Confirm user IDs**, or **Settings → User identities**. Administrator access is required.
1. Choose **Check all user IDs** to read the live Jellyfin and Seerr directories and check Jellystat user metadata.
2. Search by account name or ID, or filter by status. The results include raw Magent rows that the ordinary user directory may hide as duplicates.
3. Select accounts marked **Ready to review**. Check the Magent account, Jellyfin ID and Seerr ID in **Review selected links**.
4. Choose **Confirm and save links**. Magent checks the live mappings again before saving. If accounts, service settings or mappings changed, run a fresh check.
The canonical external identity is the Jellyfin server ID plus Jellyfin user ID. Seerr is matched through its explicit `jellyfinUserId`; Jellystat must return the same user ID. Existing Magent Jellyfin or Seerr links take precedence. For existing Jellyfin sign-in accounts without a stored ID, a unique normalized Jellyfin username provides a **suggestion requiring administrator review**. Emails and email prefixes never establish an identity.
Confirmation saves the Jellyfin link, Seerr user ID, Jellyfin server ID, timestamp and confirming administrator. Normal name-based sync cannot replace confirmed links. My Stats uses the saved Jellyfin ID for playback and the Seerr ID for requests. Changes to the authentication token format are outside this feature.
Conflicts, duplicate accounts, ambiguous case/whitespace names, absent IDs and unavailable services cannot be confirmed. This workflow does not merge, delete or create user accounts in any platform. It does not rewrite playback or requests. Conflicting mappings need investigation before reconciliation.
Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links. They do not enumerate historical Jellystat-only users or playback records. Each run supports up to 3,000 identities, fetches complete Seerr pages, limits concurrent Jellystat requests to six, and stops checking Jellystat after 25 seconds. Unfinished checks remain unavailable, never verified. Results are not HTTP-cached and contain no credentials or raw playback history.
All selected accounts are saved in one transaction. The server derives the destination IDs from a fresh check and verifies the database snapshot before writing; the browser only supplies the reviewed revision and selected Magent row IDs.
### Resolve a missing link
Open **Users > Manage users > Review account links**, then **Check all user IDs**.
For an account marked **Missing link**, choose **Resolve missing link**. Select the
correct Jellyfin account by name and ID, then **Check selected account**. The
preview checks Seerr's explicit Jellyfin ID, Jellystat's matching ID, and every
Magent account (including hidden duplicates) for ownership conflicts.
Review the IDs and choose **Confirm and save link**. Magent rechecks live services
and the local directory before atomically saving both links and the administrator
audit record. A changed preview must be checked again. Existing confirmed or
conflicting stored identities cannot be replaced using this flow. Missing upstream
records must be corrected in their service before confirmation is available.
No accounts are created, merged or deleted; emails are not used to infer identity.
### User Management and repairs
Identity checks now live at **Config > User management > Account links & repairs**.
The old `/admin/identities` link redirects there. Choose **Review repair** on a
missing or conflicting account, select the authoritative Jellyfin identity, and
preview the current and proposed Magent links. Saving rechecks live service IDs,
all local owners, the server identity and concurrent changes. Repairs retain an
atomic before/after audit in `user_identity_repairs`. Changing a Jellyfin identity
revokes identity-bound email subscriptions; users must opt in again.
If a person has never had a Seerr account, explicitly choose the single-account
import option and preview again. Confirmation imports only that Jellyfin ID via
Seerr's supported API and rechecks its resulting Seerr ID before saving Magent.
Seerr and Magent cannot share a transaction: if an import succeeds but the local
save fails, the imported account is retained and the administrator must recheck.
No automatic deletion or rollback of upstream accounts is attempted.
For an existing Seerr account with a different Jellyfin ID, inspect its ID in the
preview and reconnect that existing account in Seerr using the account owner's
Jellyfin sign-in. Magent cannot rewrite Seerr's Jellyfin ID through the normal
admin user-update endpoint. Do not import another account to bypass a mismatch.
Duplicate Magent owners remain blocked until the ownership conflict is resolved;
this workflow does not merge users, permissions, requests or playback history.
+21 -10
View File
@@ -34,6 +34,7 @@ const SECTION_LABELS: Record<string, string> = {
seerr: 'Seerr',
jellyseerr: 'Seerr',
jellyfin: 'Jellyfin',
jellystat: 'Jellystat',
artwork: 'Artwork cache',
cache: 'Request cache',
sonarr: 'Sonarr',
@@ -55,7 +56,6 @@ const BOOL_SETTINGS = new Set([
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
'site_nav_show_requests',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
@@ -98,6 +98,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.',
jellystat: 'Connect Jellystat so users can see their personal viewing stats in Magent.',
artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
@@ -119,6 +120,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
seerr: 'jellyseerr',
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
jellystat: 'jellystat',
artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
@@ -277,12 +279,6 @@ const SITE_SECTION_GROUPS: Array<{
'site_login_show_signup_link',
],
},
{
key: 'site-navigation',
title: 'Navigation',
description: 'Control new requests in the navigation.',
keys: ['site_nav_show_requests'],
},
]
const STANDARD_SECTION_GROUPS: Record<
@@ -305,6 +301,14 @@ const STANDARD_SECTION_GROUPS: Record<
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
},
],
jellystat: [
{
key: 'jellystat-connection',
title: 'Connection',
description: 'Use the Jellystat instance connected to the same Jellyfin server as Magent. Create a Jellystat API key in its settings, then save and test the connection.',
keys: ['jellystat_base_url', 'jellystat_api_key'],
},
],
jellyfin: [
{
key: 'jellyfin-connection',
@@ -483,6 +487,8 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_webhook_url: 'Generic webhook URL',
jellyfin_base_url: 'Internal server URL',
jellyfin_api_key: 'Administrator API key',
jellystat_base_url: 'Internal server URL',
jellystat_api_key: 'Jellystat API key',
jellyfin_public_url: 'Public playback URL',
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
sonarr_base_url: 'Sonarr server URL',
@@ -578,6 +584,7 @@ type SectionFeedback = {
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
'seerr-connection': 'seerr',
'jellyfin-connection': 'jellyfin',
'jellystat-connection': 'jellystat',
'sonarr-connection': 'sonarr',
'radarr-connection': 'radarr',
'bazarr-connection': 'bazarr',
@@ -807,6 +814,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyfin: ['Jellyfin'],
jellystat: ['Jellystat'],
sonarr: ['Sonarr'],
radarr: ['Radarr'],
bazarr: ['Bazarr'],
@@ -824,7 +832,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode'])
const generatedSettingKeys = new Set(['site_changelog', 'site_build_number'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const hiddenSettingKeys = new Set(['site_nav_show_requests', ...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const obsoleteSettingKeys = new Set([
'sonarr_qbittorrent_category',
'radarr_qbittorrent_category',
@@ -995,6 +1003,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellystat_base_url: 'Jellystat address reachable by Magent, including any base path. Example: http://jellystat:3000.',
jellystat_api_key: 'API key created in Jellystat. Stored privately by Magent and never sent to users browsers.',
jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
@@ -1004,11 +1014,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
bazarr_base_url: 'Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.',
bazarr_api_key: 'API key used to ask Bazarr for fresh subtitles.',
bazarr_default_language: 'Language code Bazarr should search for by default, such as en.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_quality_profile_id: 'Applied automatically to every new TV request. Users do not choose a quality profile in the request pipeline. If no Magent default is configured, requests use Seerrs default.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_quality_profile_id: 'Applied automatically to every new movie request. Users do not choose a quality profile in the request pipeline. If no Magent default is configured, requests use Seerrs default.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
@@ -1076,6 +1086,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellystat_base_url: 'http://jellystat:3000',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
+1
View File
@@ -5,6 +5,7 @@ const ALLOWED_SECTIONS = new Set([
'seerr',
'jellyseerr',
'jellyfin',
'jellystat',
'artwork',
'sonarr',
'radarr',
+9 -6
View File
@@ -5,6 +5,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
{ href: '/admin/jellystat', label: 'Jellystat', description: 'Personal viewing statistics', symbol: 'JS', service: 'Jellystat' },
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
@@ -12,12 +13,14 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
{ href: '/admin/qbittorrent', label: 'qBittorrent', description: 'Download progress and recovery', symbol: 'QB', service: 'qBittorrent' },
]},
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options', symbol: '01' },
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates', symbol: '02' },
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure', symbol: '03' },
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention', symbol: '04' },
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions', symbol: '05' },
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites', symbol: '06' },
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options' },
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates' },
{ href: '/admin/recaps', label: 'Monthly email recaps', description: 'Personal viewing emails, schedule and delivery history' },
{ href: '/admin/newsletters', label: 'Newsletters', description: 'New arrivals, featured picks and weekly editions' },
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' },
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
{ href: '/users', label: 'User management', description: 'Accounts, permissions, identity checks and repairs' },
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites' },
]},
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
{ href: '/admin/general', label: 'Hosting & proxy', description: 'Public addresses and deployment options' },
@@ -0,0 +1,74 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../../lib/auth'
import { FEATURES, type FeatureAccess } from '../../lib/features'
import type { Row } from './IdentityReviewPanel'
type Account = { id: number; username: string; email: string | null; profile_id: number | null; last_login_at: string | null }
type Preview = {
accounts: Account[]; keep_id: number; recommended_id: number; revision: string; can_confirm: boolean; issues: string[]
proposed: Account & { jellyfin_user_id: string; seerr_user_id: number; features: FeatureAccess; expires_at: string | null; is_blocked: boolean; auto_search_enabled: boolean }
}
export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: Row; onClose: () => void; onSaved: () => void }) {
const dialog = useRef<HTMLDialogElement>(null)
const controller = useRef<AbortController | null>(null)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [acknowledged, setAcknowledged] = useState(false)
const [error, setError] = useState('')
const submit = async (confirm = false, keepId?: number) => {
const abort = new AbortController()
controller.current?.abort(); controller.current = abort
setError(''); setAcknowledged(false)
if (confirm) setSaving(true)
else setBusy(true)
try {
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? 'confirm' : 'check'}`, {
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: row.user.id, ...(keepId ? { keep_id: keepId } : {}), ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}) }),
})
const data = await response.json()
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not review these accounts.')
if (!abort.signal.aborted) { if (confirm) onSaved(); else setPreview(data) }
} catch (err) { if (!abort.signal.aborted) { setError(err instanceof Error ? err.message : 'Repair failed. Preview again.'); setPreview(null) } }
finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
}
useEffect(() => {
const previous = document.activeElement as HTMLElement | null
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'; dialog.current?.showModal()
void submit()
return () => { controller.current?.abort(); document.body.style.overflow = overflow; previous?.focus() }
}, [])
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="duplicates-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
<div className="identity-resolve-content">
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header>
<p>Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to the verified Jellyfin identity.</p>
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
{error && <p className="error-banner" role="alert">{error}</p>}
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>}
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview">
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}>
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)}
</select></label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping identity-duplicate-accounts">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
<h3>Resulting account</h3>
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p>
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p>
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p>
<p>Access: {preview.proposed.is_blocked ? 'Blocked' : 'Not blocked'} · Expiry: {preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : 'None'} · Automatic search: {preview.proposed.auto_search_enabled ? 'Enabled' : 'Disabled'}</p>
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul>
<p>Request, issue, invitation and login activity history is retained. The selected account keeps its email and profile. Any block, earlier expiry or disabled permission on either row is preserved.</p>
<p>Extra Magent rows are removed from the active directory after their details are archived. Their outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains its own subscriptions where still eligible. Password reset links must be requested again.</p>
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p>
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
<label className="identity-import-option"><span><input type="checkbox" checked={acknowledged} disabled={busy || saving || !preview.can_confirm} onChange={(event) => setAcknowledged(event.target.checked)} /> I confirm these rows belong to the same person and have reviewed the account to keep.</span></label>
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button>
</section>}
</div>
</dialog>
}
@@ -0,0 +1,171 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../../lib/auth'
import './identities.css'
import DuplicateAccountRepair from './DuplicateAccountRepair'
import ResolveIdentityLink from './ResolveIdentityLink'
type Identity = { id: string; name: string }
export type Row = {
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null }
jellyfin: Identity | null
candidate_jellyfin_id: string | null
stored_jellyfin_id: string | null
seerr: { id: number; name: string; jellyfin_id: string }[]
jellystat: { state: string; id?: string; name?: string }
basis: string
issues: string[]
state: string
can_confirm: boolean
confirmed_at: string | null
}
type Report = {
revision: string; checked_at: string; server_id: string | null
services: Record<string, string>
counts: Record<string, number>
jellyfin_users: Identity[]
rows: Row[]
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
}
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' }
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' }
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerrs Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' }
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' }
export default function IdentityReviewPanel() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [report, setReport] = useState<Report | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
const [selected, setSelected] = useState<number[]>([])
const [duplicates, setDuplicates] = useState<Row | null>(null)
const [resolving, setResolving] = useState<Row | null>(null)
const [reviewing, setReviewing] = useState(false)
const controller = useRef<AbortController | null>(null)
const reviewPanel = useRef<HTMLElement | null>(null)
useEffect(() => {
setQuery(new URLSearchParams(window.location.search).get('user') ?? '')
const abort = new AbortController()
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
if (response.status === 401) { router.replace('/login'); return }
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.')
if ((await response.json()).role !== 'admin') { router.replace('/'); return }
if (!abort.signal.aborted) setReady(true)
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => { abort.abort(); controller.current?.abort() }
}, [router])
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing])
const responseData = async (response: Response) => {
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') }
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.')
return data
}
const runCheck = async () => {
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null)
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }))
if (!abort.signal.aborted) setReport(data)
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.')
} finally { if (!abort.signal.aborted) setBusy(false) }
}
const save = async () => {
if (!report || saving || !selected.length) return
setSaving(true); setError(''); setNotice('')
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
}))
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`)
// The scan describes the previous database state and cannot be reused for another write.
setReport(null); setSelected([]); setReviewing(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not save identity links.')
setReport(null); setSelected([]); setReviewing(false)
} finally { setSaving(false) }
}
const needle = query.trim().toLowerCase()
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) &&
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? []
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? []
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id)
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) }
return (
<div className="identity-review">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{!ready && !error && <p role="status">Checking administrator access</p>}
{ready && <>
<section className="identity-intro admin-panel">
<div><h2>Confirm user IDs</h2><p>Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs. Duplicate ownership and upstream changes require individual review.</p></div>
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button>
</section>
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>}
{report && <>
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div>
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p>
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div>
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p>
<div className="identity-filters">
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label>
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label>
</div>
<div className="identity-selection">
<span>{filtered.length} accounts shown · {selected.length} selected</span>
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
</div>
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
<h2>Save these {selected.length} account links?</h2>
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin <code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}</li>)}</ul>
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
</section>}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
<dl className="identity-mapping">
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
</dl>
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
</article>)}
</section>
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
</>}
</>}
{duplicates && <DuplicateAccountRepair row={duplicates} onClose={() => setDuplicates(null)} onSaved={() => { setDuplicates(null); void runCheck().then(() => setNotice('Duplicate accounts repaired. History retained and links rechecked.')) }} />}
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
setNotice('Account links repaired and saved. Run another check to see the updated mappings.')
}} />}
</div>
)
}
@@ -0,0 +1,106 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../../lib/auth'
import type { Row } from './IdentityReviewPanel'
type Preview = {
revision: string; server_id: string; row: Row
before: { jellyfin_user_id: string | null; seerr_user_id: number | null }
seerr_users: { id: number; name: string; jellyfin_id: string | null }[]
scope: string
action: string
}
export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: {
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void
}) {
const dialog = useRef<HTMLDialogElement>(null)
const controller = useRef<AbortController | null>(null)
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? '')
const [inspectSeerr, setInspectSeerr] = useState('')
const [createSeerr, setCreateSeerr] = useState(false)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
const previous = document.activeElement as HTMLElement | null
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialog.current?.showModal()
return () => {
controller.current?.abort()
document.body.style.overflow = overflow
previous?.focus()
}
}, [])
const submit = async (confirm: boolean) => {
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return
const abort = new AbortController()
controller.current = abort
setError('')
if (confirm) setSaving(true)
else { setBusy(true); setPreview(null) }
try {
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? 'confirm' : 'check'}`, {
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, create_seerr: createSeerr, ...(confirm ? { revision: preview?.revision } : {}) }),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.')
if (!abort.signal.aborted) {
if (confirm) onSaved()
else setPreview(data)
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : 'Could not resolve the link.')
setPreview(null)
}
} finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
}
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
<div className="identity-resolve-content">
<header><h2 id="resolve-title">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
<p>Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.</p>
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); setChosen(event.target.value)
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} {account.id}</option>)}</select></label>
<label className="identity-import-option"><span><input type="checkbox" checked={createSeerr} disabled={busy || saving} onChange={(event) => { setCreateSeerr(event.target.checked); setPreview(null) }} /> This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.</span></label>
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Preview repair'}</button>
{error && <p className="error-banner" role="alert">{error}</p>}
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
<h3>{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}</h3>
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
<div className="identity-mapping">
<div><strong>Current Magent links</strong><p>Jellyfin: <code>{preview.before.jellyfin_user_id ?? 'Not linked'}</code></p><p>Seerr: {preview.before.seerr_user_id ?? 'Not linked'}</p></div>
<div><strong>Proposed Magent links</strong><p>Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code></p><p>Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}</p></div>
</div>
<dl className="identity-mapping">
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div>
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this users Jellyfin account link in Seerr, then check again.'}</dd></div>
<div><dt>Jellystat</dt><dd><code>{preview.row.jellystat.id ?? 'Not verified'}</code>{preview.row.jellystat.state === 'matched' ? 'Same Jellyfin ID verified' : preview.row.jellystat.state === 'missing' ? 'This ID is missing from Jellystat. Check its Jellyfin sync, then check again.' : 'Could not verify this ID. Check the Jellystat connection and try again.'}</dd></div>
</dl>
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>}
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance">
<h3>Check the existing Seerr account</h3>
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
<label>Seerr account to inspect<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}><option value="">Choose an existing account</option>{preview.seerr_users.map((account) => <option key={account.id} value={account.id}>{account.name} (ID {account.id})</option>)}</select></label>
{preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) => <p key={account.id}>Current Jellyfin ID: <code>{account.jellyfin_id ?? 'Not linked'}</code></p>)}
<p>If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.</p>
<p>If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.</p>
</div>}
<p>{preview.scope}</p>
<p>Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.</p>
{preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && <p>Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.</p>}
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : preview.action === 'import_seerr' ? 'Import Seerr account and repair links' : 'Confirm repair'}</button>
</section>}
</div>
</dialog>
}
@@ -0,0 +1,80 @@
.identity-review { display: grid; gap: 20px; min-width: 0; }
.identity-resolution-entry { display: grid; justify-items: start; gap: 12px; margin-top: 16px; }
.identity-resolve-dialog { position: fixed; inset: 0; margin: auto; overflow: auto; overscroll-behavior: contain; width: min(900px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; color: var(--ops-text); background: var(--ops-panel, #1b1b1d); border: 1px solid var(--ops-line); border-radius: 16px; }
.identity-resolve-dialog::backdrop { background: #000b; backdrop-filter: blur(4px); }
.identity-resolve-content { display: grid; gap: 20px; padding: 24px; min-width: 0; }
.identity-resolve-content > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.identity-resolve-content h2, .identity-resolve-content h3 { margin: 0; }
.identity-resolve-content h2 { font-size: 1.2rem; }
.identity-resolve-content label { display: grid; gap: 8px; min-width: 0; }
.identity-resolve-content select { width: 100%; min-width: 0; }
.identity-resolve-content p { overflow-wrap: anywhere; }
@media (max-width: 540px) { .identity-resolve-content { padding: 16px; } }
.identity-review p { margin: 0; line-height: 1.65; }
.identity-review code { overflow-wrap: anywhere; font-size: .8rem; }
.identity-intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px; }
.identity-intro h2 { margin: 0 0 8px; font-size: 1.1rem; }
.identity-intro p { max-width: 760px; color: var(--ops-muted); }
.identity-intro button { flex-shrink: 0; }
.identity-service-strip { display: flex; flex-wrap: wrap; gap: 14px 24px; }
.identity-service-strip span { font-size: .88rem; color: var(--ops-muted); }
.identity-service-strip strong { color: var(--ops-text); margin-right: 6px; }
.identity-meta { color: var(--ops-muted); font-size: .82rem; }
.identity-counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
.identity-counts > div { border: 1px solid var(--ops-line); border-radius: 12px; padding: 16px; display: grid; gap: 4px; }
.identity-counts strong { font-size: 1.8rem; }
.identity-counts span { color: var(--ops-muted); font-size: .78rem; }
.identity-filters { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 16px; }
.identity-filters label { display: grid; gap: 8px; font-size: .85rem; }
.identity-filters input, .identity-filters select { width: 100%; min-width: 0; }
.identity-selection, .identity-confirm-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.identity-selection > span { color: var(--ops-muted); font-size: .85rem; margin-right: auto; }
.identity-accounts { display: grid; gap: 16px; }
.identity-account { border: 1px solid var(--ops-line); border-radius: 14px; padding: 22px; min-width: 0; }
.identity-account > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.identity-account-name { display: flex; gap: 12px; align-items: center; min-width: 0; }
.identity-account-name h2 { font-size: 1.05rem; margin: 0 0 3px; overflow-wrap: anywhere; white-space: pre-wrap; }
.identity-account-name span { font-size: .8rem; color: var(--ops-muted); }
.identity-review input[type=checkbox] { width: 20px; height: 20px; flex-shrink: 0; accent-color: var(--ops-primary-2); }
.identity-badge { border-radius: 100px; padding: 5px 10px; font-size: .73rem; background: #263242; color: #d9e2ef; white-space: nowrap; }
.identity-badge.is-ready { background: #18374c; color: #a3dbff; }
.identity-badge.is-confirmed { background: #17382d; color: #9ce3bd; }
.identity-badge.is-conflict { background: #492d28; color: #ffc1ac; }
.identity-badge.is-unavailable, .identity-badge.is-unlinked { background: #40391f; color: #ead696; }
.identity-mapping { display: grid; grid-template-columns: 1.3fr .8fr 1.3fr; gap: 22px; margin: 22px 0 0; }
.identity-mapping > div { min-width: 0; }
.identity-mapping dt { color: var(--ops-muted); font-size: .75rem; margin-bottom: 8px; }
.identity-mapping dd { display: grid; gap: 5px; margin: 0; overflow-wrap: anywhere; }
.identity-mapping dd small { color: var(--ops-muted); line-height: 1.5; }
.identity-issues { margin: 20px 0 0; padding: 14px 14px 14px 30px; color: #ffc1ac; background: #492d2833; border-radius: 8px; font-size: .83rem; line-height: 1.7; }
.identity-account > .identity-meta { margin-top: 16px; }
.identity-confirm-panel { border: 1px solid var(--ops-primary-2); border-radius: 12px; padding: 24px; display: grid; gap: 16px; }
.identity-confirm-panel h2 { margin: 0; font-size: 1.15rem; }
.identity-confirm-panel ul { margin: 0; padding-left: 20px; max-height: 260px; overflow: auto; }
.identity-confirm-panel li { line-height: 1.9; overflow-wrap: anywhere; }
.identity-upstream { border-top: 1px solid var(--ops-line); padding-top: 20px; }
.identity-upstream summary { cursor: pointer; }
.identity-upstream ul { padding-left: 20px; }
.identity-upstream li { margin: 16px 0; overflow-wrap: anywhere; }
@media (max-width: 980px) {
.identity-intro { align-items: flex-start; flex-direction: column; }
.identity-counts { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.identity-mapping { grid-template-columns: 1fr; gap: 16px; }
}
@media (max-width: 540px) {
.identity-filters { grid-template-columns: 1fr; }
.identity-account { padding: 16px; }
.identity-account > header { flex-direction: column; }
.identity-intro, .identity-confirm-panel { padding: 16px; }
.identity-counts { gap: 8px; }
.identity-counts > div { padding: 10px; }
.identity-counts strong { font-size: 1.4rem; }
.identity-selection button { width: 100%; }
}
.identity-upstream-guidance { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ops-line); }
.identity-import-option > span { display: flex; align-items: flex-start; gap: 10px; line-height: 1.6; }
.identity-import-option input[type=checkbox] { flex: 0 0 20px; margin: 3px 0 0; }
.identity-duplicate-accounts { grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function IdentityReviewPage() {
redirect('/users?view=identities')
}
+1 -1
View File
@@ -1704,7 +1704,7 @@ export default function AdminInviteManagementPage() {
</div>
{inviteEditingId == null ? (
<div className="invite-created-card">
<span className="eyebrow">Option 04 · Invites</span>
<span className="eyebrow">Invites</span>
<h3>Create invites from the client workspace</h3>
<p>The guided invite flow now lives in the main navigation where users can create and manage their own invitations.</p>
<button type="button" onClick={() => router.push('/profile/invites')}>Open client invite workspace</button>
@@ -0,0 +1,34 @@
.newsletter-admin { min-width: 0; }
.newsletter-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.newsletter-tabs button { background: transparent; border: 1px solid var(--ops-line); color: var(--ops-muted); padding: 12px 16px; font-size: 13px; text-transform: none; }
.newsletter-tabs button[aria-pressed=true] { background: #c7bdff14; border-color: #c7bdff60; color: #d5cdff; }
.newsletter-create { display: flex; align-items: flex-end; gap: 12px; flex-shrink: 0; }
.newsletter-create .recap-month-label { margin: 0; }
.newsletter-editions { display: grid; gap: 10px; margin-top: 24px; max-height: 430px; overflow-y: auto; }
.newsletter-edition { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; text-align: left; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: transparent; color: var(--ops-text); text-transform: none; }
.newsletter-edition > span:first-child { min-width: 0; }
.newsletter-edition.is-active { border-color: #c7bdff70; background: #c7bdff09; }
.newsletter-edition strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
.newsletter-edition small { display: block; font-size: 11px; line-height: 1.8; color: var(--ops-muted); margin-top: 7px; }
.newsletter-editor .recap-schedule-form { margin-bottom: 22px; }
.newsletter-admin textarea { border: 1px solid var(--ops-line); border-radius: 8px; padding: 12px; width: 100%; min-width: 0; resize: vertical; font: 13px/1.7 Inter, sans-serif; color: var(--ops-text); }
.newsletter-optional { font-size: 11px; color: var(--ops-faint); }
.newsletter-selection-heading { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.newsletter-selection-heading h3 { margin: 0; }
.newsletter-selection-heading span { color: #bcb3eb; font-size: 12px; }
.newsletter-titles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; max-height: 640px; overflow-y: auto; padding: 1px; }
.newsletter-title { display: flex; gap: 14px; padding: 14px; border: 1px solid var(--ops-line); border-radius: 10px; min-width: 0; background: #ffffff02; }
.newsletter-title.is-selected { border-color: #c7bdff60; background: #c7bdff08; }
.newsletter-poster { position: relative; flex: 0 0 68px; width: 68px; height: 102px; display: grid; place-items: center; overflow: hidden; border-radius: 6px; background: #353039; color: #c7bdff; font-size: 10px; }
.newsletter-poster img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.newsletter-title-copy { min-width: 0; }
.newsletter-title h4 { margin: 0; font-size: 13px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
.newsletter-title p { margin: 6px 0; font-size: 11px; }
.newsletter-title .recap-checkbox { padding: 5px 0; gap: 8px; font-size: 11px; }
.newsletter-title .recap-checkbox input { flex: 0 0 16px; width: 16px; height: 16px; }
.newsletter-editor .recap-preview, .newsletter-send { border-top: 1px solid var(--ops-line); padding-top: 24px; margin-top: 24px; }
.newsletter-send > .recap-month-label { max-width: 350px; }
.newsletter-send input { min-width: 0; width: 100%; min-height: 44px; padding: 10px; border: 1px solid var(--ops-line); border-radius: 8px; font: 13px Inter, sans-serif; }
.newsletter-cancel { margin-top: 24px; color: var(--ops-muted); }
@media (max-width: 1200px) { .newsletter-titles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .newsletter-create { flex-direction: column; align-items: stretch; } }
@media (max-width: 700px) { .newsletter-titles { grid-template-columns: 1fr; } .newsletter-create { width: 100%; } .newsletter-edition { align-items: flex-start; flex-direction: column; gap: 10px; } .newsletter-tabs button { padding: 10px 12px; font-size: 12px; } }
+146
View File
@@ -0,0 +1,146 @@
'use client'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, getApiBase } from '../../lib/auth'
import '../../email-recaps/recaps.css'
import './newsletters.css'
type Settings = { enabled: boolean; weekday: number; hour: number; limit_titles: number; public_url: string; intro: string; revision: number; next_send_at?: number | null; last_error?: string }
type Title = { id: string; title: string; type: 'movie' | 'series'; year: number | null; has_artwork: boolean; items: { id: string; season: number | null; number: number | null }[]; selected: boolean; featured: boolean }
type Edition = { id: string; subject: string; intro: string; revision: number; state: string; origin: string; send_at: number | null; created_at: number; content: { titles: Title[]; total_titles: number; period_start: string; period_end: string } }
type Summary = Omit<Edition, 'content'> & { titles: number; period_start: string; period_end: string }
type Delivery = { id: string; subject: string; kind: string; email: string; username: string | null; state: string; attempts: number; updated_at: number; next_attempt_at: number; detail: string }
type Overview = { settings: Settings; ready: boolean; detail: string; editions: Summary[]; deliveries: Delivery[]; total: number; subscribers: number }
type Preview = { id: string; revision: number; subject: string; body_html: string; body_text: string }
const daysOfWeek = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled'
const labels: Record<string, string> = { draft: 'Draft', scheduled: 'Scheduled', queued: 'Queued', complete: 'Finished', skipped: 'Skipped', preparing: 'Preparing email', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' }
const editableFields = (edition: Edition) => ({ subject: edition.subject, intro: edition.intro, titles: edition.content.titles.map(({ id, selected, featured }) => ({ id, selected, featured })) })
const scheduleFields = (settings: Settings) => ({ enabled: settings.enabled, weekday: settings.weekday, hour: settings.hour, limit_titles: settings.limit_titles, public_url: settings.public_url, intro: settings.intro, revision: settings.revision })
function Poster({ title }: { title: Title }) {
const [failed, setFailed] = useState(false)
return <div className="newsletter-poster"><span aria-hidden="true">{title.type === 'series' ? 'TV' : 'MOVIE'}</span>{title.has_artwork && !failed && <img src={`${getApiBase()}/admin/newsletters/artwork/${title.id}`} alt="" loading="lazy" onError={() => setFailed(true)} />}</div>
}
export default function NewslettersAdminPage() {
const router = useRouter()
const [data, setData] = useState<Overview | null>(null)
const [settings, setSettings] = useState<Settings | null>(null)
const [tab, setTab] = useState<'editions' | 'schedule' | 'history'>('editions')
const [edition, setEdition] = useState<Edition | null>(null)
const [saved, setSaved] = useState<Edition | null>(null)
const [preview, setPreview] = useState<Preview | null>(null)
const [mode, setMode] = useState<'html' | 'text'>('html')
const [days, setDays] = useState(7)
const [sendAt, setSendAt] = useState('')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState('')
const [offset, setOffset] = useState(0)
const [refresh, setRefresh] = useState(0)
const testRequest = useRef<{ key: string; id: string } | null>(null)
const initialized = useRef(false)
const actionController = useRef<AbortController | null>(null)
const parse = useCallback(async (response: Response) => {
if (response.status === 401) { router.replace('/login?next=%2Fadmin%2Fnewsletters'); throw new Error('Sign in to continue.') }
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not complete this action. Please try again.')
return result
}, [router])
useEffect(() => {
const abort = new AbortController()
void authFetch(`${getApiBase()}/admin/newsletters?offset=${offset}`, { signal: abort.signal }).then(parse).then((result: Overview) => {
if (abort.signal.aborted) return
setData(result)
if (!initialized.current) { setSettings(result.settings); initialized.current = true }
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => abort.abort()
}, [offset, refresh, parse])
useEffect(() => {
if (!data?.editions.some((row) => ['scheduled', 'queued'].includes(row.state)) && !data?.deliveries.some((row) => ['queued', 'preparing', 'sending', 'retry'].includes(row.state))) return
const timer = window.setInterval(() => setRefresh((value) => value + 1), 10000)
return () => window.clearInterval(timer)
}, [data])
useEffect(() => () => actionController.current?.abort(), [])
const dirty = !!edition && !!saved && JSON.stringify(editableFields(edition)) !== JSON.stringify(editableFields(saved))
const settingsDirty = !!settings && !!data && JSON.stringify(scheduleFields(settings)) !== JSON.stringify(scheduleFields(data.settings))
const selected = edition?.content.titles.filter((title) => title.selected) || []
const featured = selected.filter((title) => title.featured).length
const isDraft = edition?.state === 'draft'
const validPreview = !!edition && !!preview && preview.id === edition.id && preview.revision === edition.revision && !dirty
const hasContent = selected.length > 0 || !!edition?.intro.trim()
const remember = (row: Edition) => { setEdition(row); setSaved(row); setPreview(null); setSendAt(''); testRequest.current = null }
const action = async <T,>(name: string, path: string, method: string, payload: unknown, done: (result: T) => void) => {
if (busy) return
const abort = new AbortController()
actionController.current = abort
setBusy(name); setError(''); setNotice('')
try {
const result = await parse(await authFetch(`${getApiBase()}/admin/newsletters${path}`, { method, signal: abort.signal, headers: { 'Content-Type': 'application/json' }, ...(payload === undefined ? {} : { body: JSON.stringify(payload) }) }))
if (!abort.signal.aborted) { done(result as T); setRefresh((value) => value + 1) }
} catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not complete this action.') }
finally { if (!abort.signal.aborted) setBusy('') }
}
const changeTitle = (id: string, field: 'selected' | 'featured', value: boolean) => {
if (!edition) return
setEdition({ ...edition, content: { ...edition.content, titles: edition.content.titles.map((title) => title.id !== id ? title : { ...title, [field]: value, ...(field === 'selected' && !value ? { featured: false } : {}) }) } })
setPreview(null)
}
const saveDraft = (event: FormEvent) => {
event.preventDefault()
if (edition) void action('save', `/editions/${edition.id}`, 'PUT', { revision: edition.revision, ...editableFields(edition) }, (row: Edition) => { remember(row); setNotice('Draft saved. Preview this version before sending.') })
}
const publish = (scheduled: boolean) => {
if (!edition || !validPreview || !hasContent) return
void action('publish', `/editions/${edition.id}/publish`, 'POST', { revision: edition.revision, send_at: scheduled ? `${sendAt}:00Z` : null }, (row: Edition) => { remember(row); setNotice(`Edition scheduled for ${dateLabel(row.send_at)}. The saved content is now fixed.`) })
}
const sendTest = () => {
if (!edition || !validPreview) return
const key = `${edition.id}:${edition.revision}`
if (testRequest.current?.key !== key) testRequest.current = { key, id: crypto.randomUUID() }
void action('test', `/editions/${edition.id}/test`, 'POST', { revision: edition.revision, request_id: testRequest.current.id }, (result: { message: string }) => { setNotice(result.message); testRequest.current = null; setOffset(0) })
}
return <AdminShell title="Newsletters" subtitle="New arrivals, fresh episodes and a little inspiration for the next watch." actions={<a className="ghost-button" href="/admin/notifications">Email settings </a>}>
<div className="recap-admin newsletter-admin">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{!data && !error && <p role="status">Loading newsletters</p>}
{!data && error && <button type="button" className="ghost-button" onClick={() => { setError(''); setRefresh((value) => value + 1) }}>Try again</button>}
{data && settings && <>
<div className="recap-overview-strip"><div><span className={`recap-pill ${data.settings.enabled ? 'is-enabled' : ''}`}>{data.settings.enabled ? 'Weekly sending is on' : 'Weekly sending is paused'}</span><p>{data.settings.enabled ? `Next edition ${dateLabel(data.settings.next_send_at)}` : 'Create a one-off edition or set a weekly rhythm.'}</p></div><div className="recap-subscriber-count"><strong>{data.subscribers}</strong><span>confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</span></div></div>
<nav className="newsletter-tabs" aria-label="Newsletter sections">{(['editions', 'schedule', 'history'] as const).map((value) => <button type="button" key={value} aria-pressed={tab === value} onClick={() => setTab(value)}>{({ editions: 'Editions', schedule: 'Weekly schedule', history: 'Delivery history' })[value]}</button>)}</nav>
{!data.ready && <p className="recap-setup-note">{data.detail} <a className="recap-text-link" href="/admin/notifications">Email settings </a> · <a className="recap-text-link" href="/admin/jellyfin">Jellyfin settings </a></p>}
{tab === 'editions' && <>
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">A fresh edition</span><h2>Whats new on Grizzlyflix</h2><p>Collect arrivals from Jellyfin, choose your picks and add a note to your community.</p></div><div className="newsletter-create"><label className="recap-month-label" htmlFor="arrival-period">Arrival period<select id="arrival-period" value={days} disabled={!!busy || dirty} onChange={(event) => setDays(Number(event.target.value))}>{[7, 14, 30].map((value) => <option key={value} value={value}>Last {value} days</option>)}</select></label><button type="button" className="account-primary" disabled={!!busy || dirty} onClick={() => void action('create', '/drafts', 'POST', { days }, (row: Edition) => { remember(row); setNotice('Arrivals collected. Choose the titles you want to include.') })}>{busy === 'create' ? 'Collecting arrivals…' : 'Create draft'}</button></div></div>
{data.editions.length ? <div className="newsletter-editions">{data.editions.map((row) => <button type="button" className={`newsletter-edition ${edition?.id === row.id ? 'is-active' : ''}`} key={row.id} disabled={!!busy || dirty} onClick={() => void action('open', `/editions/${row.id}`, 'GET', undefined, remember)}><span><strong>{row.subject}</strong><small>{row.titles} {row.titles === 1 ? 'title' : 'titles'} · {row.origin === 'weekly' ? 'Weekly edition' : 'Custom edition'} · {dateLabel(row.send_at || row.created_at)}</small></span><span className="recap-pill">{labels[row.state] || row.state}</span></button>)}</div> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Something good to watch</h3><p>Your first edition starts with the latest additions to your library. Collect a draft to begin.</p></div>}
{dirty && <p className="recap-muted">Save or discard the current changes before opening another edition.</p>}
</section>
{edition && <section className="admin-panel recap-panel newsletter-editor"><div className="recap-section-heading"><div><span className="recap-eyebrow">{isDraft ? 'Make it yours' : 'Saved edition'}</span><h2>{isDraft ? 'Edit your newsletter' : edition.subject}</h2><p>Arrivals from {edition.content.period_start.slice(0, 10)} to {edition.content.period_end.slice(0, 10)} (UTC). TV additions are grouped by show.</p></div><span className="recap-pill">{labels[edition.state] || edition.state}</span></div>
<form className="recap-schedule-form" onSubmit={saveDraft}><label htmlFor="newsletter-subject">Email subject<input id="newsletter-subject" maxLength={150} required value={edition.subject} disabled={!!busy || !isDraft} onChange={(event) => { setEdition({ ...edition, subject: event.target.value }); setPreview(null) }} /></label><label htmlFor="newsletter-intro">Announcement <span className="newsletter-optional">Optional</span><textarea id="newsletter-intro" rows={4} maxLength={2000} placeholder="A welcome, a weekend recommendation, or a quick update…" disabled={!!busy || !isDraft} value={edition.intro} onChange={(event) => { setEdition({ ...edition, intro: event.target.value }); setPreview(null) }} /><small>Plain text, shared with every subscriber receiving this edition.</small></label>
<div className="newsletter-selection-heading"><h3>Choose the lineup</h3><span>{selected.length}/24 included · {featured}/3 featured</span></div>
{edition.content.total_titles > edition.content.titles.length && <p className="recap-muted">Showing the {edition.content.titles.length} newest titles of {edition.content.total_titles} found in this period.</p>}
{edition.content.titles.length ? <div className="newsletter-titles">{edition.content.titles.map((title) => <article className={`newsletter-title ${title.selected ? 'is-selected' : ''}`} key={title.id}><Poster title={title} /><div className="newsletter-title-copy"><h4>{title.title}</h4><p>{title.type === 'movie' ? `Movie${title.year ? ` · ${title.year}` : ''}` : `${title.items.length} new ${title.items.length === 1 ? 'episode' : 'episodes'}`}</p><label className="recap-checkbox"><input type="checkbox" aria-label={`Include ${title.title}`} checked={title.selected} disabled={!!busy || !isDraft || (!title.selected && selected.length >= 24)} onChange={(event) => changeTitle(title.id, 'selected', event.target.checked)} /><span>Include</span></label><label className="recap-checkbox"><input type="checkbox" aria-label={`Feature ${title.title}`} checked={title.featured} disabled={!!busy || !isDraft || !title.selected || (!title.featured && featured >= 3)} onChange={(event) => changeTitle(title.id, 'featured', event.target.checked)} /><span>Featured pick</span></label></div></article>)}</div> : <p>No new titles were found in this period. You can still create an announcement edition.</p>}
<div className="recap-actions">{isDraft && <button type="submit" className="account-primary" disabled={!!busy || !dirty}>{busy === 'save' ? 'Saving…' : 'Save draft'}</button>}<button type="button" className="account-secondary" disabled={!!busy} onClick={() => void action('reload', `/editions/${edition.id}`, 'GET', undefined, remember)}>{dirty ? 'Discard changes' : 'Reload edition'}</button><button type="button" className="account-secondary" disabled={!!busy || dirty || settingsDirty || edition.state === 'cancelled'} onClick={() => void action('preview', `/editions/${edition.id}/preview`, 'POST', { revision: edition.revision }, (result: Preview) => { setPreview(result); setMode('html') })}>{busy === 'preview' ? 'Preparing preview…' : 'Preview edition'}</button></div>
</form>
<p className="recap-muted">Each recipients email includes only titles available to their linked Jellyfin account. Posters are included in the email.</p>
{dirty && <p className="recap-muted">Save the draft to preview and send this version.</p>}
{settingsDirty && <p className="recap-muted">Save or reload your weekly settings before previewing or sending.</p>}
{validPreview && preview && <div className="recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h3>{preview.subject}</h3><p>This shows the full selection. Your test uses your own library access.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={mode === 'html'} onClick={() => setMode('html')}>Email design</button><button type="button" aria-pressed={mode === 'text'} onClick={() => setMode('text')}>Plain text</button></div></div>{mode === 'html' ? <iframe title="Newsletter email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</div>}
{edition.state !== 'cancelled' && <div className="newsletter-send"><h3>{isDraft ? 'Ready for the inbox?' : 'Delivery controls'}</h3><p>A test goes to your own confirmed newsletter email. <a href="/profile#newsletters">Manage your subscription </a></p><div className="recap-actions"><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty} onClick={sendTest}>{busy === 'test' ? 'Queuing test…' : 'Send newsletter test to me'}</button></div>{isDraft ? <><label className="recap-month-label" htmlFor="newsletter-send-time">Schedule for (UTC)<input id="newsletter-send-time" type="datetime-local" value={sendAt} disabled={!!busy} onChange={(event) => setSendAt(event.target.value)} /></label><div className="recap-actions"><button type="button" className="account-primary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !sendAt} onClick={() => publish(true)}>Schedule edition</button><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !data.subscribers} onClick={() => publish(false)}>Send now to {data.subscribers} {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</button></div><p className="recap-muted">Preview the saved edition before sending. Scheduling fixes the content for this edition. Users must be subscribed by its send time.</p></> : <p>{dateLabel(edition.send_at)} · Check Delivery history for individual results.</p>}{['draft', 'scheduled', 'queued'].includes(edition.state) && <button type="button" className="ghost-button newsletter-cancel" disabled={!!busy || dirty} onClick={() => void action('cancel', `/editions/${edition.id}/cancel`, 'POST', {}, (row: Edition) => { remember(row); setNotice('Edition cancelled. Pending emails have been stopped.') })}>Cancel edition</button>}</div>}
</section>}
</>}
{tab === 'schedule' && <section className="admin-panel recap-panel"><span className="recap-eyebrow">Set the rhythm</span><h2>A weekly discovery</h2><p>Automatically collect the previous seven days of arrivals and send an edition to confirmed subscribers. Weeks without new arrivals are skipped.</p><form className="recap-schedule-form" onSubmit={(event) => { event.preventDefault(); void action('settings', '', 'PUT', scheduleFields(settings), (result: Settings) => { setSettings(result); setData({ ...data, settings: result }); setPreview(null); setNotice(result.enabled ? `Weekly settings saved. Next edition ${dateLabel(result.next_send_at)}.` : 'Settings saved. Automatic weekly editions are paused.') }) }}><label htmlFor="newsletter-public-url">Public Magent address<input id="newsletter-public-url" type="url" maxLength={500} placeholder="https://magent.example.com" required={settings.enabled} value={settings.public_url} disabled={!!busy} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} /><small>Used for confirmation links and email preferences in this environment.</small></label><div className="recap-schedule-fields"><label htmlFor="newsletter-weekday">Send day<select id="newsletter-weekday" value={settings.weekday} disabled={!!busy} onChange={(event) => setSettings({ ...settings, weekday: Number(event.target.value) })}>{daysOfWeek.map((day, index) => <option value={index} key={day}>{day}</option>)}</select></label><label htmlFor="newsletter-hour">Send time (UTC)<select id="newsletter-hour" value={settings.hour} disabled={!!busy} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div><label htmlFor="newsletter-limit">Titles per weekly edition<select id="newsletter-limit" value={settings.limit_titles} disabled={!!busy} onChange={(event) => setSettings({ ...settings, limit_titles: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select><small>Newest titles first. Multiple episodes count as one show.</small></label><label htmlFor="newsletter-default-intro">Default announcement<textarea id="newsletter-default-intro" rows={4} maxLength={2000} value={settings.intro} disabled={!!busy} onChange={(event) => setSettings({ ...settings, intro: event.target.value })} /><small>Appears in future weekly editions and newly created drafts.</small></label><label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable automatic weekly newsletters</span></label><p className="recap-muted">The schedule starts at the next future send time, in UTC. Pausing stops pending automatic editions. Custom editions keep their individual schedules.</p><div className="recap-actions"><button type="submit" className="account-primary" disabled={!!busy || !settingsDirty}>{busy === 'settings' ? 'Saving…' : 'Save weekly settings'}</button><button type="button" className="account-secondary" disabled={!!busy} onClick={() => void action('settings-reload', '', 'GET', undefined, (result: Overview) => { setData(result); setSettings(result.settings); setPreview(null) })}>Reload settings</button></div></form>{data.settings.last_error && <p className="recap-setup-note">{data.settings.last_error}</p>}</section>}
{tab === 'history' && <section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => setRefresh((value) => value + 1)}>Refresh history</button></div>{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Edition</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((row) => <tr key={row.id}><td><strong>{row.username || 'Removed account'}</strong><small>{row.email}</small></td><td>{row.subject}<small>{row.kind === 'test' ? 'Test email' : 'Newsletter'}</small></td><td><span className={`recap-pill ${row.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(row.state) ? 'is-attention' : ''}`}>{labels[row.state] || row.state}</span><small>{row.attempts} {row.attempts === 1 ? 'attempt' : 'attempts'} · {row.detail || 'Waiting for the next worker check.'}</small>{row.state === 'retry' && <small>Next attempt {dateLabel(row.next_attempt_at)}</small>}{row.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(row.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button type="button" className="ghost-button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button type="button" className="ghost-button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first edition starts here</h3><p>Preview a draft and send yourself a test. Delivery results will appear here.</p></div>}</section>}
</>}
</div>
</AdminShell>
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default function AdminLandingPage() {
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
return (
<a href={item.href} key={item.href} className="config-directory-link">
<span className="config-link-icon" aria-hidden="true">{item.symbol}</span>
{item.symbol && <span className="config-link-icon" aria-hidden="true">{item.symbol}</span>}
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>}
<span className="config-link-arrow" aria-hidden="true"></span>
+131
View File
@@ -0,0 +1,131 @@
'use client'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, getApiBase } from '../../lib/auth'
import '../../email-recaps/recaps.css'
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null }
type Delivery = { id: string; month: string; kind: string; email: string; username: string | null; state: string; attempts: number; created_at: number; updated_at: number; next_attempt_at: number; detail: string }
type Overview = { settings: Settings; ready: boolean; detail: string; months: string[]; deliveries: Delivery[]; total: number; subscribers: number; worker_enabled: boolean }
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null }
const monthLabel = (month: string) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' })
const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled'
const stateLabels: Record<string, string> = { queued: 'Queued', preparing: 'Preparing report', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' }
export default function EmailRecapsAdminPage() {
const router = useRouter()
const [data, setData] = useState<Overview | null>(null)
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: '' })
const [month, setMonth] = useState('')
const [preview, setPreview] = useState<Preview | null>(null)
const [previewMode, setPreviewMode] = useState<'html' | 'text'>('html')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState('')
const [offset, setOffset] = useState(0)
const [revision, setRevision] = useState(0)
const testRequest = useRef<{ month: string; id: string } | null>(null)
const initialized = useRef(false)
const previewController = useRef<AbortController | null>(null)
const responseData = useCallback(async (response: Response) => {
if (response.status === 401) { router.replace('/login?next=%2Fadmin%2Frecaps'); throw new Error('Sign in to continue.') }
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not complete this action. Check your settings and try again.')
return result
}, [router])
useEffect(() => {
const abort = new AbortController()
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal }).then(responseData).then((result: Overview) => {
if (abort.signal.aborted) return
setData(result)
if (!initialized.current) { setSettings(result.settings); setMonth(result.months[0] || ''); initialized.current = true }
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => abort.abort()
}, [offset, revision, responseData])
useEffect(() => {
if (!data?.deliveries.some((delivery) => ['queued', 'preparing', 'sending', 'retry'].includes(delivery.state))) return
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000)
return () => window.clearInterval(timer)
}, [data])
useEffect(() => () => previewController.current?.abort(), [])
const dirty = !!data && (settings.enabled !== data.settings.enabled || settings.day !== data.settings.day || settings.hour !== data.settings.hour || settings.public_url !== data.settings.public_url)
const save = async (event: FormEvent) => {
event.preventDefault()
if (busy) return
setBusy('save'); setError(''); setNotice('')
try {
const { enabled, day, hour, public_url } = settings
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour, public_url }) })) as Settings
setSettings(result); setData((current) => current ? { ...current, settings: result } : current)
setPreview(null)
setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.')
setRevision((value) => value + 1)
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save the schedule.') }
finally { setBusy('') }
}
const loadPreview = async () => {
if (busy || !month) return
const abort = new AbortController()
previewController.current?.abort(); previewController.current = abort
setBusy('preview'); setError(''); setNotice(''); setPreview(null)
try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal })) as Preview
if (!abort.signal.aborted) setPreview(result)
} catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not prepare your preview.') }
finally { if (!abort.signal.aborted) setBusy('') }
}
const sendTest = async () => {
if (busy || !preview || preview.month !== month) return
if (!testRequest.current || testRequest.current.month !== month) testRequest.current = { month, id: crypto.randomUUID() }
setBusy('test'); setError(''); setNotice('')
try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ month, request_id: testRequest.current.id }) }))
setNotice(result.message); testRequest.current = null; setOffset(0); setRevision((value) => value + 1)
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your test.') }
finally { setBusy('') }
}
return <AdminShell title="Monthly email recaps" subtitle="Give each user a personal look back at their month in viewing." actions={<a className="ghost-button" href="/admin/notifications">Email settings </a>}>
<div className="recap-admin">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{!data && !error && <p role="status">Loading email recaps</p>}
{!data && error && <button className="ghost-button" type="button" onClick={() => { setError(''); setRevision((value) => value + 1) }}>Try again</button>}
{data && <>
<div className="recap-overview-strip"><div><span className={`recap-pill ${data.settings.enabled ? 'is-enabled' : ''}`}>{data.settings.enabled ? 'Schedule running' : 'Schedule paused'}</span><p>{data.settings.enabled ? `Next send ${dateLabel(data.settings.next_send_at)}` : 'Start the schedule when youre ready for monthly delivery.'}</p></div><div className="recap-subscriber-count"><strong>{data.subscribers}</strong><span>confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</span></div></div>
<div className="recap-admin-grid">
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">Set the rhythm</span><h2>Monthly schedule</h2></div></div>
<p>Send the previous months report to users who have opted in and confirmed their email. All report periods and send times use UTC.</p>
<form className="recap-schedule-form" onSubmit={save}>
<label htmlFor="recap-public-url">Public Magent address<input id="recap-public-url" type="url" placeholder="https://magent.example.com" maxLength={500} value={settings.public_url} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} disabled={!!busy} required={settings.enabled} /><small>The address users open from this environments emails.</small></label>
<div className="recap-schedule-fields"><label htmlFor="recap-day">Day of the month<select id="recap-day" value={settings.day} onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 28 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label><label htmlFor="recap-hour">Send time (UTC)<select id="recap-hour" value={settings.hour} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div>
<label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable scheduled monthly recaps</span></label>
<p className="recap-muted">Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.</p>
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>{busy === 'save' ? 'Saving…' : 'Save schedule'}</button>
</form>
{!data.ready && <p className="recap-setup-note">{data.detail} <a href="/admin/notifications">Review email settings </a></p>}
</section>
<section className="admin-panel recap-panel"><span className="recap-eyebrow">Make it yours</span><h2>Preview your recap</h2><p>See your own viewing highlights in the email design. A test goes only to your confirmed profile email.</p>
<label className="recap-month-label" htmlFor="recap-month">Report month<select id="recap-month" value={month} disabled={!!busy} onChange={(event) => { setMonth(event.target.value); setPreview(null); testRequest.current = null }}>{data.months.map((value) => <option key={value} value={value}>{monthLabel(value)}</option>)}</select></label>
<div className="recap-actions"><button type="button" className="account-primary" onClick={() => void loadPreview()} disabled={!!busy || dirty || !month}>{busy === 'preview' ? 'Preparing preview…' : 'Preview my recap'}</button><button type="button" className="account-secondary" onClick={() => void sendTest()} disabled={!!busy || dirty || !preview || !data.ready}>{busy === 'test' ? 'Queuing test…' : 'Send test to me'}</button></div>
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
<div className="recap-preview-guidance"><h3>One email. Your month.</h3><ul><li>Minutes, movies, episodes and requests</li><li>Changes from the previous month</li><li>Most watched titles and your longest run</li><li>A link to the full report and easy unsubscribe</li></ul><a href="/profile#monthly-recaps">Confirm your email in Profile </a></div>
</section>
</div>
{preview && <section className="admin-panel recap-panel recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h2>{preview.subject}</h2><p>For {preview.email || 'your profile email'} · Preview links use your saved public address.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={previewMode === 'html'} onClick={() => setPreviewMode('html')}>Email design</button><button type="button" aria-pressed={previewMode === 'text'} onClick={() => setPreviewMode('text')}>Plain text</button></div></div>{previewMode === 'html' ? <iframe title="Monthly recap email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</section>}
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => { setError(''); setRevision((value) => value + 1) }}>Refresh history</button></div>
{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Report</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((delivery) => <tr key={delivery.id}><td><strong>{delivery.username || 'Removed account'}</strong><small>{delivery.email}</small></td><td>{monthLabel(delivery.month)}<small>{delivery.kind === 'test' ? 'Test email' : delivery.kind === 'on_demand' ? 'Requested by user' : 'Scheduled recap'}</small></td><td><span className={`recap-pill ${delivery.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(delivery.state) ? 'is-attention' : ''}`}>{stateLabels[delivery.state] || delivery.state}</span><small>{delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'}</small>{delivery.state === 'retry' && <small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>}{delivery.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(delivery.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button className="ghost-button" type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button className="ghost-button" type="button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first recap starts here</h3><p>Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.</p></div>}
</section>
</>}
</div>
</AdminShell>
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { useEffect, useState } from 'react'
import { getApiBase } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
import './recaps.css'
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string }
export default function EmailRecapLinkPage() {
const [link, setLink] = useState<LinkAction | null>(null)
const [state, setState] = useState('loading')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
let controller: AbortController | null = null
const checkLink = () => {
controller?.abort()
const abort = new AbortController()
controller = abort
setError(''); setState('loading'); setLink(null)
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
const params = new URLSearchParams(window.location.hash.slice(1))
const action = params.get('action')
const token = params.get('token') || ''
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
setError('This email link is incomplete. Open Profile to manage your monthly recaps.'); setState('error'); return
}
const payload = { action, token } as LinkAction
setLink(payload)
void fetch(`${getApiBase()}/email-recaps/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => {
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.')
if (!abort.signal.aborted) setState(result.state)
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } })
}
checkLink()
window.addEventListener('hashchange', checkLink)
return () => { controller?.abort(); window.removeEventListener('hashchange', checkLink) }
}, [])
const apply = async () => {
if (!link || busy) return
setBusy(true); setError('')
try {
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(link), credentials: 'omit' })
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.')
setState(result.state)
window.history.replaceState(null, '', '/email-recaps')
} catch (err) { setError(err instanceof Error ? err.message : 'Could not update your preference.') }
finally { setBusy(false) }
}
const done = state === 'enabled' || state === 'off'
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel">
<span className="recap-eyebrow">Personal viewing reports</span>
<h1>{state === 'enabled' ? 'Youre on the list.' : state === 'off' ? 'Recaps are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps?' : 'Your month, delivered.'}</h1>
<p>{state === 'enabled' ? 'Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You wont receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off all personal viewing report emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile.' : ''}</p>
{error && <p className="account-notice is-error" role="alert">{error}</p>}
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'}</button>}
{(done || state === 'error') && <a className="recap-text-link" href="/profile#monthly-recaps">Manage email preferences </a>}
{state === 'loading' && <p role="status">One moment</p>}
</section></main>
}
+81
View File
@@ -0,0 +1,81 @@
.recap-admin { display: grid; gap: 24px; }
.recap-admin-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 24px; }
.recap-panel.admin-panel { margin: 0; padding: 28px; min-width: 0; border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); }
.recap-panel h2, .recap-preference h2 { margin: 8px 0 12px; font-size: 22px; }
.recap-panel h3 { font-size: 16px; }
.recap-panel p, .recap-preference p { color: var(--ops-muted); font-size: 13px; line-height: 1.7; }
.recap-panel a, .recap-preference a, .recap-text-link { color: #c7bdff; text-decoration: none; }
.recap-panel a:hover, .recap-preference a:hover, .recap-text-link:hover { text-decoration: underline; }
.recap-eyebrow { display: block; color: #bcb3eb; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; }
.recap-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.recap-section-heading > div { min-width: 0; }
.recap-pill { display: inline-flex; align-items: center; padding: 6px 10px; border: 1px solid var(--ops-line); border-radius: 99px; font-size: 11px; line-height: 1.4; color: var(--ops-muted); white-space: nowrap; }
.recap-pill.is-enabled { color: #cfc7fc; background: #c7bdff12; border-color: #c7bdff40; }
.recap-pill.is-attention { color: #eab9a6; border-color: #eab9a650; }
.recap-overview-strip { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 28px; border: 1px solid var(--ops-line); border-radius: 14px; background: linear-gradient(110deg, #c7bdff0c, transparent 65%), var(--ops-panel); }
.recap-overview-strip p { color: var(--ops-muted); font-size: 13px; margin: 12px 0 0; line-height: 1.6; }
.recap-subscriber-count { display: flex; align-items: center; gap: 14px; }
.recap-subscriber-count strong { color: #d8d0ff; font-size: 36px; font-weight: 500; }
.recap-subscriber-count span { max-width: 100px; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
.recap-schedule-form { display: grid; gap: 18px; margin-top: 24px; }
.recap-schedule-form label, .recap-month-label { display: grid; gap: 9px; padding: 0; margin: 0; color: var(--ops-text); font-size: 13px; text-transform: none; border: 0; background: none; }
.recap-schedule-form input:not([type=checkbox]), .recap-schedule-form select, .recap-month-label select { min-width: 0; width: 100%; min-height: 44px; border: 1px solid var(--ops-line); border-radius: 8px; padding: 10px 12px; font: 13px Inter, sans-serif; }
.recap-schedule-form small { color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
.recap-schedule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.recap-schedule-form .recap-checkbox { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.recap-checkbox input { width: 18px; height: 18px; accent-color: #c7bdff; }
.recap-schedule-form button { justify-self: start; }
.recap-schedule-form .recap-muted { margin: -6px 0 0; }
.recap-panel .recap-muted, .recap-preference .recap-muted { font-size: 12px; color: var(--ops-faint); }
.recap-setup-note { padding: 14px 16px; border: 1px solid var(--ops-line); border-radius: 8px; margin: 20px 0 0; }
.recap-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.recap-month-label { margin-top: 24px; }
.recap-preview-guidance { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 16px; }
.recap-preview-guidance ul { padding-left: 18px; margin: 18px 0; color: var(--ops-muted); font-size: 13px; line-height: 2; }
.recap-preview-guidance a { font-size: 13px; }
.recap-mode-buttons { display: flex; flex-shrink: 0; gap: 6px; }
.recap-mode-buttons button { background: transparent !important; color: var(--ops-muted); border: 1px solid var(--ops-line); padding: 10px 12px; font-size: 12px; text-transform: none; }
.recap-mode-buttons button[aria-pressed=true] { border-color: #c7bdff70; color: #d5cdff; background: #c7bdff10 !important; }
.recap-preview iframe { display: block; width: 100%; height: 1050px; margin-top: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: #131315; }
.recap-plain-preview { white-space: pre-wrap; overflow-wrap: anywhere; padding: 24px; background: #131315; border: 1px solid var(--ops-line); border-radius: 10px; color: var(--ops-muted); font-size: 13px; line-height: 1.8; }
.recap-history-scroll { overflow-x: auto; margin-top: 18px; }
.recap-history { width: 100%; border-collapse: collapse; font-size: 12px; }
.recap-history th { color: var(--ops-faint); font-size: 11px; font-weight: 500; text-align: left; }
.recap-history th, .recap-history td { padding: 16px 12px; border-bottom: 1px solid var(--ops-line-soft); vertical-align: top; }
.recap-history td { min-width: 135px; line-height: 1.7; }
.recap-history td:first-child { min-width: 170px; }
.recap-history td:nth-child(3) { min-width: 245px; max-width: 400px; }
.recap-history strong { display: block; font-weight: 500; }
.recap-history small { display: block; margin-top: 6px; font-size: 11px; color: var(--ops-muted); overflow-wrap: anywhere; }
.recap-pagination { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--ops-muted); font-size: 12px; margin-top: 16px; }
.recap-pagination .recap-actions { margin: 0; }
.recap-empty { text-align: center; padding: 36px 20px 24px; }
.recap-empty > span { color: #bcb3eb; font-size: 28px; }
.recap-empty p { max-width: 430px; margin: 12px auto; }
.recap-preference { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 28px; scroll-margin-top: 24px; }
.recap-preference p { max-width: 620px; }
.recap-delivery-address { overflow-wrap: anywhere; }
.page > main.recap-link-page { max-width: 600px; margin: 70px auto; }
.recap-brand { display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 32px; text-decoration: none; color: var(--ops-text); font: 500 25px "DM Sans", sans-serif; }
.recap-brand img { width: 42px; height: 42px; object-fit: contain; }
.recap-brand .brand-logo { width: 42px; height: 42px; flex: 0 0 42px; }
.recap-link-page h1 { font-size: clamp(25px, 5vw, 36px); margin: 16px 0; }
.recap-link-page p { font-size: 14px; line-height: 1.8; color: var(--ops-muted); }
.recap-link-page button, .recap-link-page .recap-text-link { margin-top: 16px; }
.recap-link-page .recap-text-link { display: inline-block; font-size: 14px; }
@media (max-width: 1000px) { .recap-admin-grid { grid-template-columns: 1fr; } }
@media (max-width: 600px) {
.recap-panel.admin-panel { padding: 20px 16px; }
.recap-overview-strip, .recap-section-heading { flex-direction: column; gap: 16px; }
.recap-overview-strip { padding: 20px; }
.recap-subscriber-count span { max-width: none; }
.recap-schedule-fields { gap: 12px; }
.recap-panel h2, .recap-preference h2 { font-size: 20px; }
.recap-preview iframe { height: 1200px; }
.page > main.recap-link-page { margin: 36px auto; }
.recap-link-page .account-panel { padding: 26px 22px; }
.recap-pagination { flex-wrap: wrap; }
}
.recap-delivery-choice { display: grid; gap: 8px; margin-block: 16px; }
.recap-delivery-choice select { width: 100%; min-width: 0; }
+6
View File
@@ -7597,3 +7597,9 @@ textarea {
justify-content: flex-start;
}
}
.request-language-notice { display: grid; gap: 12px; padding: 20px; border: 1px solid #a88445; border-radius: 12px; background: #a8844512; }
.request-language-notice h3, .request-language-notice p { margin: 0; }
.request-language-notice p, .request-language-notice small { line-height: 1.6; }
.request-language-notice label { display: flex; align-items: flex-start; gap: 10px; }
.request-language-notice input[type=checkbox] { flex: 0 0 20px; width: 20px; height: 20px; margin-top: 2px; }
+4 -4
View File
@@ -7,7 +7,7 @@ export default function HowItWorksPage() {
<nav aria-label="Quick links"><a href="/welcome">Welcome page</a><a href="/">My Requests</a><a href="/profile">My profile</a></nav>
<details open><summary>Request a movie or TV show</summary>
<ol>
<li><strong>Choose Movie or TV show.</strong><p>Open <a href="/new-requests">02 New Requests</a> and pick what youre looking for.</p></li>
<li><strong>Choose Movie or TV show.</strong><p>Open <a href="/new-requests">New Requests</a> and pick what youre looking for.</p></li>
<li><strong>Search and choose the right title.</strong><p>For TV, choose the seasons you want. If its already requested, open that request to see its progress.</p></li>
<li><strong>Check your choices and send it.</strong><p>Choose from the quality options shown. These come from the librarys settings.</p></li>
<li><strong>Follow it in My Requests.</strong><p>Well show whats happening and any next step you can take. Some titles need approval or may not have a suitable download yet.</p></li>
@@ -34,7 +34,7 @@ export default function HowItWorksPage() {
</details>
<details><summary>Report a problem and follow the fix</summary>
<ol>
<li><strong>Open <a href="/portal/issues">03 Issues</a>.</strong><p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p></li>
<li><strong>Open <a href="/portal/issues">Issues</a>.</strong><p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p></li>
<li><strong>Choose the affected content.</strong><p>Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.</p></li>
<li><strong>Read What will happen, then submit.</strong><p>It tells you whether the selected files will be replaced, missing content searched for, subtitles checked, or playback investigated.</p></li>
<li><strong>Follow the issues progress.</strong><p>Open your reported issue to see the work recorded and where the fix is up to.</p></li>
@@ -44,13 +44,13 @@ export default function HowItWorksPage() {
</details>
<details><summary>Invite someone</summary>
<ol>
<li><strong>Open <a href="/profile/invites">04 Invites</a>.</strong><p>If invites are enabled for your account, give your invite a name youll recognise.</p></li>
<li><strong>Open <a href="/profile/invites">Invites</a>.</strong><p>If invites are enabled for your account, give your invite a name youll recognise.</p></li>
<li><strong>Add a welcome note, or skip it.</strong><p>A custom invite code is optional too.</p></li>
<li><strong>Choose how to share it.</strong><p>Copy the link yourself, or enter an email address to send it directly.</p></li>
<li><strong>Manage it later.</strong><p>You can return to your invites to check them or disable a link. Your accounts invite limits apply automatically.</p></li>
</ol>
</details>
<details><summary>Update your account</summary><p>Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your activity, or use the password options available for your account.</p><p>Looking for your downloads instead? <a href="/">01 My Requests</a> is your starting point.</p></details>
<details><summary>Update your account</summary><p>Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your activity, or use the password options available for your account.</p><p>Looking for your downloads instead? <a href="/">My Requests</a> is your starting point.</p></details>
<footer>Ready? <a href="/welcome">Choose where to go next </a></footer>
</main>
}
+93
View File
@@ -0,0 +1,93 @@
'use client'
import { useState } from 'react'
import { getApiBase } from '../lib/auth'
export type Breakdown = { name: string; minutes: number }
export type Day = { date: string; minutes: number }
export type Transcoding = {
video_minutes: number; audio_minutes: number; hardware_video_minutes: number; software_video_minutes: number
unknown_hardware_minutes: number; unknown_video_minutes: number; unknown_audio_minutes: number
hardware: Breakdown[]; audio_codecs: Breakdown[]; gpu_busy_minutes: null
}
export type Stats = {
state: 'ready' | 'not_configured' | 'unlinked'
is_admin: boolean
days: number
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 }[]
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[]
transcoding?: Transcoding
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
}
export const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 })
export const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
export function ViewingChart({ daily }: { daily: Day[] }) {
const [selected, setSelected] = useState<number | null>(null)
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1
const bars: { start: string; end: string; minutes: number }[] = []
for (let i = 0; i < daily.length; i += bucket) {
const group = daily.slice(i, i + bucket)
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) })
}
const peak = Math.max(1, ...bars.map((bar) => bar.minutes))
const active = selected === null ? null : bars[selected]
return (
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div>
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div>
<div className="stats-chart">
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div>
<div className="stats-chart-bars">
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)}
</div>
</div>
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div>
</section>
)
}
export function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section>
}
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`
export function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
return <section className="stats-panel stats-streaming">
<div className="stats-panel-heading"><h2>How you streamed</h2></div>
<div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{minutes(row.minutes)}</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div>
{transcoding && <div className="stats-transcoding">
<h3>Transcoding playback time</h3>
<div className="stats-transcode-metrics">
<div><span>GPU-assisted video</span><strong>{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}</strong><small>{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}</small></div>
<div><span>Audio transcoding</span><strong>{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}</strong><small>{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}</small></div>
</div>
<dl className="stats-transcode-details"><div><dt>Total video transcoding</dt><dd>{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}</dd></div>{transcoding.software_video_minutes > 0 && <div><dt>Software video</dt><dd>{minutes(transcoding.software_video_minutes)}</dd></div>}{transcoding.unknown_hardware_minutes > 0 && <div><dt>Video hardware not recorded</dt><dd>{minutes(transcoding.unknown_hardware_minutes)}</dd></div>}</dl>
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && <p className="stats-muted">Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.</p>}
<p className="stats-muted">Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.</p>
</div>}
</section>
}
export function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
const [failed, setFailed] = useState(false)
return <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
{url && !failed ? <img src={`${getApiBase()}${url}`} alt="" width={44} height={66} loading="lazy" onError={() => setFailed(true)} /> : <span>{type === 'episode' ? 'TV' : type === 'movie' ? 'MV' : '▶'}</span>}
</div>
}
export function StatsNavigation({ reports = false }: { reports?: boolean }) {
return <nav className="stats-view-tabs" aria-label="My Stats views">
<a href="/insights" aria-current={!reports ? 'page' : undefined}>Overview</a>
<a href="/insights/reports" aria-current={reports ? 'page' : undefined}>Monthly reports</a>
</nav>
}
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../lib/auth'
import PageHeading from '../ui/PageHeading'
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from './components'
import './stats.css'
export default function InsightsPage() {
const router = useRouter()
const [days, setDays] = useState(30)
const [data, setData] = useState<Stats | null>(null)
const [busy, setBusy] = useState(true)
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const load = useCallback(async (signal: AbortSignal) => {
setBusy(true)
setError('')
setData(null)
try {
const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal })
if (response.status === 401) { router.replace('/login?next=%2Finsights'); return }
if (response.status === 403) throw new Error('Your account cannot access viewing stats. Please contact an administrator.')
if (!response.ok) {
const result = await response.json().catch(() => ({}))
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your viewing stats are temporarily unavailable. Please try again shortly.')
}
const result = await response.json() as Stats
if (!signal.aborted) setData(result)
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your stats.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [days, router])
useEffect(() => {
const controller = new AbortController()
void load(controller.signal)
return () => controller.abort()
}, [load, revision])
const summary = data?.summary
return (
<main className="stats-page">
<PageHeading title="My Stats" description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all." actions={<button className="ghost-button" type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}</button>} />
<StatsNavigation />
<div className="stats-toolbar">
<fieldset className="stats-period"><legend className="stats-sr-only">Stats period</legend>{[7, 30, 90, 365].map((value) => <button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>{value === 365 ? 'Past year' : `${value} days`}</button>)}</fieldset>
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat{data?.updated_at && <span> · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>}</p>
</div>
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Gathering your stats</h2><p>Fetching your viewing history from Jellystat.</p></div>}
{error && <div className="stats-state" role="alert"><h2>Stats couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>}
{data?.state === 'not_configured' && <section className="stats-state"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Your viewing story starts here</h2><p>{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.</p></section>}
{summary && <>
<section className="stats-metrics" aria-label="Viewing totals">
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{number(summary.minutes / 60)} hours across {number(summary.plays)} plays</small></article>
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small></article>
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small></article>
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests</small></article>
</section>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history in this period yet. Try a longer period, or come back after your next watch.</div>}
<div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel stats-highlights"><div className="stats-panel-heading"><h2>A little watch history</h2></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.current_streak}<small> days</small></span><div><strong>Current streak</strong><p>Consecutive viewing days through today or yesterday.</p></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Your best streak in this period.</p></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Time for a story</strong><p>Days with at least a minute watched.</p></div></div>
</section>
</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">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><span className="stats-rank">{String(index + 1).padStart(2, '0')}</span><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 favourites will find their place here.</p>}</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
</>}
{data && <div className="stats-main-grid">
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>}
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in the past {days} days</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length > 0 ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">Something on your watchlist? <a href="/new-requests">Make a request.</a></p>}</section>
</div>}
{summary && <p className="stats-footnote">Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystats movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
</main>
)
}
@@ -0,0 +1,60 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../../lib/auth'
type Delivery = { id: string; month: string; state: string; detail: string }
type Preference = { email: string | null; can_send: boolean; state: string; detail: string; deliveries: Delivery[] }
export default function EmailReportControl({ month }: { month: string }) {
const [data, setData] = useState<Preference | null>(null)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const request = useRef<{ month: string; id: string } | null>(null)
const pending = data?.deliveries.some((item) => ['queued', 'preparing', 'sending', 'retry'].includes(item.state)) ?? false
useEffect(() => {
const abort = new AbortController()
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => {
if (!response.ok) throw new Error('Could not load your report email preferences. Refresh to try again.')
const result = await response.json()
if (!abort.signal.aborted) setData(result)
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => abort.abort()
}, [revision])
useEffect(() => {
if (!pending) return
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000)
return () => window.clearInterval(timer)
}, [pending])
const send = async () => {
if (busy || !data?.can_send) return
setBusy(true); setError(''); setNotice('')
if (request.current?.month !== month) request.current = { month, id: crypto.randomUUID() }
try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps/send`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ month, request_id: request.current.id }),
})
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not queue your report. Try again.')
setNotice(result.message); request.current = null
setRevision((value) => value + 1)
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your report.') }
finally { setBusy(false) }
}
return <section className="stats-panel report-email-panel" aria-label="Email your report">
<div className="stats-panel-heading"><h2>Email yourself this report</h2><a href="/profile#monthly-recaps">Email preferences</a></div>
<p>Choose a month above, including the current month so far, then send its viewing and request summary to your confirmed profile email.</p>
{data?.can_send ? <p><strong>{data.email}</strong> · One report email every five minutes.</p> : data && <p>{data.state === 'enabled' ? data.detail : 'Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails.'}</p>}
<button type="button" disabled={busy || !data?.can_send} onClick={() => void send()}>{busy ? 'Queueing report…' : 'Email this report'}</button>
{notice && <p role="status">{notice}</p>}
{error && <p role="alert">{error}</p>}
{!!data?.deliveries.length && <details><summary>Recent report emails</summary><ul>{data.deliveries.map((item) => <li key={item.id}><strong>{item.month}</strong> · {item.state === 'sent' ? 'Accepted by mail server' : item.state} {item.detail || 'Waiting for delivery'}</li>)}</ul></details>}
</section>
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
import EmailReportControl from './EmailReportControl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../../lib/auth'
import PageHeading from '../../ui/PageHeading'
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from '../components'
import '../stats.css'
import './reports.css'
type Change = { current: number; previous: number; difference: number; percent: number | null }
type MonthlyReport = Omit<Stats, 'days'> & {
month: string; available_months: string[]; is_partial: boolean; comparison_capped: boolean
period_start: string; period_end: string; comparison_month: string; comparison_start: string; comparison_end: string
previous_summary?: Stats['summary']; previous_requests?: Omit<Stats['requests'], 'recent'>
changes?: Record<'minutes' | 'movies' | 'episodes' | 'plays' | 'active_days' | 'longest_streak' | 'requests', Change>
}
const monthLabel = (month: string, short = false) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: short ? 'short' : 'long', year: 'numeric', timeZone: 'UTC' })
const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 })
function ChangeLabel({ change, unit = '' }: { change: Change; unit?: string }) {
const delta = change.difference
return <div className={`report-change ${delta > 0 ? 'is-up' : delta < 0 ? 'is-down' : 'is-flat'}`}>
<span>{delta === 0 ? 'No change' : `${delta > 0 ? '+' : ''}${decimal(Math.abs(delta))}${unit}${change.percent === null ? '' : ` (${delta > 0 ? '+' : ''}${decimal(Math.abs(change.percent))}%)`}`}</span>
<small>{change.percent === null ? 'No activity recorded in the comparison period' : `Previously ${decimal(change.previous)}${unit}`}</small>
</div>
}
export default function MonthlyReportsPage() {
const router = useRouter()
const [month, setMonth] = useState('')
const [monthReady, setMonthReady] = useState(false)
const [months, setMonths] = useState<string[]>([])
const [data, setData] = useState<MonthlyReport | null>(null)
const [busy, setBusy] = useState(true)
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const [downloading, setDownloading] = useState(false)
const [downloadError, setDownloadError] = useState('')
const downloadController = useRef<AbortController | null>(null)
useEffect(() => {
setMonth(new URLSearchParams(window.location.search).get('month') || '')
setMonthReady(true)
}, [])
useEffect(() => {
if (monthReady) window.history.replaceState(null, '', `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ''}`)
}, [month, monthReady])
useEffect(() => () => downloadController.current?.abort(), [])
const load = useCallback(async (signal: AbortSignal) => {
setBusy(true)
setError('')
setData(null)
setDownloadError('')
try {
const query = month ? `?month=${encodeURIComponent(month)}` : ''
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal })
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`); return }
if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.')
if (!response.ok) {
const result = await response.json().catch(() => ({}))
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your report is temporarily unavailable. Please try again shortly.')
}
const result = await response.json() as MonthlyReport
if (!signal.aborted) { setData(result); setMonths(result.available_months) }
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your report.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [month, router])
useEffect(() => {
if (!monthReady) return
const controller = new AbortController()
void load(controller.signal)
return () => controller.abort()
}, [load, revision, monthReady])
const download = async () => {
if (data?.state !== 'ready' || downloading) return
const selected = data.month
const controller = new AbortController()
downloadController.current = controller
setDownloading(true)
setDownloadError('')
try {
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal })
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`); return }
if (!response.ok) throw new Error('The report could not be downloaded. Please try again.')
const blob = await response.blob()
if (controller.signal.aborted) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `magent-monthly-report-${selected}.csv`
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (err) {
if (!controller.signal.aborted) setDownloadError(err instanceof Error ? err.message : 'Could not download your report.')
} finally {
if (!controller.signal.aborted) setDownloading(false)
}
}
const selectedMonth = month || data?.month || ''
const monthIndex = months.indexOf(selectedMonth)
const summary = data?.summary
const changes = data?.changes
return <main className="stats-page reports-page">
<PageHeading title="Monthly report" description="Your month in viewing. See what you watched, what changed, and what you requested." actions={<>
<button className="ghost-button" type="button" disabled={busy || downloading} onClick={() => setRevision((value) => value + 1)}>Refresh report</button>
<button className="ghost-button" type="button" disabled={busy || downloading || data?.state !== 'ready'} onClick={() => void download()}>{downloading ? 'Downloading…' : 'Download CSV'}</button>
</>} />
<StatsNavigation reports />
<div className="stats-toolbar">
<div className="report-month-picker">
<button type="button" className="ghost-button" aria-label="Previous month" disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1} onClick={() => setMonth(months[monthIndex + 1])}></button>
<label><span className="stats-sr-only">Report month</span><select value={selectedMonth} disabled={busy || downloading || !months.length} onChange={(event) => setMonth(event.target.value)}>{!selectedMonth && <option value="">Latest complete month</option>}{months.map((value, index) => <option value={value} key={value}>{monthLabel(value)}{index === 0 ? ' · month to date' : ''}</option>)}</select></label>
<button type="button" className="ghost-button" aria-label="Next month" disabled={busy || downloading || monthIndex <= 0} onClick={() => setMonth(months[monthIndex - 1])}></button>
</div>
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat · UTC</p>
</div>
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
{data?.state === 'ready' && !busy && <EmailReportControl month={data.month} />}
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous months comparison.</p></div>}
{error && <div className="stats-state" role="alert"><h2>Report couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button>{month && <button type="button" className="ghost-button" onClick={() => setMonth('')}>Latest complete month</button>}</div>}
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>}
{data && summary && changes && <>
<section className="report-intro" aria-label="Report period">
<div><span className="report-kicker">{data.is_partial ? 'Month to date' : 'Your monthly recap'}</span><h2>{monthLabel(data.month)}</h2><p>{data.is_partial ? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ', capped at the end of that month' : ''}.` : `Compared with ${monthLabel(data.comparison_month)}.`}</p></div>
<div className="report-period-meta"><span>{data.is_partial ? 'In progress' : 'Complete month'}</span><small>{data.updated_at && `Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC`}</small></div>
</section>
<section className="stats-metrics" aria-label="Monthly totals">
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays</small><ChangeLabel change={changes.minutes} unit=" min" /></article>
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small><ChangeLabel change={changes.movies} /></article>
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small><ChangeLabel change={changes.episodes} /></article>
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{data.requests.movies} movies · {data.requests.tv} TV requests</small><ChangeLabel change={changes.requests} /></article>
</section>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history was recorded for this month. Your request totals and comparison are still shown.</div>}
<div className="stats-main-grid">
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel report-highlights"><div className="stats-panel-heading"><h2>Your viewing habits</h2></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Days you watched</strong><p>At least one minute of viewing.</p><ChangeLabel change={changes.active_days} unit=" days" /></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Consecutive viewing days this month.</p><ChangeLabel change={changes.longest_streak} unit=" days" /></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>
</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>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
<div className="stats-main-grid">
<section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>A look back</h2><span className="stats-unit">Latest 20 plays this month</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded during this month will appear here.</p>}</section>
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in {monthLabel(data.month, true)}</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">No requests recorded during this month.</p>}<p className="stats-muted">Statuses reflect where these requests are now.</p></section>
</div>
<p className="stats-footnote">Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical totals can change when retained history or library metadata changes.</p>
</>}
</main>
}
+30
View File
@@ -0,0 +1,30 @@
.report-month-picker { display: flex; align-items: center; gap: 8px; min-width: 0; }
.report-month-picker > button { min-width: 38px; min-height: 42px; padding: 8px; }
.report-month-picker label { min-width: 0; }
.report-month-picker select { width: 100%; min-height: 42px; padding: 10px 32px 10px 14px; border: 1px solid var(--ops-line); border-radius: 8px; background: var(--ops-panel); color: var(--ops-text); font-size: 13px; }
.report-month-picker :disabled { opacity: .5; cursor: default; }
.report-intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 8px 0; }
.report-kicker { color: var(--ops-faint); font-size: 12px; }
.report-intro h2 { margin: 8px 0; font-size: clamp(24px, 3vw, 32px); color: var(--ops-text); }
.report-intro p { margin: 0; color: var(--ops-muted); font-size: 13px; line-height: 1.7; }
.report-period-meta { display: grid; justify-items: end; gap: 10px; text-align: right; }
.report-period-meta > span { padding: 6px 10px; border: 1px solid var(--ops-line); border-radius: 6px; color: #d1c6ff; font-size: 11px; white-space: nowrap; }
.report-period-meta small { color: var(--ops-faint); font-size: 11px; line-height: 1.6; }
.report-change { display: grid; gap: 5px; font-size: 12px; line-height: 1.6; }
.stats-metric > .report-change { padding-top: 12px; border-top: 1px solid var(--ops-line-soft); }
.report-change > span { color: var(--ops-muted); }
.report-change.is-up > span { color: #d1c6ff; }
.report-change small { color: var(--ops-faint); font-size: 11px; }
.report-highlights .report-change { margin-top: 8px; }
.report-top-titles li { grid-template-columns: minmax(0, 1fr) auto; }
.reports-page .stats-highlight { grid-template-columns: 85px minmax(0, 1fr); }
@media (max-width: 760px) {
.report-intro { align-items: start; flex-direction: column; gap: 16px; }
.report-period-meta { justify-items: start; text-align: left; }
.report-month-picker { width: 100%; }
.report-month-picker label { flex: 1; }
}
.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; }
+110
View File
@@ -0,0 +1,110 @@
.stats-page { padding-bottom: 32px !important; }
.stats-view-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--ops-line-soft); }
.stats-view-tabs a { padding: 0 0 14px; border-bottom: 2px solid transparent; color: var(--ops-muted); font-size: 13px; text-decoration: none; }
.stats-view-tabs a[aria-current=page] { border-color: #c7bdff; color: #d1c6ff; }
.stats-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.stats-period { display: flex; padding: 4px; margin: 0; min-width: 0; gap: 4px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); }
.stats-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.stats-period button { min-height: 38px; padding: 8px 16px; border: 0; border-radius: 6px; background: transparent !important; color: var(--ops-muted) !important; font-size: 13px; text-transform: none; }
.stats-period button[aria-pressed=true] { background: #c7bdff !important; color: #211a36 !important; font-weight: 700; }
.stats-source { margin: 0; font-size: 12px; color: var(--ops-muted); }
.stats-source-dot { display: inline-block; height: 6px; width: 6px; margin-right: 8px; border-radius: 50%; background: var(--ops-faint); }
.stats-source-dot.is-ready { background: #95d5b2; }
.stats-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.stats-metric { display: grid; align-content: start; gap: 12px; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-metric > span { font-size: 13px; color: var(--ops-muted); }
.stats-metric > strong { font: 600 clamp(28px, 3vw, 42px)/1.15 "DM Sans", sans-serif; color: var(--ops-text); letter-spacing: -.03em; }
.stats-metric > small { color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
.stats-metric-accent { border-color: #655987; background: linear-gradient(135deg, #2f2940, var(--ops-panel)); }
.stats-metric-accent > strong { color: #d5cbff; }
.stats-main-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 24px; }
.stats-three-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.stats-panel { min-width: 0; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 24px; }
.stats-panel h2 { margin: 0; color: var(--ops-text); font-size: 17px; font-weight: 600; }
.stats-panel-heading p { margin: 8px 0 0; color: var(--ops-faint); font-size: 12px; }
.stats-panel-heading a { font-size: 12px; white-space: nowrap; color: #c7bdff; }
.stats-unit { color: var(--ops-faint); font-size: 11px; white-space: nowrap; }
.stats-chart-detail { min-height: 28px; color: var(--ops-muted); font-size: 12px; }
.stats-chart { height: 180px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; margin-top: 12px; }
.stats-chart-scale { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-chart-bars { display: flex; align-items: stretch; gap: clamp(2px, .5vw, 8px); background: repeating-linear-gradient(to top, var(--ops-line-soft) 0px, var(--ops-line-soft) 1px, transparent 1px, transparent 50%); }
.stats-chart-bars button { display: flex; align-items: flex-end; justify-content: center; padding: 0; min-width: 0; flex: 1; border: 0; background: transparent !important; border-radius: 3px; }
.stats-chart-bars button > span { display: block; width: 100%; max-width: 44px; background: #9085b8; border-radius: 3px 3px 0 0; }
.stats-chart-bars button:is(:hover, :focus-visible, .is-selected) > span { background: #d1c6ff; }
.stats-chart-axis { display: flex; justify-content: space-between; padding-left: 50px; margin-top: 12px; color: var(--ops-faint); font-size: 11px; }
.stats-highlight { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: 16px; align-items: center; padding: 19px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-highlight:first-of-type { border-top: 0; }
.stats-highlight-number { font-size: 28px; color: #d1c6ff; font-weight: 600; }
.stats-highlight-number small { font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-highlight strong { font-size: 13px; color: var(--ops-text); }
.stats-highlight p { margin: 6px 0 0; color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
.stats-top-titles { list-style: none; margin: 0; padding: 0; display: grid; gap: 20px; }
.stats-top-titles li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.stats-rank { font: 11px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-top-titles strong { display: block; font-size: 13px; font-weight: 500; color: var(--ops-text); overflow-wrap: anywhere; }
.stats-top-titles small { display: block; margin-top: 5px; font-size: 11px; color: var(--ops-faint); }
.stats-top-titles li > span:last-child { text-align: right; font-size: 13px; color: var(--ops-muted); }
.stats-breakdown { display: grid; gap: 24px; }
.stats-breakdown-label { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 12px; }
.stats-breakdown-label span { color: var(--ops-muted); overflow-wrap: anywhere; }
.stats-breakdown-label strong { white-space: nowrap; font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-meter { height: 5px; background: var(--ops-line-soft); border-radius: 5px; overflow: hidden; }
.stats-meter > span { display: block; height: 100%; background: #a497c9; border-radius: 5px; }
.stats-history-list { display: grid; }
.stats-history-list article { display: flex; align-items: center; gap: 14px; padding: 15px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-history-list article:first-child { padding-top: 0; border-top: 0; }
.stats-media-icon { display: grid; place-items: center; flex: 0 0 44px; height: 66px; border-radius: 6px; overflow: hidden; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
.stats-media-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
.stats-transcoding { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--ops-line-soft); }
.stats-transcoding h3 { margin: 0 0 16px; color: var(--ops-text); font-size: 13px; font-weight: 500; }
.stats-transcode-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.stats-transcode-metrics > div { display: grid; align-content: start; gap: 8px; min-width: 0; }
.stats-transcode-metrics span { color: var(--ops-muted); font-size: 11px; }
.stats-transcode-metrics strong { color: #d1c6ff; font-size: 20px; font-weight: 500; overflow-wrap: anywhere; }
.stats-transcode-metrics small { color: var(--ops-faint); font-size: 10px; line-height: 1.7; overflow-wrap: anywhere; }
.stats-transcode-details { display: grid; gap: 10px; margin: 20px 0 12px; }
.stats-transcode-details > div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 11px; color: var(--ops-muted); }
.stats-transcode-details dd { margin: 0; white-space: nowrap; color: var(--ops-faint); }
.stats-transcoding > p { margin: 12px 0 0; font-size: 10px; }
.stats-media-icon-movie { background: #3c322c; color: #e5bfa8; }
.stats-history-title { flex: 1; min-width: 0; }
.stats-history-title strong { display: block; color: var(--ops-text); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
.stats-history-title small, .stats-history-title > span { display: block; color: var(--ops-faint); font-size: 11px; line-height: 1.6; margin-top: 3px; overflow-wrap: anywhere; }
.stats-history-title > span { font-size: 10px; }
.stats-history-time { display: grid; gap: 8px; text-align: right; flex-shrink: 0; }
.stats-history-time strong { font-size: 12px; color: var(--ops-muted); font-weight: 500; }
.stats-history-time time { font-size: 11px; color: var(--ops-faint); }
.stats-requests { align-self: start; }
.stats-request-total { display: flex; align-items: center; gap: 16px; }
.stats-request-total > strong { font-size: 36px; color: var(--ops-text); }
.stats-request-total > span { max-width: 15ch; color: var(--ops-muted); font-size: 12px; line-height: 1.6; }
.stats-request-counts { display: flex; justify-content: space-between; gap: 8px; padding: 20px 0; margin-top: 16px; border-block: 1px solid var(--ops-line-soft); }
.stats-request-counts > span { font-size: 11px; color: var(--ops-faint); }
.stats-request-counts strong { display: block; margin-bottom: 8px; color: var(--ops-text); font-size: 18px; font-weight: 500; }
.stats-request-list { list-style: none; margin: 10px 0 0; padding: 0; }
.stats-request-list a { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; color: var(--ops-muted); font-size: 12px; text-decoration: none; overflow-wrap: anywhere; }
.stats-request-list a:hover { color: #d1c6ff; }
.stats-request-list a > span { color: var(--ops-faint); }
.stats-state { display: grid; justify-items: center; gap: 14px; padding: 56px 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); text-align: center; }
.stats-state h2 { margin: 0; font-size: 22px; color: var(--ops-text); }
.stats-state p { margin: 0; max-width: 60ch; font-size: 14px; color: var(--ops-muted); line-height: 1.8; }
.stats-state-symbol { margin-bottom: 8px; color: #c7bdff; font-size: 36px; }
.stats-action { display: inline-block; margin-top: 8px; padding: 12px 20px; background: #c7bdff; color: #211a36; border-radius: 8px; font-size: 13px; font-weight: 600; text-decoration: none; }
.stats-notice { padding: 16px 20px; border: 1px solid var(--ops-line); border-radius: 8px; color: var(--ops-muted); font-size: 13px; line-height: 1.6; }
.stats-muted, .stats-footnote { color: var(--ops-faint); font-size: 12px; line-height: 1.8; }
.stats-footnote { margin: 0; }
@media (max-width: 1100px) {
.stats-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-main-grid { grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr); }
.stats-three-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-three-grid > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.stats-main-grid, .stats-three-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.stats-metric { padding: 18px; gap: 10px; }
.stats-panel { padding: 20px; }
.stats-period { width: 100%; }
.stats-period button { flex: 1; padding-inline: 8px; }
.stats-metrics { gap: 12px; }
}
+2 -1
View File
@@ -6,6 +6,7 @@ import './workspace.css'
import './portal/issue-flow.css'
import type { ReactNode } from 'react'
import BrandingFavicon from './ui/BrandingFavicon'
import FeatureGate from './ui/FeatureGate'
import ApplicationChrome from './ui/ApplicationChrome'
export const metadata = {
@@ -20,7 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<BrandingFavicon />
<div className="page">
<ApplicationChrome />
{children}
<FeatureGate>{children}</FeatureGate>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
export const FEATURES = [
{ key: 'stats', label: 'My Stats', description: 'View personal viewing history, reports and request report emails.' },
{ key: 'requests', label: 'My Requests', description: 'View existing requests, their progress and request actions.' },
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' },
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' },
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' },
] as const
export type Feature = typeof FEATURES[number]['key']
export type FeatureAccess = Record<Feature, boolean>
export function featureForPath(path: string): Feature | undefined {
if (path === '/insights' || path.startsWith('/insights/')) return 'stats'
if (path === '/' || path.startsWith('/requests/')) return 'requests'
if (path === '/new-requests') return 'new_requests'
if (path.startsWith('/issues/confirm/') || path.startsWith('/portal/issues')) return 'issues'
if (path.startsWith('/profile/invites')) return 'invites'
if (path === '/portal/requests') return 'requests'
}
export function canAccess(user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null, feature?: Feature) {
if (!feature) return true
if (!user) return false
if (user.role === 'admin') return true
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : true)
}
+4 -1
View File
@@ -71,7 +71,10 @@ export default function LoginPage() {
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie')
const next = new URLSearchParams(window.location.search).get('next') || ''
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
const allowedNext = ['/insights', '/insights/reports', '/profile', '/profile#monthly-recaps', '/profile#newsletters', '/admin/recaps', '/admin/newsletters'].includes(next)
|| /^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next)
|| /^\/issues\/confirm\/\d+$/.test(next)
window.location.assign(allowedNext ? next : '/welcome')
} catch {
setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) }
+42 -28
View File
@@ -28,6 +28,7 @@ type RequestOptions = {
episodeCount: number
airDate?: string | null
}>
originalLanguage?: { code: string } | null
existingRequestId?: number | null
}
destination: {
@@ -114,8 +115,8 @@ export default function NewRequestClient() {
const [selected, setSelected] = useState<DiscoveryResult | null>(null)
const [options, setOptions] = useState<RequestOptions | null>(null)
const [loadingOptions, setLoadingOptions] = useState(false)
const [profileId, setProfileId] = useState<number | null>(null)
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [operation, setOperation] = useState<OperationProgress | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -125,6 +126,22 @@ export default function NewRequestClient() {
if (!getToken()) router.push('/login')
}, [router])
const selectedTitleId = selected?.tmdbId
useEffect(() => {
if (selectedTitleId) configureSectionRef.current?.focus()
}, [selectedTitleId])
const changeTitle = () => {
setSelected(null)
setOptions(null)
setAcceptOriginalLanguage(false)
setSelectedSeasons([])
setOperation(null)
setError(null)
setSuccess(null)
window.requestAnimationFrame(() => document.getElementById('request-title-search')?.focus())
}
const resetAfterType = (nextType: MediaType) => {
setMediaType(nextType)
setQuery('')
@@ -132,7 +149,7 @@ export default function NewRequestClient() {
setSearchAttempted(false)
setSelected(null)
setOptions(null)
setProfileId(null)
setAcceptOriginalLanguage(false)
setSelectedSeasons([])
setOperation(null)
setError(null)
@@ -152,6 +169,7 @@ export default function NewRequestClient() {
setSearchAttempted(true)
setSelected(null)
setOptions(null)
setAcceptOriginalLanguage(false)
setOperation(null)
setError(null)
setSuccess(null)
@@ -194,13 +212,12 @@ export default function NewRequestClient() {
const selectResult = async (item: DiscoveryResult) => {
setSelected(item)
setOptions(null)
setProfileId(null)
setAcceptOriginalLanguage(false)
setSelectedSeasons([])
setOperation(null)
setError(null)
setSuccess(null)
if (item.requestId) {
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
return
}
@@ -234,13 +251,11 @@ export default function NewRequestClient() {
return
}
setOptions(payload)
setProfileId(payload.destination.defaultProfileId)
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
} finally {
setLoadingOptions(false)
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
}
}
@@ -254,7 +269,7 @@ export default function NewRequestClient() {
}
const submitRequest = async () => {
if (!selected || !options || !profileId) return
if (!selected || !options) return
if (selected.type === 'tv' && selectedSeasons.length === 0) {
setError('Select at least one season.')
return
@@ -276,7 +291,7 @@ export default function NewRequestClient() {
body: JSON.stringify({
mediaType: selected.type,
tmdbId: selected.tmdbId,
profileId,
acceptOriginalLanguage,
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
}),
})
@@ -337,7 +352,7 @@ export default function NewRequestClient() {
{error && <div className="error-banner request-flow-alert">{error}</div>}
{success && <div className="status-banner request-flow-alert">{success}</div>}
<section className="request-flow-stage is-current">
{!selected && <section className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">01</span>
<div><span>Start here</span><h2>What are you looking for?</h2></div>
@@ -365,9 +380,9 @@ export default function NewRequestClient() {
</button>
))}
</div>
</section>
</section>}
{mediaType && (
{mediaType && !selected && (
<section ref={searchSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">02</span>
@@ -389,7 +404,7 @@ export default function NewRequestClient() {
</section>
)}
{mediaType && searchAttempted && !searching && (
{mediaType && !selected && searchAttempted && !searching && (
<section ref={resultsSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">03</span>
@@ -404,12 +419,11 @@ export default function NewRequestClient() {
<div className="request-result-grid">
{results.map((item) => {
const poster = artworkUrl(item.posterPath)
const isSelected = selected?.tmdbId === item.tmdbId && selected.type === item.type
return (
<button
key={`${item.type}:${item.tmdbId}`}
type="button"
className={`request-result-card ${isSelected ? 'is-selected' : ''}`}
className="request-result-card"
onClick={() => void selectResult(item)}
>
<span className="request-result-poster">
@@ -419,7 +433,7 @@ export default function NewRequestClient() {
<small>{item.type === 'tv' ? 'TV show' : 'Movie'}{item.year ? ` · ${item.year}` : ''}</small>
<strong>{item.title}</strong>
<p>{item.overview || 'Select this title to view the available request options.'}</p>
<b>{item.requestId ? item.statusLabel || 'Already requested' : isSelected ? 'Selected' : 'Select title'}</b>
<b>{item.requestId ? item.statusLabel || 'Already requested' : 'Select title'}</b>
</span>
</button>
)
@@ -430,12 +444,14 @@ export default function NewRequestClient() {
)}
{selected && (
<section ref={configureSectionRef} className="request-flow-stage is-current request-configure-stage">
<section ref={configureSectionRef} tabIndex={-1} aria-labelledby="request-configure-title" className="request-flow-stage is-current request-configure-stage">
<div className="request-flow-heading">
<span className="request-flow-number">04</span>
<div><span>Final step</span><h2>{selected.requestId ? 'This title is already in the pipeline' : 'Configure your request'}</h2></div>
<div><span>Final step</span><h2 id="request-configure-title">{selected.requestId ? 'This title is already in the pipeline' : selected.type === 'tv' ? 'Choose seasons and request' : 'Review and request'}</h2></div>
</div>
<button type="button" className="ghost-button" onClick={changeTitle} disabled={loadingOptions || submitting}>Change title</button>
<div className="request-selection-summary">
<span className="request-selection-poster">
{selectedPoster ? <img src={selectedPoster} alt="" /> : <i>No artwork</i>}
@@ -453,7 +469,7 @@ export default function NewRequestClient() {
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Open request</button>
</div>
) : loadingOptions ? (
<div className="request-flow-empty"><strong>Checking Seerr and {selected.type === 'tv' ? 'Sonarr' : 'Radarr'}</strong><p>Loading valid profiles and request choices.</p></div>
<div className="request-flow-empty"><strong>Checking Seerr and {selected.type === 'tv' ? 'Sonarr' : 'Radarr'}</strong><p>Preparing your request options.</p></div>
) : options ? (
<div className="request-options-layout">
{selected.type === 'tv' && (
@@ -480,17 +496,15 @@ export default function NewRequestClient() {
</fieldset>
)}
<label className="request-profile-field">
<span>Quality profile</span>
<select value={profileId ?? ''} onChange={(event) => setProfileId(Number(event.target.value))}>
{options.destination.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
<small>Live options from {options.destination.collector}. Seerr will use {options.destination.serverName}.</small>
</label>
{options.media.originalLanguage && <div className="request-language-notice">
<h3>Check the audio language</h3>
<p>This titles original language is <strong>{new Intl.DisplayNames(['en'], { type: 'language' }).of(options.media.originalLanguage.code) || options.media.originalLanguage.code}</strong>. An English audio track may not be available. Title metadata does not confirm the audio or subtitles in a download.</p>
<label><input type="checkbox" checked={acceptOriginalLanguage} onChange={(event) => setAcceptOriginalLanguage(event.target.checked)} disabled={submitting} /><span>Im happy to watch in the original language.</span></label>
<small>{acceptOriginalLanguage ? (selected.type === 'movie' ? 'Search for original-language audio using the same quality requirements.' : 'Continue with your selected seasons and the configured TV quality requirements.') : 'Leave this unchecked to keep the standard request settings. An English-only profile may leave this title waiting for a suitable release.'}</small>
</div>}
<div className="request-submit-bar">
<div><span>Delivery route</span><strong>Seerr {options.destination.collector} Grizzlyflix</strong><small>Only settings currently accepted by {options.destination.collector} are available.</small></div>
<button type="button" onClick={() => void submitRequest()} disabled={submitting || !profileId || (selected.type === 'tv' && selectedSeasons.length === 0)}>
<div><span>Delivery route</span><strong>Seerr {options.destination.collector} Grizzlyflix</strong><small>Your request uses the default quality set by your administrator.</small></div>
<button type="button" onClick={() => void submitRequest()} disabled={submitting || (selected.type === 'tv' && selectedSeasons.length === 0)}>
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
</button>
</div>
@@ -0,0 +1,70 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { getApiBase } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
import '../email-recaps/recaps.css'
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string }
export default function NewsletterLinkPage() {
const [link, setLink] = useState<LinkAction | null>(null)
const [state, setState] = useState('loading')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const currentLink = useRef<LinkAction | null>(null)
useEffect(() => {
let controller: AbortController | null = null
const checkLink = () => {
controller?.abort()
const abort = new AbortController()
controller = abort
setError(''); setState('loading'); setLink(null); setBusy(false); currentLink.current = null
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
const params = new URLSearchParams(window.location.hash.slice(1))
const action = params.get('action')
const token = params.get('token') || ''
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
setError('This email link is incomplete. Open Profile to manage your newsletters.'); setState('error'); return
}
const payload = { action, token } as LinkAction
currentLink.current = payload
setLink(payload)
void fetch(`${getApiBase()}/newsletter-subscription/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => {
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.')
if (!abort.signal.aborted) setState(result.state)
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } })
}
checkLink()
window.addEventListener('hashchange', checkLink)
return () => { currentLink.current = null; controller?.abort(); window.removeEventListener('hashchange', checkLink) }
}, [])
const apply = async () => {
if (!link || busy) return
const payload = link
setBusy(true); setError('')
try {
const response = await fetch(`${getApiBase()}/newsletter-subscription/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'omit' })
const result = await response.json().catch(() => ({}))
if (currentLink.current !== payload) return
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.')
setState(result.state)
window.history.replaceState(null, '', '/newsletter-subscription')
} catch (err) { if (currentLink.current === payload) setError(err instanceof Error ? err.message : 'Could not update your preference.') }
finally { if (currentLink.current === payload) setBusy(false) }
}
const done = state === 'enabled' || state === 'off'
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel">
<span className="recap-eyebrow">Grizzlyflix newsletters</span>
<h1>{state === 'enabled' ? 'Youre on the list.' : state === 'off' ? 'Newsletters are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from newsletters?' : 'Your next watch starts here.'}</h1>
<p>{state === 'enabled' ? 'Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You wont receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off new-arrival newsletters. Your personal monthly recaps are managed separately.' : state === 'ready' ? 'Confirm to receive new movies, TV updates and featured picks, with posters and links to watch.' : ''}</p>
{error && <p className="account-notice is-error" role="alert">{error}</p>}
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from newsletters' : 'Confirm newsletter subscription'}</button>}
{(done || state === 'error') && <a className="recap-text-link" href="/profile#newsletters">Manage email preferences </a>}
{state === 'loading' && <p role="status">One moment</p>}
</section></main>
}
-7
View File
@@ -242,11 +242,6 @@ a {
box-shadow: none;
}
.header-actions a span {
color: var(--ops-cyan);
font-size: 0.68rem;
}
.header-actions a:hover,
.header-actions a.is-active {
border-color: rgba(126, 215, 255, 0.22);
@@ -2861,14 +2856,12 @@ textarea {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.76rem;
}
.header-actions a span { color: var(--ops-faint); font-size: 0.68rem; }
.header-actions a:hover,
.header-actions a.is-active {
border-color: var(--ops-primary-2);
background: transparent;
color: var(--ops-primary-2);
}
.header-actions a.is-active span { color: var(--ops-primary-2); }
.user-view-toggle {
min-height: 32px;
border-color: var(--ops-line);
+1 -1
View File
@@ -618,7 +618,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const loadOverview = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/overview`)
const response = await authFetch(`${baseUrl}/portal/overview?kind=${workspace}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
@@ -0,0 +1,83 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../lib/auth'
import '../email-recaps/recaps.css'
type Preference = { automatic_monthly: boolean; state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; day: number; hour: number; resend_after: number | null }
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC`
export default function MonthlyRecapPreference() {
const router = useRouter()
const [data, setData] = useState<Preference | null>(null)
const [automatic, setAutomatic] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [revision, setRevision] = useState(0)
const [now, setNow] = useState(Date.now())
useEffect(() => {
const abort = new AbortController()
setError('')
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => {
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
const result = await response.json() as Preference
if (!abort.signal.aborted) { setData(result); setAutomatic(result.automatic_monthly) }
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => abort.abort()
}, [revision, router])
useEffect(() => {
if (!data?.resend_after || data.state === 'enabled' || data.resend_after * 1000 <= Date.now()) return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [data?.resend_after, data?.state])
const save = async (enabled: boolean, monthly = automatic) => {
if (busy) return
setBusy(true); setError(''); setNotice('')
try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, automatic_monthly: monthly }),
})
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
setData(result); setAutomatic(result.automatic_monthly); setNow(Date.now())
setNotice(result.message || (enabled ? 'Personal report emails are enabled.' : 'Personal report emails are off.'))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
// A confirmation may be pending even if SMTP could not confirm delivery.
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null)
if (response?.ok) { const fresh = await response.json(); setData(fresh); setAutomatic(fresh.automatic_monthly); setNow(Date.now()) }
} finally { setBusy(false) }
}
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0
return <section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
<div className="recap-section-heading"><div><span className="recap-eyebrow">A little look back</span><h2 id="recap-preference-title">Your reports, your choice.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Email confirmed' })[data.state]}</span>}</div>
<p>Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and decide whether you also want automatic monthly emails. <a href="/insights/reports">Explore your latest report </a></p>
{!data && !error && <p role="status">Loading your email preference</p>}
{data && <>
{data.state === 'enabled' ? <p className="recap-delivery-address">Recaps will go to <strong>{data.email}</strong>. {!data.automatic_monthly ? 'On demand only: choose a month in Reports and email it whenever you want.' : data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}</p> : <p>{data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on your recaps.' : 'Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile.'}</p>}
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>}
{data.state !== 'enabled' && data.can_subscribe && automatic && !data.schedule_enabled && <p className="recap-muted">You can subscribe now. Monthly sends will begin when your administrator starts the schedule.</p>}
<label className="recap-delivery-choice">Delivery preference<select value={automatic ? 'monthly' : 'manual'} disabled={busy || data.state === 'pending'} onChange={(event) => {
const monthly = event.target.value === 'monthly'
setAutomatic(monthly)
if (data.state === 'enabled') void save(true, monthly)
}}><option value="manual">On demand only</option><option value="monthly">On demand + automatic monthly emails</option></select></label>
<div className="recap-actions">
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Confirm my email for reports' : 'Send a new confirmation'}</button>}
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off report emails' : 'Cancel subscription'}</button>}
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh preference</button>
</div>
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>}
</>}
{error && <p className="account-notice is-error" role="alert">{error}{!data && <button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>Try again</button>}</p>}
{notice && <p className="account-notice is-status" role="status">{notice}</p>}
</section>
}
@@ -0,0 +1,77 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../lib/auth'
import '../email-recaps/recaps.css'
type Preference = { state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; weekday: number; hour: number; resend_after: number | null }
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC`
export default function NewsletterPreference() {
const router = useRouter()
const [data, setData] = useState<Preference | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [revision, setRevision] = useState(0)
const [now, setNow] = useState(Date.now())
useEffect(() => {
const abort = new AbortController()
setError('')
void authFetch(`${getApiBase()}/profile/newsletters`, { signal: abort.signal }).then(async (response) => {
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
const result = await response.json() as Preference
if (!abort.signal.aborted) setData(result)
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
return () => abort.abort()
}, [revision, router])
useEffect(() => {
if (!data?.resend_after || data.state === 'enabled' || data.resend_after * 1000 <= Date.now()) return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [data?.resend_after, data?.state])
const save = async (enabled: boolean) => {
if (busy) return
setBusy(true); setError(''); setNotice('')
try {
const response = await authFetch(`${getApiBase()}/profile/newsletters`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }),
})
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
const result = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
setData(result); setNow(Date.now())
setNotice(result.message || (enabled ? 'Newsletters are on.' : 'Newsletters are off.'))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
// A confirmation may be pending even if SMTP could not confirm delivery.
const response = await authFetch(`${getApiBase()}/profile/newsletters`).catch(() => null)
if (response?.ok) { setData(await response.json()); setNow(Date.now()) }
} finally { setBusy(false) }
}
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0
return <section className="recap-preference" id="newsletters" aria-labelledby="newsletter-preference-title">
<div className="recap-section-heading"><div><span className="recap-eyebrow">Your next watch</span><h2 id="newsletter-preference-title">New on Grizzlyflix.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Subscribed' })[data.state]}</span>}</div>
<p>Your minutes, movies, episodes, longest run and requests, in one personal monthly email. <a href="/insights/reports">Explore your latest report </a></p>
{!data && !error && <p role="status">Loading your email preference</p>}
{data && <>
{data.state === 'enabled' ? <p className="recap-delivery-address">Newsletters will go to <strong>{data.email}</strong>. {data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'Weekly sending is paused. You may still receive editions scheduled by your administrator.'}</p> : <p>{data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. Your newsletter subscription starts after you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on newsletters.' : 'Subscribe with your profile email. If you already confirmed it for monthly recaps, you can turn this on straight away. Otherwise, we?ll send a confirmation link.'}</p>}
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>}
{data.state !== 'enabled' && data.can_subscribe && !data.schedule_enabled && <p className="recap-muted">You can subscribe now, ready for the next edition your administrator sends.</p>}
<div className="recap-actions">
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Email me new arrivals' : 'Resend newsletter confirmation'}</button>}
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off newsletters' : 'Cancel newsletter subscription'}</button>}
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh newsletter preference</button>
</div>
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>}
</>}
{error && <p className="account-notice is-error" role="alert">{error}{!data && <button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>Try again</button>}</p>}
{notice && <p className="account-notice is-status" role="status">{notice}</p>}
</section>
}
+9 -3
View File
@@ -1,12 +1,16 @@
'use client'
import { canAccess, type FeatureAccess } from '../lib/features'
import PageHeading from '../ui/PageHeading'
import MonthlyRecapPreference from './MonthlyRecapPreference'
import NewsletterPreference from './NewsletterPreference'
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = {
features?: FeatureAccess
username: string
email?: string | null
role: string
@@ -68,12 +72,12 @@ export default function ProfilePage() {
const [showAllActivity, setShowAllActivity] = useState(false)
const loadProfile = useCallback(async () => {
if (!getToken()) { router.replace('/login'); return }
if (!getToken()) { router.replace('/login?next=%2Fprofile'); return }
setLoading(true)
setLoadError('')
try {
const response = await authFetch(`${getApiBase()}/auth/profile`)
if (response.status === 401) { clearToken(); router.replace('/login'); return }
if (response.status === 401) { clearToken(); router.replace('/login?next=%2Fprofile'); return }
if (!response.ok) throw new Error('Could not load your profile. Please try again.')
const profile = await response.json() as ProfileResponse
setData(profile)
@@ -119,7 +123,7 @@ export default function ProfilePage() {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() || null }),
})
if (response.status === 401) { clearToken(); router.replace('/login'); return }
if (response.status === 401) { clearToken(); router.replace('/login?next=%2Fprofile'); return }
if (!response.ok) throw new Error(await responseMessage(response, 'Could not save your email. Please try again.'))
const result = await response.json()
const saved = typeof result.email === 'string' ? result.email : ''
@@ -197,6 +201,8 @@ export default function ProfilePage() {
</div>
</form>
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
{canAccess(user, 'stats') && <MonthlyRecapPreference key={user.email || 'no-email'} />}
<NewsletterPreference key={`newsletter-${user.email || 'no-email'}`} />
</section>
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
+1 -1
View File
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
export default function ApplicationChrome() {
const pathname = usePathname()
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup', '/email-recaps', '/newsletter-subscription'].includes(pathname)) return null
return <>
<header className="header">
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
+37
View File
@@ -0,0 +1,37 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState, type ReactNode } from 'react'
import { authFetch, getApiBase, getToken } from '../lib/auth'
import { canAccess, featureForPath, type FeatureAccess } from '../lib/features'
export function useFeatureUser() {
const pathname = usePathname()
const [state, setState] = useState<{ path: string; user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null }>({ path: '', user: null })
useEffect(() => {
let active = true
const load = async () => {
if (!getToken()) { if (active) setState({ path: pathname, user: null }); return }
try {
const response = await authFetch(`${getApiBase()}/auth/me`)
const user = response.ok ? await response.json() : null
if (active) setState({ path: pathname, user })
} catch { if (active) setState({ path: pathname, user: null }) }
}
void load()
window.addEventListener('focus', load)
return () => { active = false; window.removeEventListener('focus', load) }
}, [pathname])
return { user: state.user, ready: state.path === pathname }
}
export default function FeatureGate({ children }: { children: ReactNode }) {
const pathname = usePathname()
const { user, ready } = useFeatureUser()
const feature = featureForPath(pathname)
if (!feature) return children
if (!ready) return <main className="card">Loading account access...</main>
if (!getToken()) return children
if (!canAccess(user, feature)) return <main className="card"><h1>Feature unavailable</h1><p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p><a href="/profile">Go to my profile</a></main>
return children
}
+13 -48
View File
@@ -1,54 +1,15 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { canAccess, featureForPath } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
const [showRequestsNav, setShowRequestsNav] = useState(true)
const pathname = usePathname()
useEffect(() => {
const token = getToken()
setSignedIn(Boolean(token))
if (!token) {
setShowRequestsNav(true)
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const [response, siteResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
fetch(`${baseUrl}/site/public`).catch(() => null),
])
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
const data = await response.json()
setRole(data?.role ?? null)
if (siteResponse?.ok) {
const siteData = await siteResponse.json()
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
} else {
setShowRequestsNav(true)
}
} catch (err) {
console.error(err)
setShowRequestsNav(true)
}
}
void load()
}, [])
if (!signedIn) {
return null
}
const { user, ready } = useFeatureUser()
const role = user?.role ?? null
const showRequestsNav = canAccess(user, 'new_requests')
if (!ready || !user) return null
const roleItems =
role === null
@@ -75,6 +36,11 @@ export default function HeaderActions() {
]
const commonItems = [
{
href: '/insights',
label: 'My Stats',
match: (path: string) => path === '/insights' || path.startsWith('/insights/'),
},
{
href: '/',
label: 'My Requests',
@@ -99,15 +65,14 @@ export default function HeaderActions() {
const items = [
...commonItems,
...roleItems,
]
].filter((item) => canAccess(user, featureForPath(item.href)))
return (
<nav className="header-actions" aria-label="Primary">
{items.map((item, index) => {
{items.map((item) => {
const active = item.match(pathname)
return (
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
{item.label}
</a>
)
-3
View File
@@ -103,9 +103,6 @@ export default function HeaderIdentity() {
Settings
</a>
) : null}
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
Sign out
</button>
+10 -28
View File
@@ -1,19 +1,21 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken } from '../lib/auth'
import { getToken } from '../lib/auth'
import { canAccess, featureForPath } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
type NavigationItem = {
href: string
label: string
shortLabel: string
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings'
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats'
adminOnly?: boolean
match: (path: string) => boolean
}
const NAVIGATION: NavigationItem[] = [
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/insights/') },
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
@@ -25,6 +27,7 @@ const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
const paths: Record<NavigationItem['icon'], React.ReactNode> = {
stats: <><path d="M4 20h16M6 16v-5m6 5V4m6 12V8" /></>,
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
@@ -36,43 +39,22 @@ function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
export default function WorkspaceNavigation() {
const pathname = usePathname()
const [role, setRole] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [showRequestsNav, setShowRequestsNav] = useState(true)
useEffect(() => {
const token = getToken()
if (!token) {
setReady(true)
return
}
Promise.all([
authFetch(`${getApiBase()}/auth/me`),
fetch(`${getApiBase()}/site/public`).catch(() => null),
])
.then(async ([response, siteResponse]) => {
if (response.ok) setRole((await response.json())?.role ?? 'user')
if (siteResponse?.ok) setShowRequestsNav((await siteResponse.json())?.navigation?.showRequests !== false)
})
.catch(() => undefined)
.finally(() => setReady(true))
}, [])
const { user, ready } = useFeatureUser()
const role = user?.role
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
return null
}
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && (showRequestsNav || item.href !== '/new-requests'))
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && canAccess(user, featureForPath(item.href)))
return (
<>
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.slice(0, 5).map((item) => (
{items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
</a>
))}
</nav>
</>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase } from '../lib/auth'
import { FEATURES, type Feature, type FeatureAccess } from '../lib/features'
type Account = { username: string; role: string; features: FeatureAccess }
export default function FeatureControls({ username, onSaved }: { username?: string; onSaved: () => void }) {
const [accounts, setAccounts] = useState<Account[] | null>(null)
const [changes, setChanges] = useState<Partial<FeatureAccess>>({})
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const load = async () => {
const response = await authFetch(`${getApiBase()}/admin/users/${username ? encodeURIComponent(username) : 'summary'}`)
if (!response.ok) throw new Error('Could not load feature permissions.')
const data = await response.json()
setAccounts(username ? [data.user] : data.users.filter((user: Account) => user.role !== 'admin'))
}
useEffect(() => { void load().catch((err) => setError(err.message)) }, [username])
const save = async () => {
setBusy(true); setError(''); setMessage('')
try {
const response = await authFetch(`${getApiBase()}/admin/users/${username ? `${encodeURIComponent(username)}/features` : 'features/bulk'}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes),
})
if (!response.ok) throw new Error((await response.json()).detail || 'Could not save permissions.')
const result = await response.json()
setChanges({})
setMessage(username ? 'Feature access saved.' : `Feature access saved for ${result.updated} non-admin accounts.`)
await load(); onSaved()
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save permissions.') }
finally { setBusy(false) }
}
const admin = accounts?.some((account) => account.role === 'admin')
return <section className="user-management-panel feature-controls">
<h3>Feature access</h3>
<p>{username ? 'Choose which features this person can use in Magent.' : 'Apply feature access to every existing non-admin account, including users outside the current search. Only the checkboxes you change will be applied.'}</p>
<p>{admin ? 'Administrators always have access to all features.' : 'Changes take effect on the next page or API request. These permissions control Magent access; linked services keep their own permissions.'}</p>
{!accounts && !error && <p>Loading permissions...</p>}
{FEATURES.map(({ key, label, description }) => {
const enabled = accounts?.filter((account) => account.features?.[key]).length ?? 0
const mixed = !!accounts?.length && enabled > 0 && enabled < accounts.length
const changed = Object.hasOwn(changes, key)
return <label key={key} className="feature-access-row">
<input type="checkbox" ref={(input) => { if (input) input.indeterminate = !changed && mixed }}
checked={changes[key] ?? (!!accounts?.length && enabled === accounts.length)}
disabled={busy || !accounts?.length || admin}
onChange={(event) => setChanges((previous) => ({ ...previous, [key as Feature]: event.target.checked }))} />
<span><strong>{label}</strong><small>{description}</small>{!username && <small>{enabled} of {accounts?.length ?? 0} enabled{mixed && !changed ? ' · Mixed access' : ''}{changed ? ` · Will ${changes[key] ? 'enable' : 'disable'} for everyone` : ''}</small>}</span>
</label>
})}
{error && <p className="error-banner" role="alert">{error}</p>}
{message && <p className="status-banner" role="status">{message}</p>}
<div className="admin-inline-actions"><button type="button" disabled={busy || !Object.keys(changes).length} onClick={() => void save()}>{busy ? 'Saving...' : username ? 'Save feature access' : 'Apply changed features to all users'}</button><button type="button" className="ghost-button" disabled={busy || !Object.keys(changes).length} onClick={() => setChanges({})}>Reset changes</button></div>
</section>
}
+71 -74
View File
@@ -1,8 +1,10 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import FeatureControls from '../FeatureControls'
import '../users.css'
import AdminShell from '../../ui/AdminShell'
type UserStats = {
@@ -91,6 +93,22 @@ const normalizeStats = (stats: any): UserStats => ({
})
export default function UserDetailPage() {
const [manageOpen, setManageOpen] = useState(false)
const managementDialog = useRef<HTMLDialogElement>(null)
const manageTrigger = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!manageOpen) return
const dialog = managementDialog.current
if (!dialog) return
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialog.showModal()
return () => {
dialog.close()
document.body.style.overflow = overflow
manageTrigger.current?.focus()
}
}, [manageOpen])
const params = useParams()
const router = useRouter()
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
@@ -286,30 +304,6 @@ export default function UserDetailPage() {
}
}
const updateInviteManagementEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
} catch (err) {
console.error(err)
setError('Could not update invite access.')
}
}
const applyProfileToUser = async (profileOverride?: string | null) => {
if (!user) return
const profileValue = profileOverride ?? profileSelection
@@ -408,13 +402,13 @@ export default function UserDetailPage() {
if (!user) return
if (action === 'remove') {
const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.`
`Permanently delete ${user.username} from Magent, the same-name Jellyfin account and linked Seerr account, disable their invitations and attempt a notification email? This cannot be undone. Media files and Jellystat history are kept.`
)
if (!confirmed) return
}
if (action === 'ban') {
const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?`
`Block ${user.username} in Magent, disable their same-name Jellyfin account and issued invitations, and attempt a notification email? Seerr relies on Jellyfin sign-in and is not directly banned.`
)
if (!confirmed) return
}
@@ -475,9 +469,8 @@ export default function UserDetailPage() {
title={user?.username || 'User'}
subtitle="User overview and request stats."
actions={
<button type="button" onClick={() => router.push('/users')}>
Back to users
</button>
<><button type="button" onClick={() => router.push('/users')}>Back to users</button>
<button ref={manageTrigger} type="button" disabled={!user} aria-haspopup="dialog" onClick={() => setManageOpen(true)}>Manage this user</button></>
}
>
<section className="admin-section">
@@ -486,7 +479,7 @@ export default function UserDetailPage() {
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<div className="user-detail-page-grid">
<div className="user-detail-page-grid user-detail-centered">
<div className="user-detail-main-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
@@ -510,7 +503,7 @@ export default function UserDetailPage() {
</div>
<div className="user-detail-meta-item">
<span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
<strong>{user.jellyseerr_user_id ?? 'Not linked'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Role</span>
@@ -589,7 +582,14 @@ export default function UserDetailPage() {
</div>
</div>
<div className="user-detail-side-column">
<dialog ref={managementDialog} className="user-management-dialog" aria-labelledby="manage-this-user-title" onCancel={() => setManageOpen(false)} onClose={() => setManageOpen(false)}>
<div className="user-management-content">
<header className="user-management-heading"><div><h2 id="manage-this-user-title">Manage {user.username}</h2><p>Feature access, account settings and account restrictions.</p></div><button type="button" className="ghost-button" onClick={() => setManageOpen(false)}>Close</button></header>
{error && <p className="error-banner" role="alert">{error}</p>}
{actionStatus && <p className="status-banner" role="status">{actionStatus}</p>}
<p><a className="ghost-button" href={`/users?view=identities&user=${encodeURIComponent(user.username)}`}>Review service links &amp; duplicate accounts</a></p>
{manageOpen && <FeatureControls key={user.role} username={user.username} onSaved={() => void loadUser()} />}
<div className="user-management-grid">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Contact email</h2>
@@ -660,48 +660,9 @@ export default function UserDetailPage() {
/>
<span>Allow auto search/download</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.invite_management_enabled ?? false)}
disabled={user.role === 'admin'}
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
/>
<span>Allow self-service invites</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download and invite-management access.
Admins always have automatic search/download and all features.
</div>
)}
</div>
@@ -778,7 +739,43 @@ export default function UserDetailPage() {
</div>
</div>
</div>
</div>
</div>
<section className="user-management-panel user-management-danger"><h3>Restrict access or delete accounts</h3><p>Blocking Magent prevents sign-in here and keeps the account. It does not block Jellyfin or Seerr.</p>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy || user.role === 'admin'}
>
{user.is_blocked ? 'Restore Magent access' : 'Block Magent access'}
</button>
<p>Disable access also disables invitations this user created and attempts an account notification email. Jellyfin is matched by username. Seerr relies on Jellyfin sign-in; its account is not directly banned. Restoring access does not reactivate invitations.</p>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy || user.role === 'admin'}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Restore Magent and Jellyfin access'
: 'Disable Magent and Jellyfin access'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy || user.role === 'admin'}
>
Delete Magent, Jellyfin and Seerr accounts
</button>
</div>
<p>Deletion removes the Magent account and local login activity, attempts to delete the same-name Jellyfin account and linked Seerr account, and disables issued invitations. It cannot be undone here. Media files and Jellystat history are not deleted. External actions can partially fail.</p>
</section>
</div>
</dialog>
</div>
)}
</section>
+72 -116
View File
@@ -1,10 +1,13 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
import './users.css'
import FeatureControls from './FeatureControls'
import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel'
type AdminUser = {
id: number
@@ -81,6 +84,22 @@ const normalizeStats = (stats: any): UserStats => ({
export default function UsersPage() {
const router = useRouter()
const [view, setView] = useState('directory')
useEffect(() => {
const update = () => setView(new URLSearchParams(window.location.search).get('view') === 'identities' ? 'identities' : 'directory')
update()
window.addEventListener('popstate', update)
return () => window.removeEventListener('popstate', update)
}, [])
const changeView = (next: string) => {
setView(next)
window.history.pushState(null, '', next === 'identities' ? '/users?view=identities' : '/users')
}
const [controlsOpen, setControlsOpen] = useState(false)
const controlsDialog = useRef<HTMLDialogElement>(null)
const controlsTrigger = useRef<HTMLButtonElement>(null)
const controlsClose = useRef<HTMLButtonElement>(null)
const [refreshing, setRefreshing] = useState(false)
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -89,10 +108,9 @@ export default function UsersPage() {
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const [bulkInvitesBusy, setBulkInvitesBusy] = useState(false)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const loadUsers = async () => {
setRefreshing(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/summary`)
@@ -139,6 +157,7 @@ export default function UsersPage() {
setError('Could not load user list.')
} finally {
setLoading(false)
setRefreshing(false)
}
}
@@ -169,7 +188,7 @@ export default function UsersPage() {
const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Seerr. Continue?'
'Rebuild the Magent directory from Seerr? This deletes all existing non-admin Magent accounts and creates accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Continue?'
)
if (!confirmed) return
setJellyseerrSyncStatus(null)
@@ -196,25 +215,6 @@ export default function UsersPage() {
}
}
const enableInvitesForEveryone = async () => {
if (bulkInvitesBusy || !window.confirm('Enable invite access for all existing non-admin users, including users outside the current search? Existing invite limits, account blocks and expiry dates will not change.')) return
setBulkInvitesBusy(true)
setInviteStatus(null)
try {
const response = await authFetch(`${getApiBase()}/admin/users/invite-access/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }),
})
if (!response.ok) throw new Error('Invite access update failed')
const data = await response.json()
setInviteStatus(`Invite access enabled for ${data.updated ?? 0} non-admin accounts. Existing limits are unchanged.`)
await loadUsers()
} catch {
setInviteStatus('Could not enable invites. Please reload the list to check the current permissions, then try again.')
} finally { setBulkInvitesBusy(false) }
}
const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null)
@@ -250,13 +250,28 @@ export default function UsersPage() {
void loadUsers()
}, [router])
useEffect(() => {
if (!controlsOpen) return
const dialog = controlsDialog.current
dialog?.showModal()
controlsClose.current?.focus()
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
dialog?.close()
document.body.style.overflow = previous
controlsTrigger.current?.focus()
}
}, [controlsOpen])
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy
if (loading) {
return <main className="card">Loading users...</main>
}
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
const inviteEnabledCount = nonAdminUsers.filter((user) => user.inviteManagementEnabled).length
const blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length
@@ -326,103 +341,44 @@ export default function UsersPage() {
return (
<AdminShell
title="Users"
subtitle="Directory, access status, and request activity."
rail={usersRail}
title="User management"
subtitle="Accounts, access, request activity and verified service links."
actions={<button ref={controlsTrigger} type="button" className="ghost-button" aria-haspopup="dialog" aria-expanded={controlsOpen} aria-controls="user-management-dialog" onClick={() => setControlsOpen(true)}>Manage users</button>}
>
<section className="admin-section">
<div className="admin-panel users-page-toolbar">
<div className="users-page-toolbar-grid">
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Directory actions</span>
<div className="users-page-toolbar-actions">
<button
type="button"
className="ghost-button"
onClick={() => router.push('/admin/invites')}
>
Invite management
</button>
<button type="button" onClick={loadUsers}>
Reload list
</button>
</div>
</div>
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Seerr sync</span>
<div className="users-page-toolbar-actions">
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Seerr users...' : 'Sync Seerr users'}
</button>
<button
type="button"
onClick={resyncJellyseerrUsers}
disabled={jellyseerrResyncBusy}
>
{jellyseerrResyncBusy ? 'Resyncing Seerr users...' : 'Resync Seerr users'}
</button>
</div>
</div>
<dialog id="user-management-dialog" ref={controlsDialog} className="user-management-dialog" aria-labelledby="user-management-title" onCancel={() => setControlsOpen(false)} onClose={() => setControlsOpen(false)}>
<div className="user-management-content">
<header className="user-management-heading"><div><span className="users-page-toolbar-label">Directory tools</span><h2 id="user-management-title">Manage users</h2><p>Account links, service sync and permissions for your user directory.</p></div><button ref={controlsClose} type="button" className="ghost-button" onClick={() => setControlsOpen(false)}>Close</button></header>
{error && <p className="error-banner" role="alert">{error}</p>}
{jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
<div className="user-management-grid">
<section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p>
<div className="user-management-action"><Link className="ghost-button" href="/users?view=identities" aria-describedby="identity-help">Review account links </Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div>
<div className="user-management-action"><Link className="ghost-button" href="/admin/invites" aria-describedby="invitation-help">Manage invitations </Link><p id="invitation-help">Create invitations, review issued links and set invitation defaults.</p></div>
<div className="user-management-action"><button type="button" className="ghost-button" onClick={() => void loadUsers()} disabled={controlsBusy} aria-describedby="reload-help">{refreshing ? 'Refreshing…' : 'Refresh user list'}</button><p id="reload-help">Reload account status and request totals from Magent. Your search stays in place.</p></div>
</section>
<section className="user-management-panel"><h3>Seerr sync</h3><p>Connect existing Magent accounts to their Seerr request accounts.</p>
<div className="user-management-action"><button type="button" onClick={() => void syncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="sync-help">{jellyseerrSyncBusy ? 'Matching accounts…' : 'Match unlinked Seerr accounts'}</button><p id="sync-help">Match users without a saved Seerr ID by their account name, then copy the matching Seerr ID and available email into Magent. Already-linked users are skipped.</p></div>
<details className="user-management-advanced"><summary>Advanced: rebuild from Seerr</summary><p id="resync-help">Deletes all non-admin Magent accounts, then imports accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Use this only when you intend to replace the directory.</p><button type="button" className="ghost-button" onClick={() => void resyncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="resync-help">{jellyseerrResyncBusy ? 'Rebuilding directory…' : 'Rebuild directory from Seerr'}</button></details>
</section>
<section className="user-management-panel"><h3>Automatic search &amp; download</h3><p>Allow users to trigger automatic searches and downloads for their requests.</p><span className="user-management-count">{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="auto-search-help">Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.</p><div className="user-management-buttons"><button type="button" onClick={() => void bulkUpdateAutoSearch(true)} disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length} aria-describedby="auto-search-help">Enable for all non-admin users</button><button type="button" className="ghost-button" onClick={() => void bulkUpdateAutoSearch(false)} disabled={controlsBusy || !autoSearchEnabledCount} aria-describedby="auto-search-help">Disable for all non-admin users</button></div></section>
<FeatureControls onSaved={() => void loadUsers()} />
</div>
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
</div>
{error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-panel user-directory-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Bulk controls</h2>
<p className="lede">
Manage permissions for all existing non-admin users, not just the current search results.
</p>
</div>
</div>
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Auto search/download</strong>
<span>
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
</span>
</div>
<div className="user-bulk-actions">
<button
type="button"
onClick={() => bulkUpdateAutoSearch(true)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => bulkUpdateAutoSearch(false)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
</button>
</div>
</div>
</div>
<div className="admin-panel user-directory-bulk-panel">
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Invite access</strong>
<span>{inviteEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span>
<span>Admins already have access. Existing invite limits, blocked accounts and expiry dates stay unchanged.</span>
</div>
<div className="user-bulk-actions">
<button type="button" onClick={enableInvitesForEveryone} disabled={bulkInvitesBusy || nonAdminUsers.length === 0 || inviteEnabledCount === nonAdminUsers.length}>
{bulkInvitesBusy ? 'Enabling invites...' : inviteEnabledCount === nonAdminUsers.length && nonAdminUsers.length > 0 ? 'Invites enabled for everyone' : 'Enable invites for all users'}
</button>
</div>
</div>
{inviteStatus && <p role="status">{inviteStatus}</p>}
</div>
</dialog>
<nav className="identity-selection" aria-label="User management sections">
<button type="button" className="ghost-button" aria-pressed={view === 'directory'} onClick={() => changeView('directory')}>User directory</button>
<button type="button" className="ghost-button" aria-pressed={view === 'identities'} onClick={() => changeView('identities')}>Account links &amp; repairs</button>
</nav>
{view === 'identities' ? <IdentityReviewPanel /> : <section className="admin-section users-directory-centered">
{!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>}
{!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
Find an account by username, email, role, login provider or profile ID. Select a user to manage their access.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
@@ -434,14 +390,14 @@ export default function UsersPage() {
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
placeholder="Search username, email, role, login provider or profile ID…"
/>
</label>
</div>
</div>
</div>
{filteredUsers.length === 0 ? (
<div className="status-banner">No users found yet.</div>
<div className="status-banner" role="status">{normalizedQuery ? 'No users match your search. Try another name or email.' : 'No users found yet. Open Manage users to review the directory tools.'}</div>
) : (
<div className="user-directory-list">
<div className="user-directory-header">
@@ -509,7 +465,7 @@ export default function UsersPage() {
))}
</div>
)}
</section>
</section>}
</AdminShell>
)
}
+52
View File
@@ -0,0 +1,52 @@
.users-directory-centered { width: 100%; max-width: 1280px; margin: 0 auto; min-width: 0; }
.user-management-dialog { position: fixed; inset: 0; width: min(980px, calc(100vw - 32px)); max-height: calc(100dvh - 48px); overflow: auto; padding: 0; margin: auto; border: 1px solid var(--ops-line); border-radius: 16px; background: var(--ops-panel, #1c1b1d); color: var(--ops-text, #eee8f2); box-shadow: 0 24px 90px #0009; }
.user-management-dialog::backdrop { background: #000a; backdrop-filter: blur(4px); }
.user-management-content { padding: 28px; }
.user-management-heading { position: sticky; top: 0; z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; padding-bottom: 12px; background: var(--ops-panel, #1c1b1d); box-shadow: 0 -28px 0 var(--ops-panel, #1c1b1d); }
.user-management-heading h2 { margin: 8px 0; font-size: 26px; }
.user-management-heading > button { flex-shrink: 0; }
.user-management-dialog p { color: var(--ops-muted, #bdb6c3); font-size: 13px; line-height: 1.7; margin: 8px 0 16px; }
.user-management-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.user-management-panel { min-width: 0; padding: 22px; border: 1px solid var(--ops-line); border-radius: 12px; background: #ffffff02; }
.user-management-panel h3 { margin: 0 0 10px; font-size: 17px; }
.user-management-action + .user-management-action { border-top: 1px solid var(--ops-line); padding-top: 16px; }
.user-management-action p { margin-top: 10px; font-size: 12px; }
.user-management-action a { display: inline-flex; text-decoration: none; }
.user-management-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
.user-management-dialog button, .user-management-dialog a.ghost-button { max-width: 100%; white-space: normal; line-height: 1.5; }
.user-management-count { display: block; font-size: 12px; color: #c7bdff; margin: 14px 0; }
.user-management-advanced { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--ops-line); }
.user-management-advanced summary, .user-management-summary > summary { cursor: pointer; font-size: 13px; color: var(--ops-text); padding: 8px 0; }
.user-management-advanced button { border-color: #d4a38f70; color: #edc7b8; }
.user-management-summary { border-top: 1px solid var(--ops-line); margin-top: 24px; padding-top: 12px; }
.user-management-summary .admin-rail-stack { margin-top: 14px; }
@media (min-width: 1000px) {
.users-directory-centered .user-directory-header, .users-directory-centered .user-directory-row { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, .9fr) minmax(0, 1.1fr) 64px; }
.users-directory-centered .user-directory-row-chevron { justify-self: end; }
}
@media (max-width: 700px) {
.user-management-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 24px); }
.user-management-content { padding: 20px 16px; }
.user-management-grid { grid-template-columns: 1fr; }
.user-management-panel { padding: 18px; }
.user-management-heading h2 { font-size: 22px; }
}
/* Individual profiles use the directory's modal management pattern. */
.user-detail-page-grid.user-detail-centered { display: block; width: min(100%, 1100px); margin-inline: auto; }
.user-detail-centered .user-detail-main-column { display: flex; flex-direction: column; gap: 24px; }
.user-detail-centered .user-detail-main-column > :nth-child(2) { order: -1; }
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.feature-controls { margin-bottom: 20px; }
.feature-access-row { display: flex; align-items: flex-start; gap: 14px; padding: 15px 0; border-bottom: 1px solid var(--border, #34343c); cursor: pointer; }
.feature-access-row input { flex: 0 0 auto; margin-top: 4px; width: 18px; height: 18px; accent-color: #c4b5fd; }
.feature-access-row span { display: grid; gap: 5px; }
.feature-access-row small { color: var(--text-muted, #a9a9ba); line-height: 1.5; }
.feature-controls .admin-inline-actions { margin-top: 20px; }
.user-management-panel.user-management-danger { margin-top: 24px; border: 1px solid #a84049; background: #321b2080; }
.user-management-danger h3 { color: #ff9ca6; }
.user-management-danger button { border-color: #a84049; color: #ffb6bd; background: #441e27; }
.user-management-danger p { margin-block: 16px; line-height: 1.6; }
@media (max-width: 640px) {
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
+2 -2
View File
@@ -27,9 +27,9 @@ export default function WelcomePage() {
}, [])
return <main className="welcome-page">
<header><span className="welcome-kicker">GrizzlyFlix + Magent</span><h1>Make yourself at home.</h1><p>Something to watch, or something to sort out?</p></header>
{error ? <div role="alert"><p>{error}</p><button onClick={() => window.location.reload()}>Try again</button> <a href="/login">Back to sign in</a></div> : !ready ? <p role="status">Getting things ready</p> : <div className="welcome-choices">
{error ? <div role="alert"><p>{error}</p><button type="button" onClick={() => window.location.reload()}>Try again</button> <a href="/login">Back to sign in</a></div> : !ready ? <p role="status">Getting things ready</p> : <div className="welcome-choices">
{url ? <a className="welcome-choice" href={url}><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>Find your next favourite. Watch movies and TV shows.</p><strong>Lets watch <span aria-hidden="true"></span></strong></a> : <section className="welcome-choice"><span className="welcome-icon" aria-hidden="true"></span><h2>Go to GrizzlyFlix</h2><p>The watch link hasnt been set up yet. Please ask an admin to add the public playback URL.</p></section>}
<a className="welcome-choice" href="/"><span className="welcome-icon" aria-hidden="true"></span><h2>Manage your account</h2><p>Track requests, report a problem, or update your profile.</p><strong>Open 01 My Requests <span aria-hidden="true"></span></strong></a>
<a className="welcome-choice" href="/"><span className="welcome-icon" aria-hidden="true"></span><h2>Manage your account</h2><p>Track requests, report a problem, or update your profile.</p><strong>Open My Requests <span aria-hidden="true"></span></strong></a>
</div>}
<footer>First time here? <a href="/how-it-works">Heres how it works</a>.</footer>
</main>
+2
View File
@@ -158,6 +158,8 @@
.page-heading .home-search { width: 100%; }
}
@media (max-width: 680px) {
.workspace-mobile-nav { padding-inline: 4px; gap: 0; }
.workspace-mobile-nav a { min-width: 0; flex: 1; padding-inline: 3px; }
:root { --workspace-gutter: 16px; --workspace-gap: 20px; }
.page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
.page-heading { padding-bottom: 20px; }
+66
View File
@@ -0,0 +1,66 @@
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
(async () => {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
const rows = [36, 1880].map(id => ({ user: { id, username: 'DeaTH_TaXi', role: 'user', auth_provider: 'jellyfin', jellyseerr_user_id: 13 },
jellyfin: { id: 'a'.repeat(32), name: 'DeaTH_TaXi' }, candidate_jellyfin_id: 'a'.repeat(32), seerr: [{ id: 13, name: 'DeaTH_TaXi' }],
jellystat: { state: 'matched', id: 'a'.repeat(32) }, issues: ['Multiple Magent rows share this username after case and whitespace normalization.'], state: 'conflict' }));
let blocked = false, confirmed = false, checks = 0; const writes = [];
await context.route('**/api/**', async route => {
const request = route.request(), path = new URL(request.url()).pathname;
if (path === '/api/auth/me') return route.fulfill({ json: { username: 'Admin', role: 'admin' } });
if (path === '/api/admin/identities') { checks++; return route.fulfill({ json: { rows: confirmed ? [] : rows, counts: { magent: 2, conflict: 2 }, services: { jellyfin: 'available', seerr: 'available', jellystat: 'available' }, jellyfin_users: [{ id: 'a'.repeat(32), name: 'DeaTH_TaXi' }], upstream: [] } }); }
if (path.endsWith('/duplicates/check')) {
const keep = request.postDataJSON().keep_id || 36;
return route.fulfill({ json: { accounts: rows.map(row => ({ ...row.user, email: 'viewer@example.test', profile_id: null, last_login_at: null })), keep_id: keep, recommended_id: 36, revision: String(keep), can_confirm: !blocked, issues: blocked ? ['Another account owns this identity.'] : [],
proposed: { id: keep, username: 'DeaTH_TaXi', email: 'viewer@example.test', jellyfin_user_id: 'a'.repeat(32), seerr_user_id: 13, features: { stats: true, requests: true, new_requests: true, issues: false, invites: false }, auto_search_enabled: false } } });
}
if (path.endsWith('/duplicates/confirm')) { writes.push(request.postDataJSON()); confirmed = true; return route.fulfill({ json: { consolidated: 1, kept_user_id: 1880 } }); }
return route.fulfill({ json: {} });
});
const page = await context.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 });
await page.goto(base + '/users?view=identities&user=DeaTH_TaXi');
await page.getByRole('button', { name: 'Check all user IDs', exact: true }).click();
const trigger = page.getByRole('button', { name: 'Repair duplicate accounts', exact: true }).first();
await trigger.click(); const dialog = page.getByRole('dialog');
await dialog.getByLabel('Magent account to keep').waitFor();
assert.equal(await dialog.getByLabel('Magent account to keep').inputValue(), '36');
assert(await dialog.getByRole('button', { name: 'Confirm duplicate repair' }).isDisabled());
assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth), 'No horizontal overflow');
const bounds = await dialog.boundingBox();
assert(Math.abs(bounds.x + bounds.width / 2 - width / 2) < 2, 'Dialog centered horizontally');
assert(bounds.y >= 19 && bounds.y + bounds.height <= 981, 'Dialog stays inside viewport');
await dialog.getByRole('checkbox').scrollIntoViewIfNeeded();
await dialog.getByRole('checkbox').check();
assert(await dialog.getByRole('button', { name: 'Confirm duplicate repair' }).isEnabled());
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
assert(await trigger.evaluate(el => el === document.activeElement));
}
blocked = true;
await page.getByRole('button', { name: 'Repair duplicate accounts', exact: true }).first().click();
let dialog = page.getByRole('dialog');
await dialog.getByText('Another account owns this identity.', { exact: true }).waitFor();
assert(await dialog.getByRole('checkbox').isDisabled());
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
blocked = false;
await page.getByRole('button', { name: 'Repair duplicate accounts', exact: true }).first().click(); dialog = page.getByRole('dialog');
await dialog.getByLabel('Magent account to keep').selectOption('1880');
await dialog.getByText('Magent 1880 · Keep', { exact: true }).waitFor();
await dialog.getByRole('checkbox').check();
const previousChecks = checks;
await dialog.getByRole('button', { name: 'Confirm duplicate repair' }).click();
await dialog.waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Run check again', exact: true }).waitFor();
assert.deepEqual(writes, [{ user_id: 36, keep_id: 1880, revision: '1880' }]);
assert(checks > previousChecks, 'Identity report refreshed');
assert.deepEqual(errors, []);
console.log('Passed: desktop/mobile duplicate review, recommended account, changed selection, explicit confirmation, conflict blocking, focus restoration and report refresh. All APIs intercepted.');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+146
View File
@@ -0,0 +1,146 @@
// Fixture-only browser review. All API calls are intercepted; no real messages are sent.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://localhost:3114'
const output = process.env.REVIEW_DIR
const preview = JSON.parse(fs.readFileSync(process.env.REVIEW_EMAIL_FIXTURE, 'utf8'))
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext()
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
const calls = []
let role = 'admin'
let failPreview = false
let email = 'viewer@example.test'
let settings = { enabled: false, day: 2, hour: 9, public_url: 'https://beta.example.test', next_send_at: null }
let preference = { state: 'off', email, can_subscribe: true, detail: 'Ready', schedule_enabled: false, day: 2, hour: 9, next_send_at: null, resend_after: null }
let deliveries = []
let tokenState = 'ready'
const months = Array.from({ length: 23 }, (_, index) => new Date(Date.UTC(2026, 7 - index, 1)).toISOString().slice(0, 7))
await context.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
const payload = request.postDataJSON()
calls.push({ method: request.method(), path: url.pathname, payload })
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role, email } })
if (url.pathname === '/api/auth/profile') return route.fulfill({ json: { user: { username: 'Fixture viewer', role, email, auth_provider: 'jellyfin', password_change_supported: true, password_provider: 'jellyfin' }, activity: { recent: [] } } })
if (url.pathname === '/api/auth/profile/email') { email = payload.email; preference = { ...preference, email, state: 'off' }; return route.fulfill({ json: { email } }) }
if (url.pathname === '/api/profile/email-recaps') {
if (request.method() === 'PUT') preference = { ...preference, state: payload.enabled ? 'pending' : 'off', resend_after: payload.enabled ? Date.now() / 1000 + 300 : null }
return route.fulfill({ json: preference })
}
if (url.pathname.startsWith('/api/admin/email-recaps')) {
if (role !== 'admin') return route.fulfill({ status: role === 'unauthorized' ? 401 : 403, json: { detail: 'Administrator access is required.' } })
if (url.pathname.endsWith('/preview')) {
if (failPreview) return route.fulfill({ status: 502, json: { detail: 'Your report is temporarily unavailable. Please try again shortly.' } })
return route.fulfill({ json: { ...preview, month: url.searchParams.get('month') } })
}
if (url.pathname.endsWith('/test')) {
assert.deepEqual(Object.keys(payload).sort(), ['month', 'request_id'])
deliveries = [{ id: payload.request_id, month: payload.month, kind: 'test', email, username: 'Fixture viewer', state: 'queued', attempts: 0, created_at: Date.now() / 1000, updated_at: Date.now() / 1000, next_attempt_at: Date.now() / 1000, detail: '' }]
return route.fulfill({ status: 202, json: { id: payload.request_id, message: 'Test queued for your confirmed email. Check delivery history for the result.' } })
}
if (request.method() === 'PUT') { settings = { ...payload, next_send_at: payload.enabled ? Date.UTC(2026, 9, payload.day, payload.hour) / 1000 : null }; return route.fulfill({ json: settings }) }
return route.fulfill({ json: { settings, months, ready: true, detail: 'Ready', deliveries, total: deliveries.length, subscribers: 12, worker_enabled: true } })
}
if (url.pathname.startsWith('/api/email-recaps/')) {
if (url.pathname.endsWith('/confirm')) tokenState = payload.action === 'confirm' ? 'enabled' : 'off'
return route.fulfill({ json: { action: payload.action, state: tokenState } })
}
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
return route.fulfill({ json: {} })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
if (output) fs.mkdirSync(output, { recursive: true })
for (const width of [1440, 980, 390, 320]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(`${base}/admin/recaps`)
await page.getByRole('heading', { name: 'Monthly schedule', exact: true }).waitFor()
assert.equal(await page.getByLabel('Enable scheduled monthly recaps').isChecked(), false)
assert.equal(await page.getByRole('button', { name: 'Send test to me', exact: true }).isDisabled(), true)
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Admin overflow at ${width}`)
if (output) await page.screenshot({ path: path.join(output, `recaps-admin-${width}.png`), fullPage: true })
await page.getByRole('button', { name: 'Preview my recap', exact: true }).click()
const frame = page.frameLocator('iframe[title="Monthly recap email preview"]')
await frame.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
assert.equal(await page.locator('iframe').getAttribute('sandbox'), '')
assert.equal(await frame.getByRole('link', { name: /Explore your full report/ }).getAttribute('href'), 'https://beta.example.test/insights/reports?month=2026-08')
assert(await frame.locator('body').evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Email overflow at ${width}`)
if (output) await page.locator('.recap-preview').screenshot({ path: path.join(output, `recap-preview-${width}.png`) })
await page.getByRole('button', { name: 'Plain text', exact: true }).click()
assert.match(await page.locator('.recap-plain-preview').innerText(), /Minutes watched: 1,500/)
await page.goto(`${base}/profile`)
await page.getByRole('heading', { name: 'Your month, delivered.', exact: true }).waitFor()
await page.getByRole('button', { name: 'Email me my monthly recap', exact: true }).waitFor()
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Profile overflow at ${width}`)
if (output) await page.screenshot({ path: path.join(output, `recap-profile-${width}.png`), fullPage: true })
}
await page.getByRole('button', { name: 'Email me my monthly recap', exact: true }).click()
await page.getByText('Check your inbox', { exact: true }).waitFor()
assert.equal(await page.getByRole('button', { name: 'Send a new confirmation', exact: true }).isDisabled(), true)
await page.getByRole('button', { name: 'Cancel subscription', exact: true }).click()
await page.getByText('Off', { exact: true }).waitFor()
preference = { ...preference, state: 'enabled' }
await page.getByRole('button', { name: 'Refresh preference', exact: true }).click()
await page.getByText('Subscribed', { exact: true }).waitFor()
await page.getByRole('textbox', { name: 'Email address', exact: true }).fill('changed@example.test')
await page.getByRole('button', { name: 'Save email', exact: true }).click()
await page.getByText('Off', { exact: true }).waitFor()
await page.goto(`${base}/admin/recaps`)
await page.getByLabel('Day of the month').selectOption('3')
await page.getByLabel('Enable scheduled monthly recaps').check()
assert.equal(await page.getByRole('button', { name: 'Preview my recap', exact: true }).isDisabled(), true)
await page.getByRole('button', { name: 'Save schedule', exact: true }).click()
await page.getByText('Schedule running', { exact: true }).waitFor()
assert.equal(settings.day, 3)
assert.equal(settings.enabled, true)
await page.getByRole('button', { name: 'Preview my recap', exact: true }).click()
await page.locator('iframe').waitFor()
await page.getByRole('button', { name: 'Send test to me', exact: true }).click()
await page.getByText('Queued', { exact: true }).waitFor()
deliveries = ['sent', 'retry', 'unknown', 'failed', 'cancelled'].map((state, index) => ({ ...deliveries[0], id: `fixture-${index}`, state, attempts: index + 1, detail: state === 'unknown' ? 'Check the mail server.' : 'Fixture delivery result.' }))
await page.getByRole('button', { name: 'Refresh history', exact: true }).click()
await page.getByText('Needs review', { exact: true }).waitFor()
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'History overflows mobile viewport')
assert.match(await page.locator('.recap-history').innerText(), /Automatic retries are stopped/)
await page.setViewportSize({ width: 1440, height: 1000 })
if (output) await page.screenshot({ path: path.join(output, 'recap-delivery-history.png'), fullPage: true })
failPreview = true
await page.getByRole('button', { name: 'Preview my recap', exact: true }).click()
await page.getByRole('alert').filter({ hasText: 'temporarily unavailable' }).waitFor()
await context.clearCookies()
const token = 'fixture'.repeat(7)
tokenState = 'ready'
await page.goto(`${base}/email-recaps#action=confirm&token=${token}`)
await page.getByRole('button', { name: 'Confirm email recaps', exact: true }).waitFor()
assert.equal(calls.filter((call) => call.path === '/api/email-recaps/confirm').length, 0)
assert.equal(await page.locator('.header').count(), 0)
await page.getByRole('button', { name: 'Confirm email recaps', exact: true }).click()
await page.getByRole('heading', { name: 'Youre on the list.', exact: true }).waitFor()
assert.equal(new URL(page.url()).hash, '')
tokenState = 'ready'
await page.setViewportSize({ width: 320, height: 800 })
await page.goto(`${base}/email-recaps#action=unsubscribe&token=${token}`)
await page.getByRole('button', { name: 'Unsubscribe from recaps', exact: true }).waitFor()
if (output) await page.screenshot({ path: path.join(output, 'recap-unsubscribe-mobile.png'), fullPage: true })
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Unsubscribe overflow')
await page.getByRole('button', { name: 'Unsubscribe from recaps', exact: true }).click()
await page.getByRole('heading', { name: 'Recaps are turned off.', exact: true }).waitFor()
await page.goto(`${base}/email-recaps`)
await page.getByRole('alert').filter({ hasText: 'incomplete' }).waitFor()
role = 'user'
await page.goto(`${base}/admin/recaps`)
await page.waitForURL(`${base}/`)
role = 'unauthorized'
await page.goto(`${base}/admin/recaps`)
await page.waitForURL(/\/login\?next=/)
assert.deepEqual(errors, [])
console.log(`Email recap UI passed: desktop/mobile layout, preview isolation, consent, schedule, test queue, history, public links and access control; ${calls.length} intercepted API calls.`)
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exitCode = 1 })
+71
View File
@@ -0,0 +1,71 @@
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
(async () => {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
const all = { stats: true, requests: true, new_requests: true, issues: true, invites: true };
const viewer = { id: 2, username: 'Georgia', role: 'user', email: 'georgia@example.test', features: { ...all }, stats: { total: 12, ready: 7, in_progress: 5 } };
const other = { ...viewer, id: 3, username: 'Other viewer', features: { ...all, issues: false } };
let signedIn = { username: 'Admin', role: 'admin', features: all };
const writes = [];
await context.route('**/api/**', async (route) => {
const request = route.request(), path = new URL(request.url()).pathname;
if (path === '/api/auth/me') return route.fulfill({ json: signedIn });
if (request.method() === 'PUT' && path.includes('/features')) {
writes.push({ path, payload: request.postDataJSON() });
Object.assign(viewer.features, request.postDataJSON());
return route.fulfill({ json: { updated: 2, features: viewer.features } });
}
if (path === '/api/admin/users/summary') return route.fulfill({ json: { users: [viewer, other] } });
if (path === '/api/admin/users/id/2' || path === '/api/admin/users/Georgia') return route.fulfill({ json: { user: viewer, stats: viewer.stats } });
if (path === '/api/admin/profiles') return route.fulfill({ json: { profiles: [] } });
return route.fulfill({ json: {} });
});
const page = await context.newPage();
const errors = []; page.on('pageerror', (error) => errors.push(error.message));
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 });
await page.goto(`${base}/users/2`);
await page.getByRole('heading', { name: 'Request statistics', exact: true }).waitFor();
assert.equal(await page.getByRole('heading', { name: 'Access controls', exact: true }).count(), 0);
const box = await page.locator('.user-detail-centered').boundingBox();
assert(Math.abs(box.x + box.width / 2 - width / 2) < 4, 'Profile is centred');
await page.getByRole('button', { name: 'Manage this user', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('checkbox', { name: /My Stats/ }).waitFor();
assert(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth), `No modal overflow at ${width}`);
assert.equal(await dialog.getByRole('checkbox').count(), 7);
assert(await dialog.getByText('Restrict access or delete accounts', { exact: true }).count());
await dialog.getByRole('checkbox', { name: /^Issues/ }).uncheck();
await dialog.getByRole('button', { name: 'Save feature access', exact: true }).click();
await dialog.getByRole('status').filter({ hasText: 'Feature access saved.' }).waitFor();
assert.deepEqual(writes.at(-1).payload, { issues: false });
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
assert(await page.getByRole('button', { name: 'Manage this user', exact: true }).evaluate((element) => element === document.activeElement));
viewer.features.issues = true;
}
await page.goto(`${base}/users`);
await page.getByRole('button', { name: 'Manage users', exact: true }).click();
const dialog = page.getByRole('dialog');
const issues = dialog.getByRole('checkbox', { name: /^Issues/ });
await issues.waitFor();
assert(await issues.evaluate((element) => element.indeterminate), 'Bulk mixed permissions shown');
await issues.check();
await dialog.getByRole('button', { name: 'Apply changed features to all users', exact: true }).click();
await dialog.getByRole('status').filter({ hasText: 'Feature access saved for 2' }).waitFor();
assert.deepEqual(writes.at(-1), { path: '/api/admin/users/features/bulk', payload: { issues: true } });
signedIn = { username: 'Viewer', role: 'user', features: Object.fromEntries(Object.keys(all).map((key) => [key, false])) };
for (const path of ['/insights', '/', '/new-requests', '/portal/issues', '/profile/invites']) {
await page.goto(base + path);
await page.getByRole('heading', { name: 'Feature unavailable' }).waitFor();
assert.equal(await page.locator('.header-actions a, .workspace-mobile-nav a').count(), 0);
}
assert.deepEqual(errors, []);
console.log('Passed: responsive centred profiles, management overlay, focus restoration, individual and mixed bulk permissions, saved payload scope, and all five denied routes/navigation. API traffic used fixtures only.');
} finally { await browser.close(); }
})().catch((error) => { console.error(error); process.exitCode = 1; });

Some files were not shown because too many files have changed in this diff Show More