Compare commits
12
Commits
98d8b197a9
...
2976145dd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2976145dd8 | ||
|
|
a3b5759708 | ||
|
|
13edcb8136 | ||
|
|
131b5fc5c7 | ||
|
|
bd1f2cb1cb | ||
|
|
edca300d27 | ||
|
|
4034a8f72a | ||
|
|
0637860b95 | ||
|
|
697fc235ee | ||
|
|
62ee07f92b | ||
|
|
458ef53f47 | ||
|
|
c2685f43a7 |
@@ -1,5 +1,14 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.venv/
|
||||
**/.pytest_cache/
|
||||
stitch_magent_media_operations_redesign/
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
bootstrap-admin.json
|
||||
release.tar
|
||||
*.log
|
||||
data/*
|
||||
!data/branding/
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
- main
|
||||
- prod
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.env
|
||||
bootstrap-admin.json
|
||||
.venv/
|
||||
data/
|
||||
!data/branding/
|
||||
@@ -10,3 +11,10 @@ backend/.pytest_cache/
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
*.log
|
||||
**/.pytest_cache/
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
- Users and access control (admin vs user, block access).
|
||||
- Local account password changes via "My profile".
|
||||
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
|
||||
- Docker-first deployment for easy hosting.
|
||||
|
||||
## Quick start (Docker - primary)
|
||||
|
||||
@@ -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"]
|
||||
@@ -5,6 +5,7 @@ import httpx
|
||||
|
||||
from ..logging_config import sanitize_headers, sanitize_value
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
from ..metrics import record_remote
|
||||
|
||||
|
||||
_SERVICE_NAMES = {
|
||||
@@ -309,6 +310,7 @@ class ApiClient:
|
||||
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
|
||||
active_message, _ = _operation_messages(service_name, method, path)
|
||||
operation_event_id = start_remote_call(service_name, active_message)
|
||||
metric_status = 'error'
|
||||
self.logger.debug(
|
||||
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
|
||||
method,
|
||||
@@ -327,6 +329,7 @@ class ApiClient:
|
||||
params=params,
|
||||
payload=payload,
|
||||
)
|
||||
metric_status = str(response.status_code)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
self.logger.debug(
|
||||
@@ -389,6 +392,9 @@ class ApiClient:
|
||||
)
|
||||
raise
|
||||
|
||||
finally:
|
||||
record_remote(service_name, method, metric_status, time.perf_counter() - started_at)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
path: str,
|
||||
|
||||
@@ -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")
|
||||
@@ -258,6 +258,11 @@ class Settings(BaseSettings):
|
||||
jellyseerr_api_key: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
|
||||
)
|
||||
jellystat_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
|
||||
)
|
||||
jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_KEY")
|
||||
|
||||
jellyfin_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
|
||||
)
|
||||
|
||||
+35
-12
@@ -187,6 +187,12 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
|
||||
|
||||
def init_db() -> None:
|
||||
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("""
|
||||
CREATE TABLE IF NOT EXISTS request_repairs (
|
||||
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]),
|
||||
}
|
||||
)
|
||||
# Admin user management uses Jellyfin as the source of truth for non-admin
|
||||
# user objects. Seerr rows are treated as enrichment-only and hidden
|
||||
# from admin/user-management views to avoid duplicate accounts in the UI.
|
||||
# Imported Seerr accounts must remain manageable. Prefer a Jellyfin/local
|
||||
# account when a linked duplicate exists, without hiding Seerr-only users.
|
||||
def _provider_rank(user: Dict[str, Any]) -> int:
|
||||
provider = str(user.get("auth_provider") or "local").strip().lower()
|
||||
if provider == "jellyfin":
|
||||
@@ -1236,14 +1241,7 @@ def get_all_users() -> list[Dict[str, Any]]:
|
||||
return 2
|
||||
return 2
|
||||
|
||||
visible_candidates = [
|
||||
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 = all_rows
|
||||
|
||||
visible_candidates.sort(
|
||||
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]:
|
||||
max_uses = row[6]
|
||||
max_uses = 1 if row[10] else row[6]
|
||||
use_count = int(row[7] or 0)
|
||||
expires_at = row[9]
|
||||
is_expired = _is_datetime_in_past(expires_at)
|
||||
@@ -1665,6 +1663,8 @@ def create_signup_invite(
|
||||
recipient_email: Optional[str] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if recipient_email:
|
||||
max_uses = 1
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -1722,6 +1722,11 @@ def update_signup_invite(
|
||||
expires_at: Optional[str],
|
||||
recipient_email: Optional[str],
|
||||
) -> 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()
|
||||
with _connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -1759,6 +1764,24 @@ def delete_signup_invite(invite_id: int) -> bool:
|
||||
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:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
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.portal import router as portal_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.issue_resolution import run_issue_confirmation_loop
|
||||
from .services.operation_progress import (
|
||||
@@ -43,6 +45,7 @@ from .logging_config import (
|
||||
summarize_http_body,
|
||||
)
|
||||
from .runtime import get_runtime_settings
|
||||
from .metrics import record_api, start_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_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)
|
||||
except Exception:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
record_api(request, 500, time.perf_counter() - started_at)
|
||||
logger.exception(
|
||||
"request failed method=%s path=%s duration_ms=%s",
|
||||
request.method,
|
||||
@@ -124,6 +128,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
raise
|
||||
|
||||
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-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
@@ -220,6 +225,7 @@ def _enforce_secure_startup_configuration() -> None:
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
start_metrics()
|
||||
configure_logging(
|
||||
settings.log_level,
|
||||
settings.log_file,
|
||||
@@ -251,6 +257,9 @@ async def startup() -> None:
|
||||
runtime.log_background_sync_level,
|
||||
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("requests-warmup", startup_warmup_requests_cache)
|
||||
_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(portal_router)
|
||||
app.include_router(operations_router)
|
||||
app.include_router(insights_router)
|
||||
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"jellystat_api_key",
|
||||
"magent_ssl_certificate_pem",
|
||||
"magent_ssl_private_key_pem",
|
||||
"magent_notify_email_smtp_password",
|
||||
@@ -150,6 +151,7 @@ SENSITIVE_KEYS = {
|
||||
}
|
||||
|
||||
URL_SETTING_KEYS = {
|
||||
"jellystat_base_url",
|
||||
"magent_application_url",
|
||||
"magent_api_url",
|
||||
"magent_proxy_base_url",
|
||||
@@ -172,6 +174,8 @@ NOTIFICATION_URL_SETTING_KEYS = {
|
||||
}
|
||||
|
||||
SETTING_KEYS: List[str] = [
|
||||
"jellystat_base_url",
|
||||
"jellystat_api_key",
|
||||
"magent_application_url",
|
||||
"magent_application_port",
|
||||
"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"))
|
||||
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:
|
||||
result = await send_templated_email(
|
||||
template_key,
|
||||
|
||||
@@ -28,7 +28,8 @@ from ..db import (
|
||||
create_signup_invite,
|
||||
update_signup_invite,
|
||||
delete_signup_invite,
|
||||
increment_signup_invite_use,
|
||||
reserve_signup_invite_use,
|
||||
release_signup_invite_use,
|
||||
get_user_profile,
|
||||
get_user_activity,
|
||||
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:
|
||||
return {
|
||||
"code": invite.get("code"),
|
||||
"email_bound": bool(invite.get("recipient_email")),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"enabled": bool(invite.get("enabled")),
|
||||
@@ -755,6 +757,11 @@ async def jellyfin_login(
|
||||
save_jellyfin_users_cache(users)
|
||||
except Exception:
|
||||
pass
|
||||
from ..services.jellyfin_identity import link_user
|
||||
|
||||
jellyfin_id = client._extract_user_id(auth_response)
|
||||
if jellyfin_id:
|
||||
link_user(canonical_username, jellyfin_id, runtime.jellyfin_base_url)
|
||||
sync_jellyfin_password_state(canonical_username, password)
|
||||
if user and user.get("jellyseerr_user_id") is None and candidate_map:
|
||||
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
||||
@@ -920,6 +927,16 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
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")
|
||||
|
||||
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_id = invite.get("profile_id")
|
||||
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:
|
||||
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()
|
||||
auth_provider = "local"
|
||||
local_password_value = password_value
|
||||
@@ -999,7 +1020,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
username,
|
||||
local_password_value,
|
||||
role=role,
|
||||
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
|
||||
email=account_email,
|
||||
auth_provider=auth_provider,
|
||||
jellyseerr_user_id=matched_jellyseerr_user_id,
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
@@ -1010,7 +1031,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
except Exception as 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)
|
||||
if auth_provider == "jellyfin":
|
||||
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,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if not account_created:
|
||||
release_signup_invite_use(int(invite['id']))
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
@@ -18,7 +19,8 @@ from ..db import (
|
||||
delete_portal_item,
|
||||
get_portal_item,
|
||||
get_portal_overview,
|
||||
list_portal_comments,
|
||||
list_portal_comments as _list_portal_comments,
|
||||
get_all_users,
|
||||
list_portal_item_activity,
|
||||
list_portal_items,
|
||||
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]:
|
||||
is_admin = _is_admin(user)
|
||||
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"),
|
||||
},
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -566,6 +603,7 @@ def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -
|
||||
else "Reporter"
|
||||
)
|
||||
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)
|
||||
return public_activity
|
||||
|
||||
@@ -1510,4 +1548,4 @@ async def portal_create_comment(
|
||||
user=current_user,
|
||||
note=f"internal={is_internal}",
|
||||
)
|
||||
return {"comment": comment}
|
||||
return {"comment": comment if is_admin else _public_comment(comment)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
@@ -36,6 +37,13 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
}
|
||||
if include_changelog:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from ..clients.bazarr import BazarrClient
|
||||
from ..clients.prowlarr import ProwlarrClient
|
||||
from ..clients.qbittorrent import QBittorrentClient
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellystat import JellystatClient
|
||||
|
||||
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
|
||||
|
||||
@@ -118,6 +119,11 @@ async def services_status() -> Dict[str, Any]:
|
||||
)
|
||||
)
|
||||
|
||||
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
# Optional analytics must not degrade the media pipeline when not configured.
|
||||
if jellystat.configured():
|
||||
services.append(await _check("Jellystat", True, jellystat.test_connection))
|
||||
|
||||
overall = "up"
|
||||
if any(s.get("status") == "down" for s in services):
|
||||
overall = "down"
|
||||
@@ -141,6 +147,9 @@ async def test_service(service: str) -> Dict[str, Any]:
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
|
||||
service_key = service.strip().lower()
|
||||
if service_key == "jellystat":
|
||||
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
return await _check("Jellystat", jellystat.configured(), jellystat.test_connection)
|
||||
checks = {
|
||||
"seerr": (
|
||||
"Seerr",
|
||||
|
||||
@@ -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}
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from ..db import (
|
||||
set_user_jellyseerr_id,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from .jellyfin_identity import link_user
|
||||
from .user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
@@ -68,6 +69,10 @@ async def sync_jellyfin_users() -> int:
|
||||
set_user_jellyseerr_id(name, matched_id)
|
||||
if matched_email:
|
||||
set_user_email(name, matched_email)
|
||||
if user.get("Id"):
|
||||
local_user = get_user_by_username(name)
|
||||
if local_user and local_user.get("auth_provider") == "jellyfin":
|
||||
link_user(name, str(user["Id"]), runtime.jellyfin_base_url)
|
||||
return imported
|
||||
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ PyJWT==2.13.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.31
|
||||
Pillow==12.3.0
|
||||
prometheus-client==0.22.1
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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'])
|
||||
@@ -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
|
||||
@@ -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">We’re 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`.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
seerr: 'Seerr',
|
||||
jellyseerr: 'Seerr',
|
||||
jellyfin: 'Jellyfin',
|
||||
jellystat: 'Jellystat',
|
||||
artwork: 'Artwork cache',
|
||||
cache: 'Request cache',
|
||||
sonarr: 'Sonarr',
|
||||
@@ -98,6 +99,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
seerr: 'Connect Seerr where users submit content requests.',
|
||||
jellyseerr: 'Connect Seerr where users submit content requests.',
|
||||
jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.',
|
||||
jellystat: 'Connect Jellystat so users can see their personal viewing stats in Magent.',
|
||||
artwork: 'Cache posters/backdrops and review artwork coverage.',
|
||||
cache: 'Manage saved requests cache and refresh behavior.',
|
||||
sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
|
||||
@@ -119,6 +121,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
seerr: 'jellyseerr',
|
||||
jellyseerr: 'jellyseerr',
|
||||
jellyfin: 'jellyfin',
|
||||
jellystat: 'jellystat',
|
||||
artwork: null,
|
||||
sonarr: 'sonarr',
|
||||
radarr: 'radarr',
|
||||
@@ -305,6 +308,14 @@ const STANDARD_SECTION_GROUPS: Record<
|
||||
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
|
||||
},
|
||||
],
|
||||
jellystat: [
|
||||
{
|
||||
key: 'jellystat-connection',
|
||||
title: 'Connection',
|
||||
description: 'Use the Jellystat instance connected to the same Jellyfin server as Magent. Create a Jellystat API key in its settings, then save and test the connection.',
|
||||
keys: ['jellystat_base_url', 'jellystat_api_key'],
|
||||
},
|
||||
],
|
||||
jellyfin: [
|
||||
{
|
||||
key: 'jellyfin-connection',
|
||||
@@ -483,6 +494,8 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||
magent_notify_webhook_url: 'Generic webhook URL',
|
||||
jellyfin_base_url: 'Internal server URL',
|
||||
jellyfin_api_key: 'Administrator API key',
|
||||
jellystat_base_url: 'Internal server URL',
|
||||
jellystat_api_key: 'Jellystat API key',
|
||||
jellyfin_public_url: 'Public playback URL',
|
||||
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
|
||||
sonarr_base_url: 'Sonarr server URL',
|
||||
@@ -578,6 +591,7 @@ type SectionFeedback = {
|
||||
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
||||
'seerr-connection': 'seerr',
|
||||
'jellyfin-connection': 'jellyfin',
|
||||
'jellystat-connection': 'jellystat',
|
||||
'sonarr-connection': 'sonarr',
|
||||
'radarr-connection': 'radarr',
|
||||
'bazarr-connection': 'bazarr',
|
||||
@@ -807,6 +821,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
|
||||
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
|
||||
jellyfin: ['Jellyfin'],
|
||||
jellystat: ['Jellystat'],
|
||||
sonarr: ['Sonarr'],
|
||||
radarr: ['Radarr'],
|
||||
bazarr: ['Bazarr'],
|
||||
@@ -995,6 +1010,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
jellyfin_base_url:
|
||||
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
|
||||
jellyfin_api_key: 'Admin API key for syncing users and availability.',
|
||||
jellystat_base_url: 'Jellystat address reachable by Magent, including any base path. Example: http://jellystat:3000.',
|
||||
jellystat_api_key: 'API key created in Jellystat. Stored privately by Magent and never sent to users’ browsers.',
|
||||
jellyfin_public_url:
|
||||
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
|
||||
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
|
||||
@@ -1076,6 +1093,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
|
||||
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
|
||||
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
|
||||
jellystat_base_url: 'http://jellystat:3000',
|
||||
jellyfin_public_url: 'https://jelly.example.com',
|
||||
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
|
||||
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
|
||||
|
||||
@@ -5,6 +5,7 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'seerr',
|
||||
'jellyseerr',
|
||||
'jellyfin',
|
||||
'jellystat',
|
||||
'artwork',
|
||||
'sonarr',
|
||||
'radarr',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
|
||||
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
|
||||
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
|
||||
{ href: '/admin/jellystat', label: 'Jellystat', description: 'Personal viewing statistics', symbol: 'JS', service: 'Jellystat' },
|
||||
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
|
||||
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
|
||||
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
|
||||
|
||||
@@ -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">We’re 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>
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -1,181 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import '../welcome.css'
|
||||
|
||||
export default function HowItWorksPage() {
|
||||
return (
|
||||
<main className="card how-page">
|
||||
<PageHeading title="How it works" description="Request something to watch, follow its progress, and get help when you need it." />
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>What Magent is for</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Track requests</h3>
|
||||
<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>
|
||||
return <main className="friendly-guide">
|
||||
<PageHeading title="A little help getting started." description="Magent looks after your requests. GrizzlyFlix is where you watch them." />
|
||||
<nav aria-label="Quick links"><a href="/welcome">Welcome page</a><a href="/">My Requests</a><a href="/profile">My profile</a></nav>
|
||||
<details open><summary>Request a movie or TV show</summary>
|
||||
<ol>
|
||||
<li><strong>Choose Movie or TV show.</strong><p>Open <a href="/new-requests">02 New Requests</a> and pick what you’re looking for.</p></li>
|
||||
<li><strong>Search and choose the right title.</strong><p>For TV, choose the seasons you want. If it’s already requested, open that request to see its progress.</p></li>
|
||||
<li><strong>Check your choices and send it.</strong><p>Choose from the quality options shown. These come from the library’s settings.</p></li>
|
||||
<li><strong>Follow it in My Requests.</strong><p>We’ll show what’s happening and any next step you can take. Some titles need approval or may not have a suitable download yet.</p></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>What the statuses usually mean</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Pending</h3>
|
||||
<p>The request exists, but it is still waiting for approval or the next step.</p>
|
||||
</article>
|
||||
<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>
|
||||
</details>
|
||||
<details><summary>Understand the six progress steps</summary>
|
||||
<ol>
|
||||
<li><strong>Requested:</strong> Your request has been received.</li>
|
||||
<li><strong>Approved:</strong> It has permission to go ahead.</li>
|
||||
<li><strong>Library collection:</strong> The library is tracking what’s collected and what’s missing.</li>
|
||||
<li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isn’t a good match yet.</li>
|
||||
<li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li>
|
||||
<li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-callout">
|
||||
<h2>If a request looks stuck</h2>
|
||||
<p>
|
||||
A waiting request usually means no usable release has been found yet, the download is
|
||||
still in progress, or the import has not completed. Magent will keep updating as the
|
||||
underlying services move forward.
|
||||
</p>
|
||||
</section>
|
||||
<p>A finished download still needs to be added to the media library. Wait for “Available to watch” before heading over.</p>
|
||||
</details>
|
||||
<details><summary>Something looks stuck</summary>
|
||||
<ol>
|
||||
<li><strong>Open the request.</strong><p>Read its current status and next step.</p></li>
|
||||
<li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li>
|
||||
<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 you’re unsure.</p></li>
|
||||
</ol>
|
||||
<p>Remote activity explains the latest check. Open it to see the full list. A successful search doesn’t always mean a download was found.</p>
|
||||
</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 what’s 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 issue’s 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 it’s 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 site’s 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 you’ll 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 account’s 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 couldn’t 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 Jellystat’s movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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; }
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export default function LoginPage() {
|
||||
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
|
||||
setToken('cookie')
|
||||
const next = new URLSearchParams(window.location.search).get('next') || ''
|
||||
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/')
|
||||
window.location.assign(next === '/insights' || /^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
|
||||
} catch {
|
||||
setError('Could not reach Magent. Check your connection and try again.')
|
||||
} finally { setLoading(false) }
|
||||
|
||||
@@ -3121,12 +3121,31 @@ textarea:focus {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.home-recent-grid .recent-meta::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ops-faint);
|
||||
content: "";
|
||||
content: none;
|
||||
}
|
||||
.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-attention .recent-meta::before { background: var(--ops-coral); }
|
||||
.home-recent-grid .is-ready .recent-meta::before { background: var(--ops-green); }
|
||||
@@ -3612,13 +3631,51 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.issue-portal-page .portal-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
max-height: 100%;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
+5
-403
@@ -1,407 +1,9 @@
|
||||
'use client'
|
||||
import { redirect } from 'next/navigation'
|
||||
import MyRequests from './MyRequests'
|
||||
|
||||
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 const dynamic = 'force-dynamic'
|
||||
|
||||
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('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>
|
||||
)
|
||||
if (process.env.MAGENT_COMING_SOON === 'true') redirect('/coming-soon')
|
||||
return <MyRequests />
|
||||
}
|
||||
|
||||
@@ -2242,7 +2242,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
{selectedItem.kind === 'request' ? 'Request' : 'Issue'} #{selectedItem.id}
|
||||
</h2>
|
||||
<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>
|
||||
{selectedItem.kind === 'issue' ? (
|
||||
<p className="lede">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clearToken, getApiBase, setToken } from '../lib/auth'
|
||||
|
||||
type InviteInfo = {
|
||||
code: string
|
||||
email_bound?: boolean
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
@@ -38,14 +39,15 @@ function SignupPageContent() {
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return Boolean(invite?.is_usable && username.trim() && password && !loading)
|
||||
}, [invite, username, password, loading])
|
||||
return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading)
|
||||
}, [invite, email, username, password, loading, inviteLoading])
|
||||
|
||||
const lookupInvite = async (code: string) => {
|
||||
const trimmed = code.trim()
|
||||
@@ -110,6 +112,7 @@ function SignupPageContent() {
|
||||
body: JSON.stringify({
|
||||
invite_code: inviteCode,
|
||||
username: username.trim(),
|
||||
...(!invite.email_bound ? { email: email.trim() } : {}),
|
||||
password,
|
||||
}),
|
||||
})
|
||||
@@ -120,7 +123,7 @@ function SignupPageContent() {
|
||||
const data = await response.json()
|
||||
if (data?.authenticated) {
|
||||
setToken('cookie')
|
||||
window.location.href = '/'
|
||||
window.location.href = '/welcome'
|
||||
return
|
||||
}
|
||||
throw new Error('Sign-up did not complete')
|
||||
@@ -140,7 +143,7 @@ function SignupPageContent() {
|
||||
<div className="invite-lookup-row">
|
||||
<input
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }}
|
||||
placeholder="Paste your invite code"
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
@@ -171,6 +174,10 @@ function SignupPageContent() {
|
||||
</div></details>
|
||||
</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>
|
||||
Username
|
||||
<input
|
||||
|
||||
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
|
||||
|
||||
export default function ApplicationChrome() {
|
||||
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 <>
|
||||
<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>
|
||||
|
||||
@@ -75,6 +75,11 @@ export default function HeaderActions() {
|
||||
]
|
||||
|
||||
const commonItems = [
|
||||
{
|
||||
href: '/insights',
|
||||
label: 'My Stats',
|
||||
match: (path: string) => path === '/insights',
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
label: 'My Requests',
|
||||
|
||||
@@ -93,6 +93,8 @@ export default function HeaderIdentity() {
|
||||
{viewAsUser ? <span>Previewing user view</span> : null}
|
||||
</div>
|
||||
<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)}>
|
||||
My profile
|
||||
</a>
|
||||
|
||||
@@ -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">
|
||||
{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>
|
||||
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'Share it yourself. No email needed.' : 'We’ll 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 recipient’s email. You get the link too.'}</small></span>
|
||||
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,13 @@ type NavigationItem = {
|
||||
href: string
|
||||
label: string
|
||||
shortLabel: string
|
||||
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings'
|
||||
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats'
|
||||
adminOnly?: boolean
|
||||
match: (path: string) => boolean
|
||||
}
|
||||
|
||||
const NAVIGATION: NavigationItem[] = [
|
||||
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' },
|
||||
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
|
||||
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
|
||||
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
|
||||
@@ -25,6 +26,7 @@ const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password
|
||||
|
||||
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
|
||||
const paths: Record<NavigationItem['icon'], React.ReactNode> = {
|
||||
stats: <><path d="M4 20h16M6 16v-5m6 5V4m6 12V8" /></>,
|
||||
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
|
||||
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
|
||||
@@ -67,7 +69,7 @@ export default function WorkspaceNavigation() {
|
||||
return (
|
||||
<>
|
||||
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
||||
{items.slice(0, 5).map((item) => (
|
||||
{items.map((item) => (
|
||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
||||
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
|
||||
</a>
|
||||
|
||||
@@ -15,6 +15,7 @@ type AdminUser = {
|
||||
lastLoginAt?: string | null
|
||||
isBlocked?: boolean
|
||||
autoSearchEnabled?: boolean
|
||||
inviteManagementEnabled?: boolean
|
||||
profileId?: number | null
|
||||
expiresAt?: string | null
|
||||
isExpired?: boolean
|
||||
@@ -88,6 +89,8 @@ export default function UsersPage() {
|
||||
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
|
||||
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
|
||||
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
|
||||
const [bulkInvitesBusy, setBulkInvitesBusy] = useState(false)
|
||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
@@ -116,6 +119,7 @@ export default function UsersPage() {
|
||||
lastLoginAt: user.last_login_at ?? null,
|
||||
isBlocked: Boolean(user.is_blocked),
|
||||
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
|
||||
inviteManagementEnabled: Boolean(user.invite_management_enabled),
|
||||
profileId:
|
||||
user.profile_id == null || Number.isNaN(Number(user.profile_id))
|
||||
? 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) => {
|
||||
setBulkAutoSearchBusy(true)
|
||||
setJellyseerrSyncStatus(null)
|
||||
@@ -233,6 +256,7 @@ export default function UsersPage() {
|
||||
|
||||
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
|
||||
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
|
||||
const inviteEnabledCount = nonAdminUsers.filter((user) => user.inviteManagementEnabled).length
|
||||
const blockedCount = users.filter((user) => user.isBlocked).length
|
||||
const expiredCount = users.filter((user) => user.isExpired).length
|
||||
const adminCount = users.filter((user) => user.role === 'admin').length
|
||||
@@ -348,7 +372,7 @@ export default function UsersPage() {
|
||||
<div>
|
||||
<h2>Bulk controls</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -378,6 +402,21 @@ export default function UsersPage() {
|
||||
</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="user-directory-panel-header">
|
||||
<div>
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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 couldn’t 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>Let’s 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 hasn’t 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">Here’s how it works</a>.</footer>
|
||||
</main>
|
||||
}
|
||||
@@ -158,6 +158,8 @@
|
||||
.page-heading .home-search { width: 100%; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.workspace-mobile-nav { padding-inline: 4px; gap: 0; }
|
||||
.workspace-mobile-nav a { min-width: 0; flex: 1; padding-inline: 3px; }
|
||||
:root { --workspace-gutter: 16px; --workspace-gap: 20px; }
|
||||
.page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
|
||||
.page-heading { padding-bottom: 20px; }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -5,6 +5,7 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. (Join-Path $PSScriptRoot 'env_build_number.ps1')
|
||||
|
||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||
Set-Location $repoRoot
|
||||
@@ -172,16 +173,8 @@ function Update-BuildFiles {
|
||||
$envPath = Join-Path $repoRoot ".env"
|
||||
if (Test-Path $envPath) {
|
||||
$envContent = Read-TextFile -Path ".env"
|
||||
if ($envContent -match '^BUILD_NUMBER=.*$') {
|
||||
$updatedEnv = [regex]::Replace(
|
||||
$envContent,
|
||||
'^BUILD_NUMBER=.*$',
|
||||
"BUILD_NUMBER=$BuildNumber",
|
||||
[System.Text.RegularExpressions.RegexOptions]::Multiline
|
||||
)
|
||||
} else {
|
||||
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
|
||||
}
|
||||
# Remove previous assignments before adding exactly one build number.
|
||||
$updatedEnv = Set-EnvBuildNumber -Content $envContent -BuildNumber $BuildNumber
|
||||
Write-TextFile -Path ".env" -Content $updatedEnv
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
@@ -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 couldn’t 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) })
|
||||
@@ -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); });
|
||||
@@ -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 hasn’t 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); });
|
||||
@@ -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.'
|
||||
Reference in New Issue
Block a user