Compare commits

...
12 Commits
Author SHA1 Message Date
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
Assclaw a3b5759708 Enforce recipient-bound single-use invites and fix issue card layout; clean release tooling
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:59:51 +12:00
Assclaw 13edcb8136 Prevent non-admin issue responses exposing account identities
Magent CI/CD / verify (push) Successful in 10m34s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:45:19 +12:00
Assclaw 131b5fc5c7 Add private Prometheus API metrics and Grafana performance dashboard
Magent CI/CD / verify (push) Successful in 10m24s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 19:23:35 +12:00
Assclaw bd1f2cb1cb Make request status badges prominent and easier to scan
Magent CI/CD / verify (push) Successful in 10m32s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 17:31:16 +12:00
Assclaw edca300d27 Add bulk enable invite access to user directory
Magent CI/CD / verify (push) Successful in 10m27s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 16:19:12 +12:00
Assclaw 4034a8f72a Add post-login welcome and friendly how-it-works guide
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 16:08:37 +12:00
Assclaw 0637860b95 Show imported Seerr accounts while preserving linked-user deduplication
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 15:20:10 +12:00
Assclaw 697fc235ee Serve resilient launch cover independently of application upstream
Magent CI/CD / verify (push) Failing after 50m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 13:46:09 +12:00
Assclaw 62ee07f92b Record verified coming-soon cutover and rollback procedure
Magent CI/CD / verify (push) Canceled after 14m20s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-07 13:31:44 +12:00
Assclaw 458ef53f47 Exclude production bootstrap credentials from source and build context
Magent CI/CD / verify (push) Canceled after 1m56s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-07 13:29:43 +12:00
Assclaw c2685f43a7 Prepare clean production setup and coming-soon cover
Magent CI/CD / verify (push) Successful in 11m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-07 13:14:22 +12:00
68 changed files with 2897 additions and 855 deletions
+9
View File
@@ -1,5 +1,14 @@
.git .git
.env .env
.env.*
.venv/
**/.pytest_cache/
stitch_magent_media_operations_redesign/
*.tar
*.tar.gz
*.zip
bootstrap-admin.json
release.tar
*.log *.log
data/* data/*
!data/branding/ !data/branding/
+1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- beta - beta
- main
- prod - prod
workflow_dispatch: workflow_dispatch:
+8
View File
@@ -1,4 +1,5 @@
.env .env
bootstrap-admin.json
.venv/ .venv/
data/ data/
!data/branding/ !data/branding/
@@ -10,3 +11,10 @@ backend/.pytest_cache/
frontend/node_modules/ frontend/node_modules/
frontend/.next/ frontend/.next/
*.log *.log
**/.pytest_cache/
.env.*
!.env.example
!.env.*.example
*.tar
*.tar.gz
*.zip
+61
View File
@@ -0,0 +1,61 @@
# Production
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
The root `Dockerfile` is the supported build entry point. Source releases come
from `main`; use `prod-<short-commit>` tags to identify an exact release.
## Live deployment
- Host: GRZ-DKR01 (`10.30.1.81`).
- Container and Compose service: `magent`; Compose project: `arrstack`.
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data``/app/data`.
- Public URL: `https://magent.grizzlyflix.co.nz`.
- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`.
- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes.
## Release checklist
1. Run the backend tests and frontend production build. Review only the intended
changes, then commit and push `main`.
2. Build from a clean source export using the root Dockerfile. Never include
`.env`, databases or bootstrap credentials in the build context.
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
Confirm their digests match.
4. Pull the new image before stopping production. Keep the old image under a
rollback tag and back up the current Compose configuration.
5. Briefly stop only `magent`, then back up its complete data directory so SQLite
and its WAL files are consistent. Protect backups: they contain private data.
6. Recreate only this service with `docker compose -p arrstack -f
/home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`.
Confirm that Compose selects the intended image before running this command.
7. Check container health, the API `/health` endpoint, public login, the changed
feature, database integrity and account counts. Do not trigger bulk permission
changes, email sends or user imports as a deployment smoke test.
For rollback, select the saved image and recreate only Magent. Restore data only
if needed; doing so can discard activity since the backup. Never restore a whole
shared Compose or Caddy file without checking for unrelated changes first.
## Build metadata
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
display build number as the frontend package files. `.env` should have exactly
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
tags identify the deployed source commit independently of this display value.
`scripts/process1.ps1` is a local development workflow: it updates metadata,
runs tests, rebuilds local Docker, and can commit changes/send Discord messages.
It is **not** the production deployment command. Its build-number helper can be
tested safely with `powershell -File scripts/test_env_build_number.ps1`.
## Fresh instances and historical notes
`scripts/prepare_production_settings.py` exports only allowlisted connection and
SMTP settings for a fresh instance. Do not use it to replace a live database.
`docker-compose.production.yml` is the separate fresh-instance template, not the
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
[archived cutover notes](docs/archive/production-cutover-2026-09-07.md).
+1
View File
@@ -23,6 +23,7 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
- Local database for speed and audit history. - Local database for speed and audit history.
- Users and access control (admin vs user, block access). - Users and access control (admin vs user, block access).
- Local account password changes via "My profile". - 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).
- Docker-first deployment for easy hosting. - Docker-first deployment for easy hosting.
## Quick start (Docker - primary) ## Quick start (Docker - primary)
-16
View File
@@ -1,16 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+6
View File
@@ -5,6 +5,7 @@ import httpx
from ..logging_config import sanitize_headers, sanitize_value from ..logging_config import sanitize_headers, sanitize_value
from ..services.operation_progress import finish_remote_call, start_remote_call from ..services.operation_progress import finish_remote_call, start_remote_call
from ..metrics import record_remote
_SERVICE_NAMES = { _SERVICE_NAMES = {
@@ -309,6 +310,7 @@ class ApiClient:
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client")) service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
active_message, _ = _operation_messages(service_name, method, path) active_message, _ = _operation_messages(service_name, method, path)
operation_event_id = start_remote_call(service_name, active_message) operation_event_id = start_remote_call(service_name, active_message)
metric_status = 'error'
self.logger.debug( self.logger.debug(
"outbound request started method=%s url=%s params=%s payload=%s headers=%s", "outbound request started method=%s url=%s params=%s payload=%s headers=%s",
method, method,
@@ -327,6 +329,7 @@ class ApiClient:
params=params, params=params,
payload=payload, payload=payload,
) )
metric_status = str(response.status_code)
response.raise_for_status() response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2) duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
self.logger.debug( self.logger.debug(
@@ -389,6 +392,9 @@ class ApiClient:
) )
raise raise
finally:
record_remote(service_name, method, metric_status, time.perf_counter() - started_at)
async def get( async def get(
self, self,
path: str, path: str,
+86
View File
@@ -0,0 +1,86 @@
"""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 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( jellyseerr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY") 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( jellyfin_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL") default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
) )
+35 -12
View File
@@ -187,6 +187,12 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
def init_db() -> None: def init_db() -> None:
with _connect() as conn: with _connect() as conn:
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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS request_repairs ( CREATE TABLE IF NOT EXISTS request_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1223,9 +1229,8 @@ def get_all_users() -> list[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[12]), "is_expired": _is_datetime_in_past(row[12]),
} }
) )
# Admin user management uses Jellyfin as the source of truth for non-admin # Imported Seerr accounts must remain manageable. Prefer a Jellyfin/local
# user objects. Seerr rows are treated as enrichment-only and hidden # account when a linked duplicate exists, without hiding Seerr-only users.
# from admin/user-management views to avoid duplicate accounts in the UI.
def _provider_rank(user: Dict[str, Any]) -> int: def _provider_rank(user: Dict[str, Any]) -> int:
provider = str(user.get("auth_provider") or "local").strip().lower() provider = str(user.get("auth_provider") or "local").strip().lower()
if provider == "jellyfin": if provider == "jellyfin":
@@ -1236,14 +1241,7 @@ def get_all_users() -> list[Dict[str, Any]]:
return 2 return 2
return 2 return 2
visible_candidates = [ visible_candidates = all_rows
user
for user in all_rows
if not (
str(user.get("auth_provider") or "local").strip().lower() == "jellyseerr"
and str(user.get("role") or "user").strip().lower() != "admin"
)
]
visible_candidates.sort( visible_candidates.sort(
key=lambda user: ( key=lambda user: (
@@ -1581,7 +1579,7 @@ def delete_user_profile(profile_id: int) -> bool:
def _row_to_signup_invite(row: Any) -> Dict[str, Any]: def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = row[6] max_uses = 1 if row[10] else row[6]
use_count = int(row[7] or 0) use_count = int(row[7] or 0)
expires_at = row[9] expires_at = row[9]
is_expired = _is_datetime_in_past(expires_at) is_expired = _is_datetime_in_past(expires_at)
@@ -1665,6 +1663,8 @@ def create_signup_invite(
recipient_email: Optional[str] = None, recipient_email: Optional[str] = None,
created_by: Optional[str] = None, created_by: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
if recipient_email:
max_uses = 1
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( cursor = conn.execute(
@@ -1722,6 +1722,11 @@ def update_signup_invite(
expires_at: Optional[str], expires_at: Optional[str],
recipient_email: Optional[str], recipient_email: Optional[str],
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
existing = get_signup_invite_by_id(invite_id)
if recipient_email or (existing and existing.get('recipient_email')):
max_uses = 1
if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
raise ValueError('A used email invitation cannot be reassigned.')
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( cursor = conn.execute(
@@ -1759,6 +1764,24 @@ def delete_signup_invite(invite_id: int) -> bool:
return cursor.rowcount > 0 return cursor.rowcount > 0
def reserve_signup_invite_use(invite_id: int) -> bool:
"""Atomically reserve capacity before any remote account is provisioned."""
with _connect() as conn:
cursor = conn.execute('''
UPDATE signup_invites SET use_count = use_count + 1
WHERE id = ? AND enabled = 1
AND (expires_at IS NULL OR julianday(expires_at) > julianday('now'))
AND ((recipient_email IS NOT NULL AND recipient_email != '' AND use_count < 1)
OR ((recipient_email IS NULL OR recipient_email = '') AND (max_uses IS NULL OR use_count < max_uses)))
''', (invite_id,))
return cursor.rowcount == 1
def release_signup_invite_use(invite_id: int) -> None:
with _connect() as conn:
conn.execute('UPDATE signup_invites SET use_count = MAX(0, use_count - 1) WHERE id = ?', (invite_id,))
def increment_signup_invite_use(invite_id: int) -> None: def increment_signup_invite_use(invite_id: int) -> None:
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
+10
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import os
import time import time
import uuid import uuid
from typing import Awaitable, Callable from typing import Awaitable, Callable
@@ -26,6 +27,7 @@ from .routers.site import router as site_router
from .routers.events import router as events_router from .routers.events import router as events_router
from .routers.portal import router as portal_router from .routers.portal import router as portal_router
from .routers.operations import router as operations_router from .routers.operations import router as operations_router
from .routers.insights import router as insights_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop from .services.issue_resolution import run_issue_confirmation_loop
from .services.operation_progress import ( from .services.operation_progress import (
@@ -43,6 +45,7 @@ from .logging_config import (
summarize_http_body, summarize_http_body,
) )
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = [] _background_tasks: list[asyncio.Task[None]] = []
@@ -111,6 +114,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
except Exception: except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2) duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
record_api(request, 500, time.perf_counter() - started_at)
logger.exception( logger.exception(
"request failed method=%s path=%s duration_ms=%s", "request failed method=%s path=%s duration_ms=%s",
request.method, request.method,
@@ -124,6 +128,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
raise raise
duration_ms = round((time.perf_counter() - started_at) * 1000, 2) duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
record_api(request, response.status_code, time.perf_counter() - started_at)
response.headers.setdefault("X-Request-ID", request_id) response.headers.setdefault("X-Request-ID", request_id)
response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Frame-Options", "DENY")
@@ -220,6 +225,7 @@ def _enforce_secure_startup_configuration() -> None:
@app.on_event("startup") @app.on_event("startup")
async def startup() -> None: async def startup() -> None:
start_metrics()
configure_logging( configure_logging(
settings.log_level, settings.log_level,
settings.log_file, settings.log_file,
@@ -251,6 +257,9 @@ async def startup() -> None:
runtime.log_background_sync_level, runtime.log_background_sync_level,
runtime.requests_data_source, runtime.requests_data_source,
) )
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
logger.info("Background imports and automation paused for initial setup")
return
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync) _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache) _launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("requests-delta-loop", run_requests_delta_loop) _launch_background_task("requests-delta-loop", run_requests_delta_loop)
@@ -272,3 +281,4 @@ app.include_router(site_router)
app.include_router(events_router) app.include_router(events_router)
app.include_router(portal_router) app.include_router(portal_router)
app.include_router(operations_router) app.include_router(operations_router)
app.include_router(insights_router)
+27
View File
@@ -0,0 +1,27 @@
"""Low-cardinality operational metrics; no URLs, query values or user data."""
import os
from prometheus_client import Counter, Histogram, start_http_server
BUCKETS = (.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60)
API_CALLS = Counter('magent_api_requests_total', 'API responses by route template', ['method', 'route', 'status'])
API_TIME = Histogram('magent_api_response_seconds', 'Time until response headers (not stream lifetime)', ['method', 'route'], buckets=BUCKETS)
REMOTE_CALLS = Counter('magent_remote_requests_total', 'Logical service client calls', ['service', 'method', 'status'])
REMOTE_TIME = Histogram('magent_remote_response_seconds', 'Logical service client call duration', ['service', 'method'], buckets=BUCKETS)
_server = None
def start_metrics():
global _server
if _server is None and os.getenv('MAGENT_METRICS_ENABLED', '').lower() == 'true':
_server = start_http_server(int(os.getenv('MAGENT_METRICS_PORT', '9108')), addr=os.getenv('MAGENT_METRICS_BIND', '127.0.0.1'))
def record_api(request, status, seconds):
route = getattr(request.scope.get('route'), 'path', 'unmatched')
method = request.method if request.method in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
API_CALLS.labels(method, route, str(status)).inc()
API_TIME.labels(method, route).observe(max(0, seconds))
def record_remote(service, method, status, seconds):
service = service if service in {'Seerr', 'Jellyfin', 'Sonarr', 'Radarr', 'Bazarr', 'Prowlarr', 'qBittorrent'} else 'Other'
method = method.upper() if method.upper() in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} else 'OTHER'
REMOTE_CALLS.labels(service, method, str(status)).inc()
REMOTE_TIME.labels(service, method).observe(max(0, seconds))
+18
View File
@@ -132,6 +132,7 @@ def _optional_recipient_email(value: object) -> Optional[str]:
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address") raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
SENSITIVE_KEYS = { SENSITIVE_KEYS = {
"jellystat_api_key",
"magent_ssl_certificate_pem", "magent_ssl_certificate_pem",
"magent_ssl_private_key_pem", "magent_ssl_private_key_pem",
"magent_notify_email_smtp_password", "magent_notify_email_smtp_password",
@@ -150,6 +151,7 @@ SENSITIVE_KEYS = {
} }
URL_SETTING_KEYS = { URL_SETTING_KEYS = {
"jellystat_base_url",
"magent_application_url", "magent_application_url",
"magent_api_url", "magent_api_url",
"magent_proxy_base_url", "magent_proxy_base_url",
@@ -172,6 +174,8 @@ NOTIFICATION_URL_SETTING_KEYS = {
} }
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"jellystat_base_url",
"jellystat_api_key",
"magent_application_url", "magent_application_url",
"magent_application_port", "magent_application_port",
"magent_api_url", "magent_api_url",
@@ -1919,6 +1923,20 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
message = _normalize_optional_text(payload.get("message")) message = _normalize_optional_text(payload.get("message"))
reason = _normalize_optional_text(payload.get("reason")) reason = _normalize_optional_text(payload.get("reason"))
if template_key == 'invited':
if not invite:
raise HTTPException(status_code=400, detail='Choose an invitation before sending it.')
if int(invite.get('use_count') or 0) > 0:
raise HTTPException(status_code=400, detail='This invitation has already been used. Create a new invitation.')
if invite.get('recipient_email') and normalize_delivery_email(invite['recipient_email']) != recipient_email:
raise HTTPException(status_code=400, detail='This invitation belongs to a different recipient. Create a new invitation.')
invite = update_signup_invite(
int(invite['id']), code=invite['code'], label=invite.get('label'),
description=invite.get('description'), profile_id=invite.get('profile_id'),
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
)
try: try:
result = await send_templated_email( result = await send_templated_email(
template_key, template_key,
+27 -3
View File
@@ -28,7 +28,8 @@ from ..db import (
create_signup_invite, create_signup_invite,
update_signup_invite, update_signup_invite,
delete_signup_invite, delete_signup_invite,
increment_signup_invite_use, reserve_signup_invite_use,
release_signup_invite_use,
get_user_profile, get_user_profile,
get_user_activity, get_user_activity,
get_user_activity_summary, get_user_activity_summary,
@@ -398,6 +399,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return { return {
"code": invite.get("code"), "code": invite.get("code"),
"email_bound": bool(invite.get("recipient_email")),
"label": invite.get("label"), "label": invite.get("label"),
"description": invite.get("description"), "description": invite.get("description"),
"enabled": bool(invite.get("enabled")), "enabled": bool(invite.get("enabled")),
@@ -755,6 +757,11 @@ async def jellyfin_login(
save_jellyfin_users_cache(users) save_jellyfin_users_cache(users)
except Exception: except Exception:
pass 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) sync_jellyfin_password_state(canonical_username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map: if user and user.get("jellyseerr_user_id") is None and candidate_map:
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map) matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
@@ -920,6 +927,16 @@ async def signup(payload: dict, response: Response) -> dict:
if remaining_uses is not None and int(remaining_uses) <= 0: if remaining_uses is not None and int(remaining_uses) <= 0:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
account_email = normalize_delivery_email(invite.get('recipient_email'))
if account_email:
supplied_email = str(payload.get('email') or '').strip()
if supplied_email and normalize_delivery_email(supplied_email) != account_email:
raise HTTPException(status_code=400, detail='This invitation is tied to the email address it was sent to.')
else:
account_email = normalize_delivery_email(payload.get('email'))
if not account_email:
raise HTTPException(status_code=400, detail='A valid email address is required to create your account.')
profile = None profile = None
profile_id = invite.get("profile_id") profile_id = invite.get("profile_id")
if profile_id is not None: if profile_id is not None:
@@ -946,6 +963,10 @@ async def signup(payload: dict, response: Response) -> dict:
if isinstance(account_expires_days, int) and account_expires_days > 0: if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
if not reserve_signup_invite_use(int(invite['id'])):
raise HTTPException(status_code=403, detail='This invitation has already been used or is unavailable.')
account_created = False
try:
runtime = get_runtime_settings() runtime = get_runtime_settings()
auth_provider = "local" auth_provider = "local"
local_password_value = password_value local_password_value = password_value
@@ -999,7 +1020,7 @@ async def signup(payload: dict, response: Response) -> dict:
username, username,
local_password_value, local_password_value,
role=role, role=role,
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None, email=account_email,
auth_provider=auth_provider, auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id, jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled, auto_search_enabled=auto_search_enabled,
@@ -1010,7 +1031,7 @@ async def signup(payload: dict, response: Response) -> dict:
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
increment_signup_invite_use(int(invite["id"])) account_created = True
created_user = get_user_by_username(username) created_user = get_user_by_username(username)
if auth_provider == "jellyfin": if auth_provider == "jellyfin":
sync_jellyfin_password_state(username, password_value) sync_jellyfin_password_state(username, password_value)
@@ -1053,6 +1074,9 @@ async def signup(payload: dict, response: Response) -> dict:
"expires_at": created_user.get("expires_at") if created_user else None, "expires_at": created_user.get("expires_at") if created_user else None,
}, },
) )
finally:
if not account_created:
release_signup_invite_use(int(invite['id']))
@router.post("/password/forgot") @router.post("/password/forgot")
+34
View File
@@ -0,0 +1,34 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, field_validator
from ..auth import get_current_user
from ..clients.jellystat import HistoryLimitError, JellystatError
from ..services.insights import get_insights
router = APIRouter(prefix="/insights", tags=["insights"])
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
+40 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
@@ -18,7 +19,8 @@ from ..db import (
delete_portal_item, delete_portal_item,
get_portal_item, get_portal_item,
get_portal_overview, get_portal_overview,
list_portal_comments, list_portal_comments as _list_portal_comments,
get_all_users,
list_portal_item_activity, list_portal_item_activity,
list_portal_items, list_portal_items,
update_portal_item, update_portal_item,
@@ -481,6 +483,31 @@ def _public_media_status_payload(
} }
def _public_text(value: Any) -> Any:
if not isinstance(value, str):
return value
identities = {str(u.get(key) or '').strip() for u in get_all_users() for key in ('username', 'email')}
identities.discard('')
if identities:
pattern = r'(?<![\w@])(?:' + '|'.join(re.escape(v) for v in sorted(identities, key=len, reverse=True)) + r')(?![\w@])'
value = re.sub(pattern, '[private]', value, flags=re.IGNORECASE)
return re.sub(r'[\w.+%-]+@[\w.-]+\.[A-Za-z]{2,}', '[private email]', value)
def _public_comment(comment: Dict[str, Any]) -> Dict[str, Any]:
return {
'id': comment.get('id'), 'item_id': comment.get('item_id'),
'author_username': 'Support team' if comment.get('author_role') == 'admin' else 'Reporter',
'author_role': comment.get('author_role'), 'created_at': comment.get('created_at'),
'message': _public_text(comment.get('message')), 'is_internal': False,
}
def list_portal_comments(*args, **kwargs):
comments = _list_portal_comments(*args, **kwargs)
return comments if kwargs.get('include_internal') else [_public_comment(c) for c in comments if not c.get('is_internal')]
def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
is_admin = _is_admin(user) is_admin = _is_admin(user)
is_owner = _is_owner(user, item) is_owner = _is_owner(user, item)
@@ -525,6 +552,16 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"last_delivery_succeeded": resolution.get("lastDeliverySucceeded"), "last_delivery_succeeded": resolution.get("lastDeliverySucceeded"),
}, },
} }
if not is_admin:
serialized = {key: value for key, value in serialized.items() if key in {
'id', 'kind', 'title', 'description', 'media_type', 'year', 'source_request_id',
'related_item_id', 'status', 'workflow_request_status', 'workflow_media_status',
'issue_type', 'issue_resolved_at', 'priority', 'created_at', 'updated_at',
'last_activity_at', 'permissions', 'workflow', 'issue',
}}
serialized['created_by_username'] = user.get('username') if is_owner else 'Another member'
serialized['title'] = _public_text(serialized.get('title'))
serialized['description'] = _public_text(serialized.get('description'))
return serialized return serialized
@@ -566,6 +603,7 @@ def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -
else "Reporter" else "Reporter"
) )
public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user" public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user"
public_entry['message'] = _public_text(public_entry.get('message'))
public_activity.append(public_entry) public_activity.append(public_entry)
return public_activity return public_activity
@@ -1510,4 +1548,4 @@ async def portal_create_comment(
user=current_user, user=current_user,
note=f"internal={is_internal}", note=f"internal={is_internal}",
) )
return {"comment": comment} return {"comment": comment if is_admin else _public_comment(comment)}
+8
View File
@@ -1,4 +1,5 @@
from typing import Any, Dict from typing import Any, Dict
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -36,6 +37,13 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
} }
if include_changelog: if include_changelog:
info["changelog"] = (CHANGELOG or "").strip() info["changelog"] = (CHANGELOG or "").strip()
playback_url = (runtime.jellyfin_public_url or "").strip()
try:
parsed = urlsplit(playback_url)
valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password
except ValueError:
valid = False
info["mediaServerUrl"] = playback_url if valid else None
return info return info
+9
View File
@@ -11,6 +11,7 @@ from ..clients.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)]) 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" overall = "up"
if any(s.get("status") == "down" for s in services): if any(s.get("status") == "down" for s in services):
overall = "down" 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) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower() 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 = { checks = {
"seerr": ( "seerr": (
"Seerr", "Seerr",
+181
View File
@@ -0,0 +1,181 @@
import asyncio
import hashlib
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
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
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) -> dict:
clause = "julianday(created_at) >= julianday(?) AND julianday(created_at) <= 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) -> 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)
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 not start <= 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"
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})
count = (end.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 = end.date() if end.date().isoformat() in active_days else end.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])],
"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, **cached[1]}
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, **data}
+35
View File
@@ -0,0 +1,35 @@
"""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:
# 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, set_user_jellyseerr_id,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user
from .user_cache import ( from .user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
extract_jellyseerr_user_email, extract_jellyseerr_user_email,
@@ -68,6 +69,10 @@ async def sync_jellyfin_users() -> int:
set_user_jellyseerr_id(name, matched_id) set_user_jellyseerr_id(name, matched_id)
if matched_email: if matched_email:
set_user_email(name, 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 return imported
+1
View File
@@ -7,3 +7,4 @@ PyJWT==2.13.0
passlib==1.7.4 passlib==1.7.4
python-multipart==0.0.31 python-multipart==0.0.31
Pillow==12.3.0 Pillow==12.3.0
prometheus-client==0.22.1
+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
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, 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)
+54
View File
@@ -0,0 +1,54 @@
import asyncio
import unittest
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException, Response
from backend.app import db
from backend.app.routers import auth
from backend.tests.test_backend_quality import TempDatabaseMixin
class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def signup(self, code, username, **extra):
with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'):
return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response())
async def test_email_invite_binds_account_and_cannot_be_reused(self):
invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20)
self.assertEqual(invite['max_uses'], 1)
public = auth._public_invite_payload(invite)
self.assertTrue(public['email_bound'])
self.assertNotIn('recipient@example.com', str(public))
await self.signup('EMAILTEST', 'first-user')
self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com')
with self.assertRaises(HTTPException):
await self.signup('EMAILTEST', 'second-user')
async def test_email_invite_rejects_recipient_override(self):
db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com')
with self.assertRaises(HTTPException):
await self.signup('BOUNDTEST', 'override-user', email='different@example.com')
self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0)
async def test_manual_invite_requires_and_saves_email(self):
db.create_signup_invite(code='MANUALTEST', max_uses=3)
for email in ['', 'invalid']:
with self.assertRaises(HTTPException):
await self.signup('MANUALTEST', 'manual-user', email=email)
await self.signup('MANUALTEST', 'manual-user', email='manual@example.com')
self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com')
self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2)
async def test_failed_creation_releases_reservation(self):
invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com')
with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')):
with self.assertRaises(HTTPException):
await self.signup('FAILTEST', 'failed-user')
self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0)
async def test_single_use_reservation_is_atomic(self):
invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com')
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4))
self.assertEqual(sum(results), 1)
+20
View File
@@ -0,0 +1,20 @@
import unittest
from types import SimpleNamespace
from prometheus_client import REGISTRY, generate_latest
from backend.app.metrics import record_api, record_remote
class MetricsTests(unittest.TestCase):
def test_route_template_not_private_path(self):
request = SimpleNamespace(method='GET', scope={'route': SimpleNamespace(path='/requests/{request_id}')})
before = REGISTRY.get_sample_value('magent_api_requests_total', {'method': 'GET', 'route': '/requests/{request_id}', 'status': '200'}) or 0
record_api(request, 200, .1)
self.assertEqual(REGISTRY.get_sample_value('magent_api_requests_total', {'method': 'GET', 'route': '/requests/{request_id}', 'status': '200'}), before + 1)
def test_unknown_route_and_service_are_bounded(self):
record_api(SimpleNamespace(method='SECRET-USER-METHOD', scope={}), 404, .01)
record_remote('secret-service-name', 'GET', 'error', .1)
data = generate_latest().decode()
self.assertNotIn('secret-service-name', data)
self.assertNotIn('SECRET-USER-METHOD', data)
self.assertIn('route="unmatched"', data)
+33
View File
@@ -0,0 +1,33 @@
import unittest
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app.routers import portal
class PortalPrivacyTests(unittest.TestCase):
def test_detail_and_comments_are_private_for_regular_users(self):
item = {'id': 1, 'kind': 'issue', 'title': 'Broken movie', 'status': 'new',
'created_by_username': 'private-reporter', 'created_by_id': 42,
'assignee_username': 'private-admin', 'metadata_json': '{"email":"secret@example.com"}',
'description': 'Contact private-reporter or secret@example.com', 'created_at': '2026-09-07'}
comment = {'id': 1, 'item_id': 1, 'author_username': 'private-admin', 'author_role': 'admin',
'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'}
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=[]), \
patch.object(portal, 'issue_resolution_state', return_value={}), \
patch.object(portal, 'get_all_users', return_value=[{'username': 'private-reporter', 'email': 'secret@example.com'}, {'username': 'private-admin'}]):
client = TestClient(app)
for path in ['/portal/items/1', '/portal/items/1/comments']:
response = client.get(path)
self.assertEqual(response.status_code, 200)
for secret in ['private-reporter', 'private-admin', 'secret@example.com', 'metadata_json', 'assignee_username', 'created_by_id']:
self.assertNotIn(secret, response.text)
admin_result = portal._serialize_item(item, {'username': 'admin', 'role': 'admin'})
self.assertEqual(admin_result['created_by_username'], 'private-reporter')
own_result = portal._serialize_item(item, {'username': 'private-reporter', 'role': 'user'})
self.assertTrue(own_result['permissions']['can_edit'])
@@ -0,0 +1,17 @@
import unittest
from backend.app import db
from backend.tests.test_backend_quality import TempDatabaseMixin
class SeerrUserVisibilityTests(TempDatabaseMixin, unittest.TestCase):
def test_seerr_only_users_are_visible(self):
db.create_user('local-admin', 'test-password', role='admin')
db.create_user('imported-member', 'jellyseerr-user', auth_provider='jellyseerr', jellyseerr_user_id=42)
self.assertEqual({u['username'] for u in db.get_all_users()}, {'local-admin', 'imported-member'})
def test_linked_duplicate_prefers_jellyfin(self):
db.create_user('member@example.com', 'jellyseerr-user', auth_provider='jellyseerr', jellyseerr_user_id=42)
db.create_user('member', 'jellyfin-user', auth_provider='jellyfin', jellyseerr_user_id=42)
users = db.get_all_users()
self.assertEqual(len(users), 1)
self.assertEqual(users[0]['username'], 'member')
+19
View File
@@ -0,0 +1,19 @@
import unittest
from unittest.mock import patch
from backend.app.config import Settings
from backend.app.routers.site import _build_site_info
class WelcomeSiteTests(unittest.TestCase):
def test_public_response_does_not_expose_playback_url(self):
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com'})):
self.assertNotIn('mediaServerUrl', _build_site_info(False))
def test_authenticated_response_uses_public_playback_url(self):
with patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': 'https://watch.example.com/web/'})):
self.assertEqual(_build_site_info(True)['mediaServerUrl'], 'https://watch.example.com/web/')
def test_missing_unsafe_or_credential_urls_have_no_watch_link(self):
for url in ['', 'javascript:alert(1)', '//internal', 'https://user:secret@example.com', 'https://[broken']:
with self.subTest(url=url), patch('backend.app.routers.site.get_runtime_settings', return_value=Settings().model_copy(update={'jellyfin_public_url': url})):
self.assertIsNone(_build_site_info(True)['mediaServerUrl'])
+13
View File
@@ -0,0 +1,13 @@
name: magent-production
services:
magent:
build: .
env_file:
- ./.env
ports:
- "10.30.1.32:3200:3000"
- "127.0.0.1:8200:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101012"><title>Coming soon | Magent — Grizzlyflix</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#101012;color:#f4f0ff;font-family:Arial,Helvetica,sans-serif}main{min-height:100svh;padding:60px 24px 28px;text-align:center;display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at 50% 20%,#282139,transparent 60%)}.brand{color:#c7bdff;letter-spacing:.3em;font-size:14px;font-weight:700;margin-bottom:36px}.badge{color:#8be7f1;border:1px solid #6eddec66;border-radius:30px;padding:10px 22px;font-size:12px;letter-spacing:.15em}h1{font-size:clamp(40px,7vw,88px);line-height:1.08;letter-spacing:-.045em;margin:28px 0 22px}h1 span{color:#c7bdff}p{max-width:560px;color:#bcb8c9;line-height:1.65;font-size:18px;margin:0}.steps{display:grid;grid-template-columns:repeat(3,1fr);width:min(580px,100%);margin:40px 0 24px;border:1px solid #ffffff20;border-radius:16px;background:#ffffff04}.steps div{padding:22px 12px;display:grid;gap:8px}.steps div+div{border-left:1px solid #ffffff15}.steps small{color:#8be7f1}.note{font-size:14px;color:#a9a4b5}footer{margin-top:60px;color:#a9a4b5;font-size:12px;display:flex;flex-wrap:wrap;justify-content:center;gap:14px}a{color:#c7bdff;text-underline-offset:3px}a:focus-visible{outline:2px solid #8be7f1;outline-offset:5px}
</style></head><body><main><div class="brand">GRIZZLYFLIX</div><div class="badge">COMING SOON</div><h1>Your next watch.<br><span>Made simpler.</span></h1><p>The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p><div class="steps" aria-label="Request journey"><div><small>01</small><strong>Request</strong></div><div><small>02</small><strong>Track</strong></div><div><small>03</small><strong>Watch</strong></div></div><p class="note">Were getting everything ready. Check back soon.</p><footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer></main></body></html>
@@ -0,0 +1,68 @@
# Historical production cutover notes — superseded
These notes describe the temporary AMS-DEV01 setup, not the current production
deployment. Do not run these cutover or rollback instructions against the live
service. See [current production instructions](../../PRODUCTION.md).
Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
overwritten. Main runs CI verification; production activation is deliberately
manual during the initial cutover.
Only API connection URLs/credentials and SMTP configuration are exported by
`scripts/prepare_production_settings.py`. It reads the source's effective settings,
uses an explicit allowlist, refuses existing output directories, and creates
private files. It never copies a database, users, invite codes, issues, history,
tokens, sessions, branding or notification templates. A new bootstrap admin and
JWT secret are generated. Retrieve the bootstrap credentials from the protected
`bootstrap-admin.json` on the server; never commit them.
The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
imports and repair emails. The cover is not an authentication/security boundary;
normal API authentication remains in force. Administrators can use `/login`.
Run `docker compose -f docker-compose.production.yml up -d --build` from the
production directory. Caddy should proxy this hostname to `10.30.1.32:3200`;
Next forwards `/api` internally. The backend health port is localhost-only at
8200. Do not alter beta's route or other Caddy sites.
Before public activation, validate Caddy config, save its existing configuration,
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
Do not send SMTP tests without approval. Keep the old upstream for rollback.
At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`,
then recreate the container. External service records can then be imported through
normal synchronization; no beta client data is migrated. Review quality profiles,
root folders, invite policy and notification rules in admin settings before use.
## Initial cutover — 7 September 2026
- Public HTTPS cover and `/api/health` verified after cutover.
- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`.
- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`.
- Only the `magent.grizzlyflix.co.nz` upstream changed, from
`10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged.
- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`.
Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then
`sudo systemctl reload caddy`. Review subsequent edits before restoring the
whole file; the old application was not stopped or deleted.
- Initial database: one newly generated bootstrap admin; zero invites, issues,
cached requests, actions or snapshots. Login smoke-testing subsequently creates
normal admin login activity only.
- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on
AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active.
- No SMTP message was sent as part of validation. Background jobs remain paused.
## Cover resilience update
The application host subsequently became unreachable over TCP from Caddy (both
3100 and 3200 timed out, despite responding to ping). The cover is now served
directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from
`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`.
It needs no application server, JavaScript, API or external assets.
Other paths retain the production reverse proxy. Full launch now also requires
removing the `@landing`/static `handle` block from the production Caddy site once
upstream connectivity is stable; the environment switch alone is insufficient.
Pre-static configuration backup:
`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`.
+47
View File
@@ -0,0 +1,47 @@
# 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. Reports and newsletters can build on this integration in a later beta increment; they are not included here.
## 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`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification 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.
-33
View File
@@ -1,33 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json ./
RUN npm install
COPY app ./app
COPY public ./public
COPY next-env.d.ts ./next-env.d.ts
COPY next.config.js ./next.config.js
COPY tsconfig.json ./tsconfig.json
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js ./next.config.js
EXPOSE 3000
CMD ["npm", "run", "start"]
+411
View File
@@ -0,0 +1,411 @@
'use client'
import PageHeading from './ui/PageHeading'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
type: item.type,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() {
const router = useRouter()
const [query, setQuery] = useState('')
const [recent, setRecent] = useState<
{
id: number
title: string
year?: number
type?: string
statusLabel?: string
artwork?: { poster_url?: string; backdrop_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = query.trim()
if (!trimmed) return
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`)
return
}
void runSearch(trimmed)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setRecentLoading(true)
setRecentError(null)
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`)
}
const me = await meResponse.json()
const userRole = me?.role ?? null
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
}
} catch (error) {
console.error(error)
setRecentError('Recent requests are not available right now.')
} finally {
setRecentLoading(false)
}
}
load()
}, [recentDays, recentStage])
useEffect(() => {
if (!authReady) {
return
}
if (!getToken()) {
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onmessage = (event) => {
if (closed) return
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
}
if (payload.type === 'home_recent') {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results))
setRecentError(null)
setRecentLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setRecentError('Recent requests are not available right now.')
setRecentLoading(false)
}
return
}
} catch (error) {
console.error(error)
}
}
} catch (error) {
if (closed) return
console.error(error)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setSearchResults(
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
}))
)
setSearchError(null)
}
} catch (error) {
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
}
}
const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null
return url.startsWith('http') ? url : `${getApiBase()}${url}`
}
const formatRequestTime = (value?: string | null) => {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return label.includes('ready') || label.includes('available')
}).length
const requestCardState = (value?: string) => {
const label = String(value ?? '').toLowerCase()
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
if (!/not |unavailable|waiting/.test(label) && (label.includes('ready') || label.includes('available'))) return { key: 'ready', label: value || 'Ready', progress: 100 }
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
}
return (
<main className="card home-page">
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
</form>
} />
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
<span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
</span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
</button>
))}
</div>
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
</div>
)}
</div>
{authReady && (
<div className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
<button
type="button"
key={option.value}
className={recentStage === option.value ? 'is-active' : undefined}
onClick={() => setRecentStage(option.value)}
>
{option.value === 'working' ? <i aria-hidden="true" /> : null}
{option.value === 'all' ? 'All' : option.label}
</button>
))}
</div>
)}
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
>
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
<span className="recent-info">
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
<span className="recent-status-badge">
<span aria-hidden="true">{({ ready: '✓', processing: '↻', attention: '!', waiting: '◷' })[requestCardState(item.statusLabel).key]}</span>
{item.statusLabel || 'Status not available yet'}
</span>
<span className="recent-meta">
Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
</main>
)
}
+18
View File
@@ -34,6 +34,7 @@ const SECTION_LABELS: Record<string, string> = {
seerr: 'Seerr', seerr: 'Seerr',
jellyseerr: 'Seerr', jellyseerr: 'Seerr',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
jellystat: 'Jellystat',
artwork: 'Artwork cache', artwork: 'Artwork cache',
cache: 'Request cache', cache: 'Request cache',
sonarr: 'Sonarr', sonarr: 'Sonarr',
@@ -98,6 +99,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
seerr: 'Connect Seerr where users submit content requests.', seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: '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.', 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.', artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.', cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.', sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
@@ -119,6 +121,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
seerr: 'jellyseerr', seerr: 'jellyseerr',
jellyseerr: 'jellyseerr', jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin', jellyfin: 'jellyfin',
jellystat: 'jellystat',
artwork: null, artwork: null,
sonarr: 'sonarr', sonarr: 'sonarr',
radarr: 'radarr', radarr: 'radarr',
@@ -305,6 +308,14 @@ const STANDARD_SECTION_GROUPS: Record<
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'], 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: [ jellyfin: [
{ {
key: 'jellyfin-connection', key: 'jellyfin-connection',
@@ -483,6 +494,8 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_webhook_url: 'Generic webhook URL', magent_notify_webhook_url: 'Generic webhook URL',
jellyfin_base_url: 'Internal server URL', jellyfin_base_url: 'Internal server URL',
jellyfin_api_key: 'Administrator API key', 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_public_url: 'Public playback URL',
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr', jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
sonarr_base_url: 'Sonarr server URL', sonarr_base_url: 'Sonarr server URL',
@@ -578,6 +591,7 @@ type SectionFeedback = {
const SERVICE_TEST_ENDPOINTS: Record<string, string> = { const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
'seerr-connection': 'seerr', 'seerr-connection': 'seerr',
'jellyfin-connection': 'jellyfin', 'jellyfin-connection': 'jellyfin',
'jellystat-connection': 'jellystat',
'sonarr-connection': 'sonarr', 'sonarr-connection': 'sonarr',
'radarr-connection': 'radarr', 'radarr-connection': 'radarr',
'bazarr-connection': 'bazarr', 'bazarr-connection': 'bazarr',
@@ -807,6 +821,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'], seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'], jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyfin: ['Jellyfin'], jellyfin: ['Jellyfin'],
jellystat: ['Jellystat'],
sonarr: ['Sonarr'], sonarr: ['Sonarr'],
radarr: ['Radarr'], radarr: ['Radarr'],
bazarr: ['Bazarr'], bazarr: ['Bazarr'],
@@ -995,6 +1010,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
jellyfin_base_url: jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.', 'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.', 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: jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).', '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.', jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
@@ -1076,6 +1093,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent', magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055', 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', 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', jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989', 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', 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', 'seerr',
'jellyseerr', 'jellyseerr',
'jellyfin', 'jellyfin',
'jellystat',
'artwork', 'artwork',
'sonarr', 'sonarr',
'radarr', 'radarr',
+1
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: [ { 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/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/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/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/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' }, { href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
+17
View File
@@ -0,0 +1,17 @@
import './style.css'
export const metadata = { title: 'Coming soon | Magent — Grizzlyflix' }
export default function ComingSoonPage() {
return <main className="launch-cover">
<div className="launch-brand">GRIZZLYFLIX</div>
<span className="launch-badge">COMING SOON</span>
<h1>Your next watch.<br /><em>Made simpler.</em></h1>
<p className="launch-intro">The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p>
<div className="launch-path" aria-label="Request journey">
{['Request', 'Track', 'Watch'].map((label, index) => <div key={label}><span>0{index + 1}</span><strong>{label}</strong></div>)}
</div>
<p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer>
</main>
}
+16
View File
@@ -0,0 +1,16 @@
.page:has(.launch-cover) { max-width: none; margin: 0; padding: 0; }
.launch-cover { box-sizing: border-box; min-height: 100svh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px 24px; text-align: center; background: radial-gradient(ellipse at 50% 25%, #252039 0%, transparent 55%), #101012; color: #f4f0ff; }
.launch-brand { font-size: 14px; letter-spacing: .3em; color: #c7bdff; font-weight: 700; margin-bottom: 36px; }
.launch-badge { border: 1px solid #6eddec66; color: #8be7f1; border-radius: 30px; padding: 8px 18px; font-size: 12px; letter-spacing: .15em; }
.launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; }
.launch-cover h1 em { color: #c7bdff; font-style: normal; }
.launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; }
.launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; }
.launch-path > div { padding: 22px 12px; display: grid; gap: 8px; }
.launch-path > div + div { border-left: 1px solid #ffffff15; }
.launch-path span { color: #8be7f1; font-size: 12px; }
.launch-path strong { font-size: 18px; }
.launch-note { color: #a9a4b5; font-size: 14px; }
.launch-cover footer { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; margin-top: 64px; font-size: 12px; color: #a9a4b5; }
.launch-cover footer a { color: #c7bdff; text-underline-offset: 3px; }
.launch-cover a:focus-visible { outline: 2px solid #8be7f1; outline-offset: 5px; }
+49 -174
View File
@@ -1,181 +1,56 @@
'use client'
import PageHeading from '../ui/PageHeading' import PageHeading from '../ui/PageHeading'
import '../welcome.css'
export default function HowItWorksPage() { export default function HowItWorksPage() {
return ( return <main className="friendly-guide">
<main className="card how-page"> <PageHeading title="A little help getting started." description="Magent looks after your requests. GrizzlyFlix is where you watch them." />
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." /> <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>
<section className="how-flow"> <ol>
<h2>What Magent is for</h2> <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>
<div className="how-grid"> <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>
<article className="how-card"> <li><strong>Check your choices and send it.</strong><p>Choose from the quality options shown. These come from the librarys settings.</p></li>
<h3>Track requests</h3> <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>
<p>
Search by title, year, or request number to open the request page and see where an
item is up to.
</p>
</article>
<article className="how-card">
<h3>See live progress</h3>
<p>
Request status, timeline events, and download progress update live while you are
viewing the page.
</p>
</article>
<article className="how-card">
<h3>Know when it is ready</h3>
<p>
When the request is fully imported and available, Magent shows it as ready and links
you through to Jellyfin.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>The request pipeline</h2>
<ol className="how-steps">
<li>
<strong>You request a movie or show</strong> through Seerr.
</li>
<li>
<strong>Magent picks up the request</strong> and shows its current state.
</li>
<li>
<strong>The automation stack searches and downloads it</strong> if it can find a valid
release.
</li>
<li>
<strong>The file is imported into the library</strong>.
</li>
<li>
<strong>Jellyfin serves it</strong> once it is ready to watch.
</li>
</ol> </ol>
</section> </details>
<details><summary>Understand the six progress steps</summary>
<section className="how-flow"> <ol>
<h2>What the statuses usually mean</h2> <li><strong>Requested:</strong> Your request has been received.</li>
<div className="how-grid"> <li><strong>Approved:</strong> It has permission to go ahead.</li>
<article className="how-card"> <li><strong>Library collection:</strong> The library is tracking whats collected and whats missing.</li>
<h3>Pending</h3> <li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt a good match yet.</li>
<p>The request exists, but it is still waiting for approval or the next step.</p> <li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li>
</article> <li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li>
<article className="how-card">
<h3>Approved / Processing</h3>
<p>The request has been accepted and the automation tools are working on it.</p>
</article>
<article className="how-card">
<h3>Downloading</h3>
<p>Magent can show live progress while the content is still being downloaded.</p>
</article>
<article className="how-card">
<h3>Ready</h3>
<p>The item has been imported and should now be available in Jellyfin.</p>
</article>
<article className="how-card">
<h3>Partial / Waiting</h3>
<p>
Part of the workflow completed, but the request is still waiting on another service or
on content becoming available.
</p>
</article>
<article className="how-card">
<h3>Declined</h3>
<p>The request was rejected or cannot proceed in its current form.</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Live updates you can expect</h2>
<div className="how-step-grid">
<article className="how-step-card step-seerr">
<div className="step-badge">1</div>
<h3>Recent requests refresh automatically</h3>
<p className="step-note">
Your request list and landing-page activity update automatically while you are signed
in.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Request pages update in real time</h3>
<p className="step-note">
State changes, timeline steps, and downloader progress are pushed to the page live.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as the import completes</h3>
<p className="step-note">
Once the content is actually available, Magent updates the request page without a hard
refresh.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>User actions you may see</h2>
<div className="how-grid">
<article className="how-card">
<h3>Open request</h3>
<p>Jump into the full request page to inspect the current state and activity.</p>
</article>
<article className="how-card">
<h3>Open in Jellyfin</h3>
<p>Appears when the request is ready and Magent can link you through for playback.</p>
</article>
<article className="how-card">
<h3>Search + auto-download</h3>
<p>
Only appears for accounts that have been granted self-service download access by the
admin team.
</p>
</article>
<article className="how-card">
<h3>My invites</h3>
<p>
If your account is allowed to invite others, you can create and manage invite links
from your profile.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Invites and signup</h2>
<ol className="how-steps">
<li>
<strong>You receive an invite link</strong> by email or directly from the person who
invited you.
</li>
<li>
<strong>You sign up through Magent</strong> and your account is linked into the media
stack.
</li>
<li>
<strong>Your account defaults apply</strong> based on the invite or your assigned
profile.
</li>
<li>
<strong>You sign in and track requests</strong> from the landing page and your request
pages.
</li>
</ol> </ol>
</section> <p>A finished download still needs to be added to the media library. Wait for Available to watch before heading over.</p>
</details>
<section className="how-callout"> <details><summary>Something looks stuck</summary>
<h2>If a request looks stuck</h2> <ol>
<p> <li><strong>Open the request.</strong><p>Read its current status and next step.</p></li>
A waiting request usually means no usable release has been found yet, the download is <li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li>
still in progress, or the import has not completed. Magent will keep updating as the <li><strong>Follow the action offered.</strong><p>You may be able to restart a search or review suitable releases. Choose Best pick when offered if youre unsure.</p></li>
underlying services move forward. </ol>
</p> <p>Remote activity explains the latest check. Open it to see the full list. A successful search doesnt always mean a download was found.</p>
</section> </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>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>
<li><strong>Tell us if it worked.</strong><p>When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose Yes if its fixed or No if you still need help.</p></li>
</ol>
<p>Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure timings depend on the sites settings.</p>
</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>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>
<footer>Ready? <a href="/welcome">Choose where to go next </a></footer>
</main> </main>
)
} }
+133
View File
@@ -0,0 +1,133 @@
'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 './stats.css'
type Breakdown = { name: string; minutes: number }
type Day = { date: string; minutes: number }
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 }[]
clients?: Breakdown[]
methods?: Breakdown[]
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
}
const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 })
const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
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>
)
}
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>
}
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>} />
<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 ?? []} />
<BreakdownCard title="How you streamed" rows={data.methods ?? []} />
</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}><div className={`stats-media-icon stats-media-icon-${play.type}`} aria-hidden="true">{play.type === 'episode' ? 'TV' : play.type === 'movie' ? 'MV' : '▶'}</div><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.client}</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>
)
}
+95
View File
@@ -0,0 +1,95 @@
.stats-page { padding-bottom: 32px !important; }
.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 40px; height: 48px; border-radius: 6px; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
.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; }
}
+1 -1
View File
@@ -71,7 +71,7 @@ export default function LoginPage() {
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie') setToken('cookie')
const next = new URLSearchParams(window.location.search).get('next') || '' const next = new URLSearchParams(window.location.search).get('next') || ''
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/') window.location.assign(next === '/insights' || /^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
} catch { } catch {
setError('Could not reach Magent. Check your connection and try again.') setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) } } finally { setLoading(false) }
+63 -6
View File
@@ -3121,12 +3121,31 @@ textarea:focus {
font-size: 0.68rem; font-size: 0.68rem;
} }
.home-recent-grid .recent-meta::before { .home-recent-grid .recent-meta::before {
width: 7px; content: none;
height: 7px;
border-radius: 50%;
background: var(--ops-faint);
content: "";
} }
.home-recent-grid .recent-status-badge {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 9px;
max-width: 100%;
padding: 8px 12px;
border: 1px solid #9c8044;
border-radius: 8px;
background: #352c1b;
color: #ffe0a0;
font-family: "DM Sans", sans-serif;
font-size: 1rem;
font-weight: 700;
line-height: 1.35;
text-align: left;
text-transform: none;
overflow-wrap: anywhere;
}
.home-recent-grid .recent-status-badge > span { flex: 0 0 auto; font-size: 1.2rem; }
.home-recent-grid .is-ready .recent-status-badge { color: #a7f3cd; background: #16372b; border-color: #398663; }
.home-recent-grid .is-processing .recent-status-badge { color: #b6e6ff; background: #183344; border-color: #448bad; }
.home-recent-grid .is-attention .recent-status-badge { color: #ffd2b4; background: #40281e; border-color: #aa7150; }
.home-recent-grid .is-processing .recent-meta::before { background: var(--ops-primary-2); } .home-recent-grid .is-processing .recent-meta::before { background: var(--ops-primary-2); }
.home-recent-grid .is-attention .recent-meta::before { background: var(--ops-coral); } .home-recent-grid .is-attention .recent-meta::before { background: var(--ops-coral); }
.home-recent-grid .is-ready .recent-meta::before { background: var(--ops-green); } .home-recent-grid .is-ready .recent-meta::before { background: var(--ops-green); }
@@ -3612,13 +3631,51 @@ textarea:focus {
} }
.issue-portal-page .portal-item-list { .issue-portal-page .portal-item-list {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px; gap: 8px;
max-height: 100%; max-height: 100%;
padding-right: 4px; padding-right: 4px;
} }
.issue-portal-page .portal-item-row { .issue-portal-page .portal-item-row {
padding: 11px; display: block;
flex: 0 0 auto;
width: 100%;
height: auto;
min-height: min-content;
max-height: none;
padding: 14px;
white-space: normal;
text-align: left;
line-height: 1.45;
}
.issue-portal-page .portal-item-row-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.issue-portal-page .portal-item-row-title strong {
flex-basis: 100%;
min-width: 0;
font-size: 0.95rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.issue-portal-page .portal-item-row-meta {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ops-line-soft);
line-height: 1.5;
overflow-wrap: anywhere;
}
.issue-portal-page .portal-item-row-meta > span:last-child {
flex-basis: 100%;
} }
.issue-portal-page .portal-item-row p { .issue-portal-page .portal-item-row p {
+5 -403
View File
@@ -1,407 +1,9 @@
'use client' import { redirect } from 'next/navigation'
import MyRequests from './MyRequests'
import PageHeading from './ui/PageHeading' export const dynamic = 'force-dynamic'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
type: item.type,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() { export default function HomePage() {
const router = useRouter() if (process.env.MAGENT_COMING_SOON === 'true') redirect('/coming-soon')
const [query, setQuery] = useState('') return <MyRequests />
const [recent, setRecent] = useState<
{
id: number
title: string
year?: number
type?: string
statusLabel?: string
artwork?: { poster_url?: string; backdrop_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState<
{
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([])
const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = query.trim()
if (!trimmed) return
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`)
return
}
void runSearch(trimmed)
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setRecentLoading(true)
setRecentError(null)
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`)
}
const me = await meResponse.json()
const userRole = me?.role ?? null
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
}
} catch (error) {
console.error(error)
setRecentError('Recent requests are not available right now.')
} finally {
setRecentLoading(false)
}
}
load()
}, [recentDays, recentStage])
useEffect(() => {
if (!authReady) {
return
}
if (!getToken()) {
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onmessage = (event) => {
if (closed) return
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
}
if (payload.type === 'home_recent') {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results))
setRecentError(null)
setRecentLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setRecentError('Recent requests are not available right now.')
setRecentLoading(false)
}
return
}
} catch (error) {
console.error(error)
}
}
} catch (error) {
if (closed) return
console.error(error)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`)
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setSearchResults(
data.results.map((item: any) => ({
title: item.title,
year: item.year,
type: item.type,
requestId: item.requestId,
statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
}))
)
setSearchError(null)
}
} catch (error) {
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
}
}
const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null
return url.startsWith('http') ? url : `${getApiBase()}${url}`
}
const formatRequestTime = (value?: string | null) => {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return label.includes('ready') || label.includes('available')
}).length
const requestCardState = (value?: string) => {
const label = String(value ?? '').toLowerCase()
if (label.includes('ready') || label.includes('available')) return { key: 'ready', label: value || 'Ready', progress: 100 }
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
}
return (
<main className="card home-page">
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
</form>
} />
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
<span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
</span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
</button>
))}
</div>
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
</div>
)}
</div>
{authReady && (
<div className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
<button
type="button"
key={option.value}
className={recentStage === option.value ? 'is-active' : undefined}
onClick={() => setRecentStage(option.value)}
>
{option.value === 'working' ? <i aria-hidden="true" /> : null}
{option.value === 'all' ? 'All' : option.label}
</button>
))}
</div>
)}
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
>
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
<span className="recent-info">
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
<span className="recent-meta">
{item.statusLabel || 'Status not available yet'} · Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
</main>
)
} }
+1 -1
View File
@@ -2242,7 +2242,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
{selectedItem.kind === 'request' ? 'Request' : 'Issue'} #{selectedItem.id} {selectedItem.kind === 'request' ? 'Request' : 'Issue'} #{selectedItem.id}
</h2> </h2>
<p className="lede"> <p className="lede">
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)} {isAdmin ? `Created by ${selectedItem.created_by_username} on ` : 'Reported on '}{formatDate(selectedItem.created_at)}
</p> </p>
{selectedItem.kind === 'issue' ? ( {selectedItem.kind === 'issue' ? (
<p className="lede"> <p className="lede">
+11 -4
View File
@@ -7,6 +7,7 @@ import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = { type InviteInfo = {
code: string code: string
email_bound?: boolean
label?: string | null label?: string | null
description?: string | null description?: string | null
enabled: boolean enabled: boolean
@@ -38,14 +39,15 @@ function SignupPageContent() {
const [inviteLoading, setInviteLoading] = useState(false) const [inviteLoading, setInviteLoading] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
const canSubmit = useMemo(() => { const canSubmit = useMemo(() => {
return Boolean(invite?.is_usable && username.trim() && password && !loading) return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading)
}, [invite, username, password, loading]) }, [invite, email, username, password, loading, inviteLoading])
const lookupInvite = async (code: string) => { const lookupInvite = async (code: string) => {
const trimmed = code.trim() const trimmed = code.trim()
@@ -110,6 +112,7 @@ function SignupPageContent() {
body: JSON.stringify({ body: JSON.stringify({
invite_code: inviteCode, invite_code: inviteCode,
username: username.trim(), username: username.trim(),
...(!invite.email_bound ? { email: email.trim() } : {}),
password, password,
}), }),
}) })
@@ -120,7 +123,7 @@ function SignupPageContent() {
const data = await response.json() const data = await response.json()
if (data?.authenticated) { if (data?.authenticated) {
setToken('cookie') setToken('cookie')
window.location.href = '/' window.location.href = '/welcome'
return return
} }
throw new Error('Sign-up did not complete') throw new Error('Sign-up did not complete')
@@ -140,7 +143,7 @@ function SignupPageContent() {
<div className="invite-lookup-row"> <div className="invite-lookup-row">
<input <input
value={inviteCode} value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)} onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }}
placeholder="Paste your invite code" placeholder="Paste your invite code"
autoCapitalize="characters" autoCapitalize="characters"
/> />
@@ -171,6 +174,10 @@ function SignupPageContent() {
</div></details> </div></details>
</div> </div>
)} )}
{invite?.email_bound ? <p className="account-hint">Your account will use the email address this invitation was sent to. This invitation can be used once.</p> : <label>
Email address
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" placeholder="you@example.com" />
</label>}
<label> <label>
Username Username
<input <input
+1 -1
View File
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
export default function ApplicationChrome() { export default function ApplicationChrome() {
const pathname = usePathname() const pathname = usePathname()
if (['/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
return <> return <>
<header className="header"> <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> <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>
+5
View File
@@ -75,6 +75,11 @@ export default function HeaderActions() {
] ]
const commonItems = [ const commonItems = [
{
href: '/insights',
label: 'My Stats',
match: (path: string) => path === '/insights',
},
{ {
href: '/', href: '/',
label: 'My Requests', label: 'My Requests',
+2
View File
@@ -93,6 +93,8 @@ export default function HeaderIdentity() {
{viewAsUser ? <span>Previewing user view</span> : null} {viewAsUser ? <span>Previewing user view</span> : null}
</div> </div>
<div className="signed-in-actions"> <div className="signed-in-actions">
<a href="/welcome" onClick={() => setOpen(false)}>Welcome page</a>
<a href="/how-it-works" onClick={() => setOpen(false)}>How it works</a>
<a href="/profile" onClick={() => setOpen(false)}> <a href="/profile" onClick={() => setOpen(false)}>
My profile My profile
</a> </a>
+1 -1
View File
@@ -10,7 +10,7 @@ export default function InviteDeliveryChoice({ value, onChange }: {
<span className="delivery-choice-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"> <span className="delivery-choice-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
{method === 'manual' ? <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" /></> : <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="m3 7 9 6 9-6" /></>} {method === 'manual' ? <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" /></> : <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="m3 7 9 6 9-6" /></>}
</svg></span> </svg></span>
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'Share it yourself. No email needed.' : 'Well send the invite. You get the link too.'}</small></span> <span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'They add their email when signing up.' : 'One use, tied to the recipients email. You get the link too.'}</small></span>
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span> <span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
</button>)} </button>)}
</div> </div>
-59
View File
@@ -1,59 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'magent_theme'
const getPreferredTheme = () => {
if (typeof window === 'undefined') return 'dark'
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') {
return stored
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const applyTheme = (theme: string) => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', theme)
}
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const preferred = getPreferredTheme()
setTheme(preferred)
applyTheme(preferred)
}, [])
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
applyTheme(next)
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next)
}
}
return (
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v3M12 19v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M2 12h3M19 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3a8.5 8.5 0 1 0 11.5 11.5z" />
</svg>
)}
</button>
)
}
+4 -2
View File
@@ -8,12 +8,13 @@ type NavigationItem = {
href: string href: string
label: string label: string
shortLabel: string shortLabel: string
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats'
adminOnly?: boolean adminOnly?: boolean
match: (path: string) => boolean match: (path: string) => boolean
} }
const NAVIGATION: NavigationItem[] = [ const NAVIGATION: NavigationItem[] = [
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' },
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') }, { 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: '/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') }, { href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
@@ -25,6 +26,7 @@ const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) { function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
const paths: Record<NavigationItem['icon'], React.ReactNode> = { 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" /></>, 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" /></>, 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" /></>, issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
@@ -67,7 +69,7 @@ export default function WorkspaceNavigation() {
return ( return (
<> <>
<nav className="workspace-mobile-nav" aria-label="Mobile navigation"> <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}> <a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span> <NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
</a> </a>
+40 -1
View File
@@ -15,6 +15,7 @@ type AdminUser = {
lastLoginAt?: string | null lastLoginAt?: string | null
isBlocked?: boolean isBlocked?: boolean
autoSearchEnabled?: boolean autoSearchEnabled?: boolean
inviteManagementEnabled?: boolean
profileId?: number | null profileId?: number | null
expiresAt?: string | null expiresAt?: string | null
isExpired?: boolean isExpired?: boolean
@@ -88,6 +89,8 @@ export default function UsersPage() {
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false) const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false) const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false) const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const [bulkInvitesBusy, setBulkInvitesBusy] = useState(false)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -116,6 +119,7 @@ export default function UsersPage() {
lastLoginAt: user.last_login_at ?? null, lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked), isBlocked: Boolean(user.is_blocked),
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true), autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
inviteManagementEnabled: Boolean(user.invite_management_enabled),
profileId: profileId:
user.profile_id == null || Number.isNaN(Number(user.profile_id)) user.profile_id == null || Number.isNaN(Number(user.profile_id))
? null ? null
@@ -192,6 +196,25 @@ 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) => { const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true) setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null) setJellyseerrSyncStatus(null)
@@ -233,6 +256,7 @@ export default function UsersPage() {
const nonAdminUsers = users.filter((user) => user.role !== 'admin') const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length 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 blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length const adminCount = users.filter((user) => user.role === 'admin').length
@@ -348,7 +372,7 @@ export default function UsersPage() {
<div> <div>
<h2>Bulk controls</h2> <h2>Bulk controls</h2>
<p className="lede"> <p className="lede">
Auto search/download can be enabled or disabled for all non-admin users. Manage permissions for all existing non-admin users, not just the current search results.
</p> </p>
</div> </div>
</div> </div>
@@ -378,6 +402,21 @@ export default function UsersPage() {
</div> </div>
</div> </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>
<div className="admin-panel user-directory-search-panel"> <div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header"> <div className="user-directory-panel-header">
<div> <div>
+21
View File
@@ -0,0 +1,21 @@
.welcome-page, .friendly-guide { width: min(100%, 1040px); margin: 40px auto; color: var(--ops-text); }
.welcome-page > header { text-align: center; margin-bottom: 32px; }
.welcome-kicker { color: var(--ops-cyan); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; }
.welcome-page h1 { font-size: clamp(30px, 5vw, 48px); line-height: 1.15; margin: 16px 0; }
.welcome-page p, .friendly-guide p { color: var(--ops-muted); line-height: 1.65; }
.welcome-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.welcome-choice { display: flex; flex-direction: column; align-items: flex-start; padding: 32px; gap: 16px; background: var(--ops-panel); border: 1px solid var(--ops-line); border-radius: 16px; text-decoration: none; color: inherit; }
.welcome-choice h2, .welcome-choice p { margin: 0; }
.welcome-choice strong { color: var(--ops-primary-2); margin-top: auto; padding-top: 16px; }
a.welcome-choice:hover { border-color: var(--ops-cyan); background: var(--ops-panel-2); }
.welcome-icon { color: var(--ops-cyan); font-size: 36px; line-height: 1; }
.welcome-page footer { text-align: center; margin-top: 28px; color: var(--ops-muted); }
.welcome-page a:focus-visible, .friendly-guide a:focus-visible, .friendly-guide summary:focus-visible { outline: 3px solid var(--ops-cyan); outline-offset: 5px; }
.friendly-guide > nav { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 24px; }
.friendly-guide details { border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); margin: 12px 0; padding: 20px 24px; }
.friendly-guide summary { font-weight: 700; font-size: 19px; cursor: pointer; }
.friendly-guide ol { padding-left: 24px; }
.friendly-guide li { padding: 8px 0 8px 8px; line-height: 1.6; }
.friendly-guide li p { margin: 4px 0; }
.friendly-guide > footer { padding: 20px 0; }
@media (max-width: 640px) { .welcome-choices { grid-template-columns: 1fr; } .welcome-page, .friendly-guide { margin: 24px auto; } .welcome-choice { padding: 24px; } }
+36
View File
@@ -0,0 +1,36 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase } from '../lib/auth'
import '../welcome.css'
export default function WelcomePage() {
const [ready, setReady] = useState(false)
const [url, setUrl] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void (async () => {
try {
const response = await authFetch(`${getApiBase()}/site/info`, { signal: controller.signal })
if (response.status === 401) { clearToken(); window.location.replace('/login'); return }
if (!response.ok) throw new Error('Unavailable')
const data = await response.json()
const candidate = data.mediaServerUrl ? new URL(data.mediaServerUrl) : null
if (candidate && ['https:', 'http:'].includes(candidate.protocol) && !candidate.username && !candidate.password) setUrl(candidate.href)
setReady(true)
} catch {
if (!controller.signal.aborted) setError('We couldnt load your welcome page. Please try again.')
}
})()
return () => controller.abort()
}, [])
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">
{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>
</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%; } .page-heading .home-search { width: 100%; }
} }
@media (max-width: 680px) { @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; } :root { --workspace-gutter: 16px; --workspace-gap: 20px; }
.page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; } .page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
.page-heading { padding-bottom: 20px; } .page-heading { padding-bottom: 20px; }
+21
View File
@@ -0,0 +1,21 @@
# Magent monitoring
Grafana dashboard: `grafana/magent-api-performance.json` (Prometheus UID `prometheus`).
Set `MAGENT_METRICS_ENABLED=true`, `MAGENT_METRICS_BIND=0.0.0.0` and
`MAGENT_METRICS_PORT=9108` inside the container. Publish port 9108 **only on a
private interface**; do not proxy it through the public website. By default the
listener is disabled and its bind address is loopback.
Production publishes `100.114.113.88:9108:9108` on GRZ-DKR01's Tailscale interface.
Prometheus on ANA-DKR01 scrapes it every 15 seconds with job name `magent`.
Grafana's existing file provider loads the dashboard from its Magent folder.
API labels contain method, matched route template and HTTP status, never raw
paths, query values, usernames or credentials. API latency measures time to
response headers, not long-lived event-stream duration. Service metrics cover
the shared ApiClient, including background calls; custom client paths and CSRF
subrequests are not separate calls. CPU/memory refer to the Python backend only.
Metrics start at deployment, with no historical backfill. Rate/percentile panels
need multiple scrapes; unused services have no series until called. Prometheus
retains history across Magent restarts, while process counters reset normally.
@@ -0,0 +1,474 @@
{
"uid": "magent-api-performance",
"title": "Magent — API & Performance",
"tags": [
"magent",
"production"
],
"schemaVersion": 40,
"version": 1,
"refresh": "15s",
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"editable": true,
"description": "Metrics begin when instrumentation is deployed. No historical backfill. API timings are time-to-headers. Outbound metrics cover shared ApiClient calls; CPU/memory cover the Python backend. No user IDs, usernames, tokens, search terms or raw URLs are labels.",
"panels": [
{
"id": 1,
"title": "Magent metrics reachable",
"description": "1 = scrape healthy; 0 = unavailable.",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "up{job=\"magent\"}",
"legendFormat": "Magent"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value"
}
},
{
"id": 2,
"title": "API calls / second",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "Calls / sec"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 3,
"title": "API response time — p95 by route",
"description": "Time to response headers; streaming session lifetime is excluded.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 8,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (le, route) (rate(magent_api_response_seconds_bucket{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{route}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 4,
"title": "API responses by status",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 8,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (status) (rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "HTTP {{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 5,
"title": "API server error percentage",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 16,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "100 * (sum(rate(magent_api_requests_total{job=\"magent\",status=~\"5..\"}[$__rate_interval])) or vector(0)) / clamp_min(sum(rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval])), 0.000001)",
"legendFormat": "5xx"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 6,
"title": "Busiest API routes",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 16,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "topk(10,sum by (route) (rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{route}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 7,
"title": "Connected service calls / second",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 24,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (service) (rate(magent_remote_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "{{service}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 8,
"title": "Connected services — p95 response time",
"description": "Instrumented shared API-client calls, including background work. Does not count every low-level HTTP exchange.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 24,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95,sum by (le,service) (rate(magent_remote_response_seconds_bucket{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{service}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 9,
"title": "Service redirects and errors",
"description": "error = connection/transport failure. Redirects are shown because they can prevent API operations.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 32,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (service,status) (rate(magent_remote_requests_total{job=\"magent\",status=~\"3..|4..|5..|error\"}[$__rate_interval]))",
"legendFormat": "{{service}} · {{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 10,
"title": "Backend memory",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 32,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "process_resident_memory_bytes{job=\"magent\"}",
"legendFormat": "Python backend"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 11,
"title": "Backend CPU — cores used",
"description": "Backend process only, not the frontend or whole host.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 40,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "rate(process_cpu_seconds_total{job=\"magent\"}[$__rate_interval])",
"legendFormat": "CPU cores"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 12,
"title": "Backend uptime",
"description": "",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 40,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "time() - process_start_time_seconds{job=\"magent\"}",
"legendFormat": "Uptime"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value"
}
}
]
}
-31
View File
@@ -1,31 +0,0 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
if ($LASTEXITCODE -ne 0) {
throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
}
$now = Get-Date
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
Write-Host "Build number: $buildNumber"
git tag $buildNumber
git push origin $buildNumber
$backendImage = "rephl3xnz/magent-backend:$buildNumber"
$frontendImage = "rephl3xnz/magent-frontend:$buildNumber"
docker build -f backend/Dockerfile -t $backendImage --build-arg BUILD_NUMBER=$buildNumber .
docker build -f frontend/Dockerfile -t $frontendImage frontend
docker tag $backendImage rephl3xnz/magent-backend:latest
docker tag $frontendImage rephl3xnz/magent-frontend:latest
docker push $backendImage
docker push $frontendImage
docker push rephl3xnz/magent-backend:latest
docker push rephl3xnz/magent-frontend:latest
+10
View File
@@ -0,0 +1,10 @@
function Set-EnvBuildNumber {
param(
[AllowEmptyString()][string]$Content,
[Parameter(Mandatory = $true)][string]$BuildNumber
)
if ($BuildNumber -notmatch '^\d+$') { throw 'Build number must contain digits only.' }
$newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" }
$remaining = [regex]::Replace($Content, '(?m)^[\t ]*(?:export[\t ]+)?BUILD_NUMBER[\t ]*=[^\r\n]*(?:\r?\n|$)', '')
return "BUILD_NUMBER=$BuildNumber$newline$remaining"
}
+59
View File
@@ -0,0 +1,59 @@
"""Run inside the configured source container. Export configuration, NEVER data.
Usage: python prepare_production_settings.py /secure/new-directory
Creates new files exclusively with mode 0600. No secrets go to stdout.
"""
import json
import os
from pathlib import Path
import secrets
import sys
from app.runtime import get_runtime_settings
def prepare(destination: Path) -> None:
runtime = get_runtime_settings()
keys = [
'jellyfin_base_url', 'jellyfin_api_key', 'jellyfin_public_url',
'jellyseerr_base_url', 'jellyseerr_api_key',
'sonarr_base_url', 'sonarr_api_key', 'radarr_base_url', 'radarr_api_key',
'prowlarr_base_url', 'prowlarr_api_key', 'bazarr_base_url', 'bazarr_api_key',
'qbittorrent_base_url', 'qbittorrent_username', 'qbittorrent_password',
'magent_notify_enabled', 'magent_notify_email_enabled',
'magent_notify_email_smtp_host', 'magent_notify_email_smtp_port',
'magent_notify_email_smtp_username', 'magent_notify_email_smtp_password',
'magent_notify_email_from_address', 'magent_notify_email_from_name',
'magent_notify_email_use_tls', 'magent_notify_email_use_ssl',
]
values = {key.upper(): getattr(runtime, key) for key in keys if getattr(runtime, key, None) is not None}
password = secrets.token_urlsafe(30)
values.update(
APP_NAME='Magent', JWT_SECRET=secrets.token_urlsafe(48),
ADMIN_USERNAME='admin', ADMIN_PASSWORD=password,
AUTH_COOKIE_SECURE=True, AUTH_COOKIE_DOMAIN='magent.grizzlyflix.co.nz',
AUTH_COOKIE_NAME='magent_auth', AUTH_STATE_COOKIE_NAME='magent_logged_in',
CORS_ALLOW_ORIGIN='https://magent.grizzlyflix.co.nz',
MAGENT_APPLICATION_URL='https://magent.grizzlyflix.co.nz',
MAGENT_API_URL='https://magent.grizzlyflix.co.nz/api',
SQLITE_PATH='/app/data/magent.db', LOG_FILE='/app/data/magent.log',
SITE_BANNER_ENABLED=False, MAGENT_COMING_SOON=True,
BACKGROUND_TASKS_ENABLED=False,
)
destination.mkdir(mode=0o700, parents=True, exist_ok=False)
def write_private(name, content):
with os.fdopen(os.open(destination / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w') as stream:
stream.write(content)
# Compose single-quoted values preserve dollar signs in SMTP passwords.
def encode(value):
text = str(value).lower() if isinstance(value, bool) else str(value)
if '\n' in text or '\r' in text:
raise ValueError('Multiline configuration values require manual review')
return "'" + text.replace('\\', '\\\\').replace("'", "\\'") + "'"
write_private('.env', ''.join(f'{key}={encode(value)}\n' for key, value in values.items()))
write_private('bootstrap-admin.json', json.dumps({'username': 'admin', 'password': password}))
print(f'Prepared {len(keys)} allowlisted connection settings; fresh session and admin credentials. No client records copied.')
if __name__ == '__main__':
prepare(Path(sys.argv[1]))
+3 -10
View File
@@ -5,6 +5,7 @@ param(
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot 'env_build_number.ps1')
$repoRoot = Resolve-Path "$PSScriptRoot\.." $repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot Set-Location $repoRoot
@@ -172,16 +173,8 @@ function Update-BuildFiles {
$envPath = Join-Path $repoRoot ".env" $envPath = Join-Path $repoRoot ".env"
if (Test-Path $envPath) { if (Test-Path $envPath) {
$envContent = Read-TextFile -Path ".env" $envContent = Read-TextFile -Path ".env"
if ($envContent -match '^BUILD_NUMBER=.*$') { # Remove previous assignments before adding exactly one build number.
$updatedEnv = [regex]::Replace( $updatedEnv = Set-EnvBuildNumber -Content $envContent -BuildNumber $BuildNumber
$envContent,
'^BUILD_NUMBER=.*$',
"BUILD_NUMBER=$BuildNumber",
[System.Text.RegularExpressions.RegexOptions]::Multiline
)
} else {
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
}
Write-TextFile -Path ".env" -Content $updatedEnv Write-TextFile -Path ".env" -Content $updatedEnv
} }
+20
View File
@@ -0,0 +1,20 @@
const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const page = await browser.newPage()
await page.route('**/api/**', route => route.fulfill({ json: {} }))
await page.goto('http://127.0.0.1:3101/')
assert.ok(page.url().endsWith('/coming-soon'))
await page.getByRole('heading', { name: 'Your next watch. Made simpler.' }).waitFor()
assert.equal(await page.locator('.header').count(), 0)
assert.equal(await page.getByRole('link', { name: 'Admin sign in' }).getAttribute('href'), '/login')
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 })
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
if (process.env.REVIEW_DIR) await page.screenshot({ path: `${process.env.REVIEW_DIR}/coming-soon-${width}.png`, fullPage: true })
}
console.log('PASS: cover redirect, isolated layout, admin login link and responsive widths')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
+129
View File
@@ -0,0 +1,129 @@
// Fixture-only review. No requests reach Jellystat or a live Magent backend.
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://127.0.0.1:3114'
const output = process.env.REVIEW_DIR
;(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 }])
let mode = 'ready'
let role = 'admin'
const periods = []
const mutations = []
let settings = [
{ key: 'jellystat_base_url', value: 'http://jellystat:3000', isSet: true, source: 'environment', sensitive: false },
{ key: 'jellystat_api_key', value: null, isSet: true, source: 'environment', sensitive: true },
]
const daily = Array.from({ length: 31 }, (_, i) => ({ date: new Date(Date.UTC(2026, 7, 8 + i)).toISOString().slice(0, 10), minutes: i % 4 ? 30 + i * 2 : 0 }))
const fixture = (days) => ({
state: mode, is_admin: role === 'admin', days, source: 'Jellystat', timezone: 'UTC', updated_at: '2026-09-07T12:00:00Z',
summary: { minutes: daily.reduce((sum, day) => sum + day.minutes, 0), movies: 8, episodes: 23, plays: 35, active_days: 21, current_streak: 3, longest_streak: 6 },
daily,
top_titles: [{ title: 'Severance', type: 'series', minutes: 460, plays: 10 }, { title: 'Arrival', type: 'movie', minutes: 116, plays: 1 }, { title: 'The Bear', type: 'series', minutes: 91, plays: 3 }],
clients: [{ name: 'Jellyfin Web', minutes: 740 }, { name: 'Jellyfin for Android TV', minutes: 301 }],
methods: [{ name: 'Direct play', minutes: 880 }, { name: 'Transcode', minutes: 161 }],
recent: [{ id: '1', title: 'Good News About Hell', series: 'Severance', type: 'episode', episode: 'S1 · E1', minutes: 57, played_at: '2026-09-07T10:00:00Z', client: 'Jellyfin Web', method: 'Direct play' },
{ id: '2', title: 'Arrival', series: '', type: 'movie', minutes: 116, played_at: '2026-09-06T10:00:00Z', client: 'Jellyfin for Android TV', method: 'Direct play' }],
requests: { total: 3, movies: 2, tv: 1, pending: 1, approved: 2, declined: 0, recent: [{ request_id: 12, title: 'Dune: Part Two', media_type: 'movie', status: 2 }] },
})
await context.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role } })
if (url.pathname === '/api/insights') {
const days = Number(url.searchParams.get('days'))
periods.push(days)
if (mode === 'unauthorized') return route.fulfill({ status: 401, json: { detail: 'Sign in' } })
if (mode === 'unavailable') return route.fulfill({ status: 502, json: { detail: 'Your viewing stats are temporarily unavailable. Please try again shortly.' } })
const result = fixture(days)
if (mode === 'empty') {
result.state = 'ready'
for (const key of Object.keys(result.summary)) result.summary[key] = 0
result.daily = daily.map((day) => ({ ...day, minutes: 0 }))
result.top_titles = result.recent = result.clients = result.methods = []
} else if (mode !== 'ready') result.summary = null
return route.fulfill({ json: result })
}
if (url.pathname === '/api/admin/settings') {
if (request.method() === 'PUT') {
const body = request.postDataJSON()
mutations.push({ path: url.pathname, body })
settings = settings.map((setting) => Object.hasOwn(body, setting.key) ? { ...setting, value: setting.sensitive ? null : body[setting.key], isSet: true, source: 'database' } : setting)
return route.fulfill({ json: { updated: Object.keys(body).length } })
}
return route.fulfill({ json: { settings } })
}
if (url.pathname === '/api/status/services/jellystat/test') {
mutations.push({ path: url.pathname })
return route.fulfill({ json: { name: 'Jellystat', status: 'up', detail: { connected: true } } })
}
if (url.pathname === '/api/status/services') return route.fulfill({ json: { services: [{ name: 'Jellystat', status: 'up' }] } })
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))
for (const width of [1440, 980, 390, 320]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/insights')
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
assert.notEqual(await page.locator('.stats-period [aria-pressed=true]').evaluate((element) => getComputedStyle(element).backgroundColor), await page.locator('.stats-period [aria-pressed=false]').first().evaluate((element) => getComputedStyle(element).backgroundColor), 'Selected period must be visibly different')
await page.locator('.stats-chart-bars button').first().click()
assert.match(await page.locator('.stats-chart-detail').innerText(), /minutes/)
assert.equal(await page.getByRole('link', { name: 'Dune: Part Two' }).getAttribute('href'), '/requests/12')
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Overflow at ${width}px`)
if (width <= 980) {
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Config' }).waitFor({ state: 'visible' })
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Stats' }).waitFor({ state: 'visible' })
}
if (output) {
fs.mkdirSync(output, { recursive: true })
await page.screenshot({ path: path.join(output, `insights-${width}.png`), fullPage: true })
}
}
await page.getByRole('button', { name: '90 days', exact: true }).click()
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
assert.equal(periods.at(-1), 90)
for (const [state, text] of [['empty', 'No viewing history in this period yet.'], ['not_configured', 'Your viewing story starts here'], ['unlinked', 'Link your viewing account'], ['unavailable', 'Stats couldnt load']]) {
mode = state
await page.reload()
await page.getByText(text, { exact: false }).waitFor()
if (state === 'not_configured') assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).getAttribute('href'), '/admin/jellystat')
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
}
mode = 'not_configured'
role = 'user'
await page.reload()
await page.getByText('Viewing stats will appear here once your administrator connects Jellystat.').waitFor()
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
mode = 'unauthorized'
await page.reload()
await page.waitForURL('**/login?next=%2Finsights')
role = 'admin'
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/admin/jellystat')
await page.getByRole('heading', { name: 'Jellystat', exact: true }).waitFor()
await page.getByRole('button', { name: 'Test connection', exact: true }).waitFor()
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
if (output) await page.screenshot({ path: path.join(output, `jellystat-settings-${width}.png`), fullPage: true })
}
await page.getByRole('button', { name: 'Test connection', exact: true }).click()
await page.getByText('Jellystat connection test passed.').waitFor()
assert(mutations.some((request) => request.path === '/api/status/services/jellystat/test'))
const urlInput = page.locator('#setting-jellystat_base_url')
await urlInput.fill('http://jellystat:3001')
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
await page.getByText('Saved', { exact: true }).first().waitFor()
assert(mutations.some((request) => request.body?.jellystat_base_url === 'http://jellystat:3001'))
assert(mutations.filter((request) => request.body).every((request) => !Object.hasOwn(request.body, 'jellystat_api_key')), 'An unchanged secret must be preserved')
assert.deepEqual(errors, [])
console.log('Insights browser checks passed: desktop/mobile, chart, period, requests, empty/setup/unlinked/error/auth states, settings save/test, preserved secret.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exit(1) })
+20
View File
@@ -0,0 +1,20 @@
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT);
const fs = require('node:fs');
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const css = ['globals.css', 'ops-redesign.css', 'admin/config.css', 'account.css', 'workspace.css', 'portal/issue-flow.css'].map(p => fs.readFileSync(`frontend/app/${p}`, 'utf8')).join('\n');
for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 });
await page.setContent(`<style>${css}</style><main class="issue-portal-page" style="display:block;width:100%;margin:0"><section class="portal-list-panel" style="width: min(100%, 360px);height:600px"><div class="portal-item-list">${Array.from({length: 8}, (_, i) => `<button class="portal-item-row"><div class="portal-item-row-main"><div class="portal-item-row-title"><strong>Issue ${i}: A long movie title with a missing episode and more details</strong><span class="small-pill">Broken media</span><span class="small-pill">Normal</span></div><p>Two lines of issue description to make sure this card expands correctly and does not overlap its neighbours.</p><div class="issue-card-progress">Reported Step 1 of 6</div><div class="portal-item-row-meta"><span>#${i}</span><span>By: long-test-account@example.invalid</span><span>Updated: 9/7/2026, 10:00:00 PM</span></div></div></button>`).join('')}</div></section></main>`);
const bounds = await page.locator('.portal-item-row').evaluateAll(rows => rows.map(row => {
const r = row.getBoundingClientRect(), content = row.firstElementChild.getBoundingClientRect();
return {top:r.top,bottom:r.bottom,innerTop:content.top,innerBottom:content.bottom};
}));
bounds.forEach((r,i) => { assert(r.innerTop >= r.top); assert(r.innerBottom <= r.bottom); if(i) assert(r.top >= bounds[i-1].bottom + 7); });
console.log(`Issue card content and spacing passed at ${width}px`);
}
} finally { await browser.close(); }
})().catch(e => { console.error(e); process.exit(1); });
+34
View File
@@ -0,0 +1,34 @@
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT);
const assert = require('node:assert/strict');
(async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
let mode = 'ready';
await page.route('**/api/**', route => {
if (route.request().url().endsWith('/auth/me')) return route.fulfill({ json: { username: 'Tester', role: 'user' } });
if (route.request().url().endsWith('/site/info')) return route.fulfill({ status: mode === 'unauthorized' ? 401 : 200, json: { mediaServerUrl: mode === 'missing' ? null : 'https://watch.example.com/' } });
return route.fulfill({ json: {} });
});
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('http://127.0.0.1:3103/welcome');
await page.getByRole('link', { name: /Go to GrizzlyFlix/ }).waitFor();
assert.equal(await page.getByRole('link', { name: /Go to GrizzlyFlix/ }).getAttribute('href'), 'https://watch.example.com/');
assert.equal(await page.getByRole('link', { name: /Manage your account/ }).getAttribute('href'), '/');
assert.equal(await page.locator('.header').count(), 0);
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.goto('http://127.0.0.1:3103/how-it-works');
await page.getByText('Understand the six progress steps').click();
await page.getByText('Your request has been received.').waitFor();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
}
mode = 'missing';
await page.goto('http://127.0.0.1:3103/welcome');
await page.getByText(/watch link hasnt been set up/).waitFor();
mode = 'unauthorized';
await page.goto('http://127.0.0.1:3103/welcome');
await page.waitForURL('**/login');
console.log('Welcome and guide checks passed: desktop, mobile, links, disclosure, missing URL, auth redirect.');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'env_build_number.ps1')
foreach ($newline in @("`n", "`r`n")) {
foreach ($content in @('', 'BUILD_NUMBER=1', "# keep$newline`BUILD_NUMBER=1$newline`TOKEN=example=unchanged$newline`BUILD_NUMBER=2$newline", "export BUILD_NUMBER=3$newline`OTHER=value$newline")) {
$result = Set-EnvBuildNumber -Content $content -BuildNumber '0803262237'
if ([regex]::Matches($result, '(?m)^BUILD_NUMBER=').Count -ne 1) { throw 'Duplicate build numbers remain' }
if ((Set-EnvBuildNumber -Content $result -BuildNumber '0803262237') -cne $result) { throw 'Update is not idempotent' }
$expected = [regex]::Replace($content, '(?m)^(?:export )?BUILD_NUMBER=[^\r\n]*(?:\r?\n|$)', '')
$actual = [regex]::Replace($result, '(?m)^BUILD_NUMBER=[^\r\n]*(?:\r?\n|$)', '')
if ($actual -cne $expected) { throw 'Unrelated settings changed' }
}
}
Write-Host 'Build-number tests passed: empty, existing, duplicates, LF/CRLF, preservation and repeat updates.'