Compare commits

..

2 Commits

Author SHA1 Message Date
619708cae0 Align referral uses field 2026-01-23 23:07:13 +13:00
af67c888c6 Fix invite referral uses hint layout 2026-01-23 23:05:09 +13:00
58 changed files with 1579 additions and 15324 deletions

View File

@@ -1 +0,0 @@
2702261153

View File

@@ -1,53 +0,0 @@
FROM node:20-slim AS frontend-builder
WORKDIR /frontend
ENV NODE_ENV=production \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api
COPY frontend/package.json ./
RUN npm install
COPY frontend/app ./app
COPY frontend/public ./public
COPY frontend/next-env.d.ts ./next-env.d.ts
COPY frontend/next.config.js ./next.config.js
COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY data/branding /app/data/branding
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
COPY --from=frontend-builder /frontend/public /app/frontend/public
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
EXPOSE 3000 8000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]

View File

@@ -26,7 +26,7 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult:
recommendations.append(
TriageRecommendation(
action_id="readd_to_arr",
title="Push to Sonarr/Radarr",
title="Add it to the library queue",
reason="Sonarr/Radarr has not created the entry for this request.",
risk="medium",
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,55 +1,19 @@
from datetime import datetime, timezone
from typing import Dict, Any, Optional
from typing import Dict, Any
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from .db import get_user_by_username, upsert_user_activity
from .db import get_user_by_username
from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _is_expired(expires_at: str | None) -> bool:
if not isinstance(expires_at, str) or not expires_at.strip():
return False
candidate = expires_at.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if request.client and request.client.host:
return request.client.host
return "unknown"
def _load_current_user_from_token(
token: str,
request: Optional[Request] = None,
allowed_token_types: Optional[set[str]] = None,
) -> Dict[str, Any]:
def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
token_type = str(payload.get("typ") or "access").strip().lower()
if allowed_token_types and token_type not in allowed_token_types:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
username = payload.get("sub")
if not username:
@@ -60,61 +24,15 @@ def _load_current_user_from_token(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
if request is not None:
ip = _extract_client_ip(request)
user_agent = request.headers.get("user-agent", "unknown")
upsert_user_activity(user["username"], ip, user_agent)
return {
"username": user["username"],
"role": user["role"],
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
"profile_id": user.get("profile_id"),
"expires_at": user.get("expires_at"),
"is_expired": bool(user.get("is_expired", False)),
}
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
return _load_current_user_from_token(token, request)
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
token = None
stream_query_token = None
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
stream_query_token = request.query_params.get("stream_token")
if not token and not stream_query_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
if token:
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
return _load_current_user_from_token(token, None)
return _load_current_user_from_token(
str(stream_query_token),
None,
allowed_token_types={"sse"},
)
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
def require_admin_event_stream(
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

View File

@@ -1,4 +0,0 @@
BUILD_NUMBER = "2702261314"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -30,25 +30,3 @@ class ApiClient:
response = await client.post(url, headers=self.headers(), json=payload)
response.raise_for_status()
return response.json()
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.put(url, headers=self.headers(), json=payload)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def delete(self, path: str) -> Optional[Any]:
if not self.base_url:
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.delete(url, headers=self.headers())
response.raise_for_status()
if not response.content:
return None
return response.json()

View File

@@ -10,158 +10,27 @@ class JellyfinClient(ApiClient):
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
def _emby_headers(self) -> Dict[str, str]:
return {"X-Emby-Token": self.api_key} if self.api_key else {}
@staticmethod
def _extract_user_id(payload: Any) -> Optional[str]:
if not isinstance(payload, dict):
return None
candidate = payload.get("User") if isinstance(payload.get("User"), dict) else payload
if not isinstance(candidate, dict):
return None
for key in ("Id", "id", "UserId", "userId"):
value = candidate.get(key)
if value is None:
continue
if isinstance(value, (str, int)):
text = str(value).strip()
if text:
return text
return None
async def get_users(self) -> Optional[Dict[str, Any]]:
if not self.base_url:
return None
url = f"{self.base_url}/Users"
headers = self._emby_headers()
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def find_user_by_name(self, username: str) -> Optional[Dict[str, Any]]:
users = await self.get_users()
if not isinstance(users, list):
return None
target = username.strip().lower()
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip().lower()
if name and name == target:
return user
return None
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
if not self.base_url:
return None
url = f"{self.base_url}/Users/AuthenticateByName"
headers = self._emby_headers()
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
payload = {"Username": username, "Pw": password}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
async def create_user(self, username: str) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/New"
headers = self._emby_headers()
payload = {"Name": username}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def set_user_password(self, user_id: str, password: str) -> None:
if not self.base_url or not self.api_key:
return None
headers = self._emby_headers()
payloads = [
{"CurrentPw": "", "NewPw": password},
{"CurrentPwd": "", "NewPw": password},
{"CurrentPw": "", "NewPw": password, "ResetPassword": False},
{"CurrentPwd": "", "NewPw": password, "ResetPassword": False},
{"NewPw": password, "ResetPassword": False},
]
paths = [
f"/Users/{user_id}/Password",
f"/Users/{user_id}/EasyPassword",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=10.0) as client:
for path in paths:
url = f"{self.base_url}{path}"
for payload in payloads:
try:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return
except httpx.HTTPStatusError as exc:
last_error = exc
continue
except Exception as exc:
last_error = exc
continue
if last_error:
raise last_error
async def set_user_disabled(self, user_id: str, disabled: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
user = await self.get_user(user_id)
if not isinstance(user, dict):
raise RuntimeError("Jellyfin user details not available")
policy = user.get("Policy") if isinstance(user.get("Policy"), dict) else {}
payload = {**policy, "IsDisabled": bool(disabled)}
url = f"{self.base_url}/Users/{user_id}/Policy"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
async def delete_user(self, user_id: str) -> None:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Users/{user_id}"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.delete(url, headers=headers)
response.raise_for_status()
async def create_user_with_password(self, username: str, password: str) -> Optional[Dict[str, Any]]:
created = await self.create_user(username)
user_id = self._extract_user_id(created)
if not user_id:
users = await self.get_users()
if isinstance(users, list):
for user in users:
if not isinstance(user, dict):
continue
name = str(user.get("Name") or "").strip()
if name.lower() == username.strip().lower():
created = user
user_id = self._extract_user_id(user)
break
if not user_id:
raise RuntimeError("Jellyfin user created but user ID was not returned")
await self.set_user_password(user_id, password)
return created
async def search_items(
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
) -> Optional[Dict[str, Any]]:
@@ -174,7 +43,7 @@ class JellyfinClient(ApiClient):
"Recursive": "true",
"Limit": limit,
}
headers = self._emby_headers()
headers = {"X-Emby-Token": self.api_key}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
@@ -184,18 +53,8 @@ class JellyfinClient(ApiClient):
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/System/Info"
headers = self._emby_headers()
headers = {"X-Emby-Token": self.api_key}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers()
params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()

View File

@@ -35,18 +35,3 @@ class JellyseerrClient(ApiClient):
"page": page,
},
)
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/user",
params={
"take": take,
"skip": skip,
},
)
async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/user/{user_id}")
async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]:
return await self.delete(f"/api/v1/user/{user_id}")

View File

@@ -1,6 +1,5 @@
from typing import Any, Dict, Optional
import httpx
import logging
from .base import ApiClient
@@ -9,7 +8,6 @@ class QBittorrentClient(ApiClient):
super().__init__(base_url, None)
self.username = username
self.password = password
self.logger = logging.getLogger(__name__)
def configured(self) -> bool:
return bool(self.base_url and self.username and self.password)
@@ -74,14 +72,6 @@ class QBittorrentClient(ApiClient):
raise
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
url_host = None
if isinstance(url, str) and "://" in url:
url_host = url.split("://", 1)[-1].split("/", 1)[0]
self.logger.warning(
"qBittorrent add_torrent_url invoked: category=%s host=%s",
category,
url_host or "unknown",
)
data: Dict[str, Any] = {"urls": url}
if category:
data["category"] = category

View File

@@ -9,9 +9,6 @@ class RadarrClient(ApiClient):
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/movie/{movie_id}")
async def get_movies(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie")
@@ -24,9 +21,6 @@ class RadarrClient(ApiClient):
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
@@ -47,17 +41,5 @@ class RadarrClient(ApiClient):
}
return await self.post("/api/v3/movie", payload=payload)
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/movie", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release/push", payload=payload)
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post(
"/api/v3/command",
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
)

View File

@@ -9,9 +9,6 @@ class SonarrClient(ApiClient):
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/series/{series_id}")
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/rootfolder")
@@ -21,9 +18,6 @@ class SonarrClient(ApiClient):
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"seriesId": series_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
@@ -54,17 +48,5 @@ class SonarrClient(ApiClient):
payload["title"] = title
return await self.post("/api/v3/series", payload=payload)
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/series", payload=payload)
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release/push", payload=payload)
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post(
"/api/v3/command",
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
)

View File

@@ -2,7 +2,6 @@ from typing import Optional
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
@@ -11,16 +10,6 @@ class Settings(BaseSettings):
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
)
auth_rate_limit_max_attempts_ip: int = Field(
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
@@ -49,137 +38,6 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
site_banner_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
)
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
)
magent_application_port: int = Field(
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
)
magent_api_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
)
magent_api_port: int = Field(
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
)
magent_bind_host: str = Field(
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
)
magent_proxy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
)
magent_proxy_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
)
magent_proxy_trust_forwarded_headers: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
)
magent_proxy_forwarded_prefix: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
)
magent_ssl_bind_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
)
magent_ssl_certificate_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
)
magent_ssl_private_key_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
)
magent_ssl_certificate_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
)
magent_ssl_private_key_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
)
magent_notify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
)
magent_notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
)
magent_notify_email_smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
)
magent_notify_email_smtp_port: int = Field(
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
)
magent_notify_email_smtp_username: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
)
magent_notify_email_smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
)
magent_notify_email_from_address: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
)
magent_notify_email_from_name: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
)
magent_notify_email_use_tls: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
)
magent_notify_email_use_ssl: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
)
magent_notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
)
magent_notify_discord_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
)
magent_notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
)
magent_notify_telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
)
magent_notify_telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
)
magent_notify_push_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
)
magent_notify_push_provider: Optional[str] = Field(
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
)
magent_notify_push_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
)
magent_notify_push_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
)
magent_notify_push_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
)
magent_notify_push_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
)
magent_notify_push_device: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
)
magent_notify_webhook_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
)
magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
@@ -212,10 +70,6 @@ class Settings(BaseSettings):
sonarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
)
sonarr_qbittorrent_category: Optional[str] = Field(
default="sonarr",
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
)
radarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
@@ -229,10 +83,6 @@ class Settings(BaseSettings):
radarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
)
radarr_qbittorrent_category: Optional[str] = Field(
default="radarr",
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import asyncio
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
@@ -13,23 +13,16 @@ from .routers.requests import (
run_daily_db_cleanup,
)
from .routers.auth import router as auth_router
from .routers.admin import router as admin_router, events_router as admin_events_router
from .routers.admin import router as admin_router
from .routers.images import router as images_router
from .routers.branding import router as branding_router
from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .routers.events import router as events_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging
from .runtime import get_runtime_settings
app = FastAPI(
title=settings.app_name,
docs_url="/docs" if settings.api_docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
)
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
@@ -40,22 +33,6 @@ app.add_middleware(
)
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
# Keep API responses non-executable and non-embeddable by default.
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
)
return response
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@@ -75,10 +52,7 @@ async def startup() -> None:
app.include_router(requests_router)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(admin_events_router)
app.include_router(images_router)
app.include_router(branding_router)
app.include_router(status_router)
app.include_router(feedback_router)
app.include_router(site_router)
app.include_router(events_router)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,6 @@ router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
def _ensure_branding_dir() -> None:
@@ -45,18 +41,6 @@ def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_ensure_branding_dir()
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
try:
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
try:
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)
@@ -81,32 +65,24 @@ def _ensure_default_branding() -> None:
favicon.save(_FAVICON_PATH, format="ICO")
def _resolve_branding_paths() -> tuple[str, str]:
if _BRANDING_SOURCE == "data":
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
@router.get("/logo.png")
async def branding_logo() -> FileResponse:
logo_path, _ = _resolve_branding_paths()
if not os.path.exists(logo_path):
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
if not os.path.exists(_LOGO_PATH):
raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "no-store"}
return FileResponse(logo_path, media_type="image/png", headers=headers)
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
@router.get("/favicon.ico")
async def branding_favicon() -> FileResponse:
_, favicon_path = _resolve_branding_paths()
if not os.path.exists(favicon_path):
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
if not os.path.exists(_FAVICON_PATH):
raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "no-store"}
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:

View File

@@ -1,249 +0,0 @@
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from . import requests as requests_router
from .status import services_status
router = APIRouter(prefix="/events", tags=["events"])
def _sse_json(payload: Dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
def _jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
try:
return value.model_dump(mode="json")
except TypeError:
return value.model_dump()
if hasattr(value, "dict"):
try:
return value.dict()
except TypeError:
return value
return value
def _request_history_brief(entries: Any) -> list[dict[str, Any]]:
if not isinstance(entries, list):
return []
items: list[dict[str, Any]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
items.append(
{
"request_id": entry.get("request_id"),
"state": entry.get("state"),
"state_reason": entry.get("state_reason"),
"created_at": entry.get("created_at"),
}
)
return items
def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
if not isinstance(entries, list):
return []
items: list[dict[str, Any]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
items.append(
{
"request_id": entry.get("request_id"),
"action_id": entry.get("action_id"),
"label": entry.get("label"),
"status": entry.get("status"),
"message": entry.get("message"),
"created_at": entry.get("created_at"),
}
)
return items
@router.get("/stream")
async def events_stream(
request: Request,
recent_days: int = 90,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
async def event_generator():
yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None
last_services_signature: Optional[str] = None
next_recent_at = 0.0
next_services_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
now = time.monotonic()
sent_any = False
if now >= next_recent_at:
next_recent_at = now + 15.0
try:
recent_payload = await requests_router.recent_requests(
take=recent_take,
skip=0,
days=recent_days,
user=user,
)
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"results": results if isinstance(results, list) else [],
}
except Exception as exc:
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_recent_signature:
last_recent_signature = signature
yield _sse_json(payload)
sent_any = True
if now >= next_services_at:
next_services_at = now + 30.0
try:
status_payload = await services_status()
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"status": status_payload,
}
except Exception as exc:
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_services_signature:
last_services_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
@router.get("/requests/{request_id}/stream")
async def request_events_stream(
request_id: str,
request: Request,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
request_id = str(request_id).strip()
if not request_id:
raise HTTPException(status_code=400, detail="Missing request id")
async def event_generator():
yield "retry: 2000\n\n"
last_signature: Optional[str] = None
next_refresh_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
now = time.monotonic()
sent_any = False
if now >= next_refresh_at:
next_refresh_at = now + 2.0
try:
snapshot = await requests_router.get_snapshot(request_id=request_id, user=user)
history_payload = await requests_router.request_history(
request_id=request_id, limit=5, user=user
)
actions_payload = await requests_router.request_actions(
request_id=request_id, limit=5, user=user
)
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"snapshot": _jsonable(snapshot),
"history": _request_history_brief(
history_payload.get("snapshots", []) if isinstance(history_payload, dict) else []
),
"actions": _request_actions_brief(
actions_payload.get("actions", []) if isinstance(actions_payload, dict) else []
),
}
except HTTPException as exc:
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc.detail),
"status_code": int(exc.status_code),
}
except Exception as exc:
payload = {
"type": "request_live",
"request_id": request_id,
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_signature:
last_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)

View File

@@ -11,10 +11,7 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
@router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
webhook_url = (
getattr(runtime, "magent_notify_discord_webhook_url", None)
or runtime.discord_webhook_url
)
webhook_url = runtime.discord_webhook_url
if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured")

View File

@@ -1,8 +1,6 @@
import os
import re
import mimetypes
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, RedirectResponse
import httpx
@@ -13,7 +11,6 @@ router = APIRouter(prefix="/images", tags=["images"])
_TMDB_BASE = "https://image.tmdb.org/t/p"
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
logger = logging.getLogger(__name__)
def _safe_filename(path: str) -> str:
@@ -22,35 +19,23 @@ def _safe_filename(path: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
return safe or "image"
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
if not path or "://" in path or ".." in path:
return None
if not path.startswith("/"):
path = f"/{path}"
if size not in _ALLOWED_SIZES:
return None
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
return os.path.join(cache_dir, _safe_filename(path))
def is_tmdb_cached(path: str, size: str) -> bool:
file_path = tmdb_cache_path(path, size)
return bool(file_path and os.path.exists(file_path))
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if not path or "://" in path or ".." in path:
return False
if not path.startswith("/"):
path = f"/{path}"
if size not in _ALLOWED_SIZES:
return False
runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
if cache_mode != "cache":
return False
file_path = tmdb_cache_path(path, size)
if not file_path:
return False
os.makedirs(os.path.dirname(file_path), exist_ok=True)
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
os.makedirs(cache_dir, exist_ok=True)
file_path = os.path.join(cache_dir, _safe_filename(path))
if os.path.exists(file_path):
return True
@@ -79,10 +64,9 @@ async def tmdb_image(path: str, size: str = "w342"):
if cache_mode != "cache":
return RedirectResponse(url=url)
file_path = tmdb_cache_path(path, size)
if not file_path:
raise HTTPException(status_code=400, detail="Invalid image path")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
os.makedirs(cache_dir, exist_ok=True)
file_path = os.path.join(cache_dir, _safe_filename(path))
headers = {"Cache-Control": "public, max-age=86400"}
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
@@ -93,8 +77,6 @@ async def tmdb_image(path: str, size: str = "w342"):
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
return FileResponse(file_path, media_type=media_type, headers=headers)
logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
except (httpx.HTTPError, OSError) as exc:
logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
return RedirectResponse(url=url)
raise HTTPException(status_code=502, detail="Image cache failed")
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
_BANNER_TONES = {"info", "warning", "error", "maintenance"}
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip()
tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES:
tone = "info"
info = {
"buildNumber": (runtime.site_build_number or "").strip(),
"banner": {
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
"tone": tone,
},
}
if include_changelog:
info["changelog"] = (runtime.site_changelog or "").strip()
return info
@router.get("/public")
async def site_public() -> Dict[str, Any]:
return _build_site_info(False)
@router.get("/info")
async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
return _build_site_info(True)

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..runtime import get_runtime_settings
@@ -93,42 +93,3 @@ async def services_status() -> Dict[str, Any]:
overall = "degraded"
return {"overall": overall, "services": services}
@router.post("/services/{service}/test")
async def test_service(service: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower()
checks = {
"jellyseerr": (
"Jellyseerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
if service_key not in checks:
raise HTTPException(status_code=404, detail="Unknown service")
name, configured, func = checks[service_key]
result = await _check(name, configured, func)
if name == "Prowlarr" and result.get("status") == "up":
health = result.get("detail")
if isinstance(health, list) and health:
result["status"] = "degraded"
result["message"] = "Health warnings"
return result

View File

@@ -2,8 +2,6 @@ from .config import settings
from .db import get_settings_overrides
_INT_FIELDS = {
"magent_application_port",
"magent_api_port",
"sonarr_quality_profile_id",
"radarr_quality_profile_id",
"jwt_exp_minutes",
@@ -11,24 +9,10 @@ _INT_FIELDS = {
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
"magent_notify_email_smtp_port",
}
_BOOL_FIELDS = {
"magent_proxy_enabled",
"magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled",
"magent_notify_enabled",
"magent_notify_email_enabled",
"magent_notify_email_use_tls",
"magent_notify_email_use_ssl",
"magent_notify_discord_enabled",
"magent_notify_telegram_enabled",
"magent_notify_push_enabled",
"magent_notify_webhook_enabled",
"jellyfin_sync_to_arr",
"site_banner_enabled",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
def get_runtime_settings():
@@ -37,8 +21,6 @@ def get_runtime_settings():
for key, value in overrides.items():
if value is None:
continue
if key in _SKIP_OVERRIDE_FIELDS:
continue
if key in _INT_FIELDS:
try:
update[key] = int(value)

View File

@@ -18,30 +18,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password)
def _create_token(
subject: str,
role: str,
*,
expires_at: datetime,
token_type: str = "access",
) -> str:
payload: Dict[str, Any] = {
"sub": subject,
"role": role,
"typ": token_type,
"exp": expires_at,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return _create_token(subject, role, expires_at=expires, token_type="access")
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
return _create_token(subject, role, expires_at=expires, token_type="sse")
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def decode_token(token: str) -> Dict[str, Any]:

View File

@@ -3,19 +3,8 @@ import logging
from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient
from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..db import create_user_if_missing
from ..runtime import get_runtime_settings
from .user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
logger = logging.getLogger(__name__)
@@ -28,11 +17,6 @@ async def sync_jellyfin_users() -> int:
users = await client.get_users()
if not isinstance(users, list):
return 0
save_jellyfin_users_cache(users)
# Jellyfin is the canonical source for local user objects; Jellyseerr IDs are
# matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
imported = 0
for user in users:
if not isinstance(user, dict):
@@ -40,26 +24,8 @@ async def sync_jellyfin_users() -> int:
name = user.get("Name")
if not name:
continue
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
created = create_user_if_missing(
name,
"jellyfin-user",
role="user",
auth_provider="jellyfin",
jellyseerr_user_id=matched_id,
)
if created:
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
imported += 1
else:
existing = get_user_by_username(name)
if (
existing
and str(existing.get("role") or "user").strip().lower() != "admin"
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
):
set_user_auth_provider(name, "jellyfin")
if matched_id is not None:
set_user_jellyseerr_id(name, matched_id)
return imported

View File

@@ -11,21 +11,9 @@ from ..clients.radarr import RadarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..runtime import get_runtime_settings
from ..db import (
save_snapshot,
get_request_cache_payload,
get_request_cache_by_id,
get_recent_snapshots,
get_setting,
set_setting,
)
from ..db import save_snapshot, get_request_cache_payload
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
logger = logging.getLogger(__name__)
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
_jellyfin_scan_key = "jellyfin_scan_last_at"
STATUS_LABELS = {
1: "Waiting for approval",
@@ -53,35 +41,6 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
return
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return
last_scan = get_setting(_jellyfin_scan_key)
if last_scan:
try:
parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00"))
if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS:
return
except ValueError:
pass
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
if previous:
prev_state = previous[0].get("state")
if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
return
try:
await client.refresh_library()
except Exception as exc:
logger.warning("Jellyfin library refresh failed: %s", exc)
return
set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat())
logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id)
def _queue_records(queue: Any) -> List[Dict[str, Any]]:
if isinstance(queue, dict):
records = queue.get("records")
@@ -226,21 +185,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
logging.getLogger(__name__).debug(
"snapshot cache miss: request_id=%s mode=%s", request_id, mode
)
if cached_request is not None:
cache_meta = get_request_cache_by_id(int(request_id))
cached_title = cache_meta.get("title") if cache_meta else None
if cached_title and isinstance(cached_request, dict):
media = cached_request.get("media")
if not isinstance(media, dict):
media = {}
cached_request["media"] = media
if not media.get("title") and not media.get("name"):
media["title"] = cached_title
media["name"] = cached_title
if not cached_request.get("title") and not cached_request.get("name"):
cached_request["title"] = cached_title
allow_remote = mode == "always_js" and jellyseerr.configured()
if not jellyseerr.configured() and not cached_request:
timeline.append(TimelineHop(service="Jellyseerr", status="not_configured"))
timeline.append(TimelineHop(service="Sonarr/Radarr", status="not_configured"))
@@ -248,15 +193,9 @@ async def build_snapshot(request_id: str) -> Snapshot:
timeline.append(TimelineHop(service="qBittorrent", status="not_configured"))
snapshot.timeline = timeline
return snapshot
if cached_request is None and not allow_remote:
timeline.append(TimelineHop(service="Jellyseerr", status="cache_miss"))
snapshot.timeline = timeline
snapshot.state = NormalizedState.unknown
snapshot.state_reason = "Request not found in cache"
return snapshot
jelly_request = cached_request
if allow_remote and (jelly_request is None or mode == "always_js"):
if (jelly_request is None or mode == "always_js") and jellyseerr.configured():
try:
jelly_request = await jellyseerr.get_request(request_id)
logging.getLogger(__name__).debug(
@@ -279,25 +218,17 @@ async def build_snapshot(request_id: str) -> Snapshot:
jelly_status = jelly_request.get("status", "unknown")
jelly_status_label = _status_label(jelly_status)
jelly_type = jelly_request.get("type") or "unknown"
media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {}
if not isinstance(media, dict):
media = {}
snapshot.title = (
media.get("title")
or media.get("name")
or jelly_request.get("title")
or jelly_request.get("name")
or "Unknown"
)
snapshot.year = media.get("year") or jelly_request.get("year")
snapshot.title = jelly_request.get("media", {}).get("title", "Unknown")
snapshot.year = jelly_request.get("media", {}).get("year")
snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {}
poster_path = None
backdrop_path = None
if isinstance(media, dict):
poster_path = media.get("posterPath") or media.get("poster_path")
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
if snapshot.title in {None, "", "Unknown"} and allow_remote:
if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured():
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
try:
@@ -450,6 +381,10 @@ async def build_snapshot(request_id: str) -> Snapshot:
if arr_state is None:
arr_state = "unknown"
if arr_state == "missing" and media_status_code in {4}:
arr_state = "available"
elif arr_state == "missing" and media_status_code in {6}:
arr_state = "added"
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
@@ -589,7 +524,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state_reason = "Waiting for download to start in qBittorrent."
elif arr_state == "missing" and derived_approved:
snapshot.state = NormalizedState.needs_add
snapshot.state_reason = "Approved, but not yet added to Sonarr/Radarr."
snapshot.state_reason = "Approved, but not added to the library yet."
elif arr_state == "searching":
snapshot.state = NormalizedState.searching
snapshot.state_reason = "Searching for a matching release."
@@ -613,7 +548,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
actions.append(
ActionOption(
id="readd_to_arr",
label="Push to Sonarr/Radarr",
label="Add to the library queue (Sonarr/Radarr)",
risk="medium",
)
)
@@ -669,6 +604,5 @@ async def build_snapshot(request_id: str) -> Snapshot:
},
}
await _maybe_refresh_jellyfin(snapshot)
await asyncio.to_thread(save_snapshot, snapshot)
return snapshot

View File

@@ -1,158 +0,0 @@
import json
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from ..db import get_setting, set_setting, delete_setting
logger = logging.getLogger(__name__)
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
parsed = _parse_iso(cached_at)
if not parsed:
return False
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(minutes=max_age_minutes)
def _load_cached_users(
cache_key: str, cache_at_key: str, max_age_minutes: int
) -> Optional[List[Dict[str, Any]]]:
cached_at = get_setting(cache_at_key)
if not _cache_is_fresh(cached_at, max_age_minutes):
return None
raw = get_setting(cache_key)
if not raw:
return None
try:
data = json.loads(raw)
except (TypeError, json.JSONDecodeError):
return None
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
return None
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
payload = json.dumps(users, ensure_ascii=True)
set_setting(cache_key, payload)
set_setting(cache_at_key, _now_iso())
def _normalized_handles(value: Any) -> List[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
candidate_to_id: Dict[str, int] = {}
for user in users:
if not isinstance(user, dict):
continue
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
for key in ("username", "email", "displayName", "name"):
for handle in _normalized_handles(user.get(key)):
candidate_to_id.setdefault(handle, user_id)
return candidate_to_id
def match_jellyseerr_user_id(
username: str, candidate_map: Dict[str, int]
) -> Optional[int]:
for handle in _normalized_handles(username):
matched = candidate_map.get(handle)
if matched is not None:
return matched
return None
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("id") or user.get("userId") or user.get("Id"),
"email": user.get("email"),
"username": user.get("username"),
"displayName": user.get("displayName"),
"name": user.get("name"),
}
)
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
logger.debug("Cached Jellyseerr users: %s", len(normalized))
return normalized
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("Id"),
"name": user.get("Name"),
"hasPassword": user.get("HasPassword"),
"lastLoginDate": user.get("LastLoginDate"),
}
)
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
logger.debug("Cached Jellyfin users: %s", len(normalized))
return normalized
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
def clear_user_import_caches() -> Dict[str, int]:
cleared = 0
for key in (
JELLYSEERR_CACHE_KEY,
JELLYSEERR_CACHE_AT_KEY,
JELLYFIN_CACHE_KEY,
JELLYFIN_CACHE_AT_KEY,
):
delete_setting(key)
cleared += 1
logger.debug("Cleared user import cache keys: %s", cleared)
return {"settingsKeysCleared": cleared}

View File

@@ -1,10 +1,19 @@
services:
magent:
image: rephl3xnz/magent:latest
backend:
image: rephl3xnz/magent-backend:latest
env_file:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
frontend:
image: rephl3xnz/magent-frontend:latest
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend

View File

@@ -1,12 +1,23 @@
services:
magent:
backend:
build:
context: .
dockerfile: Dockerfile
dockerfile: backend/Dockerfile
env_file:
- ./.env
ports:
- "3000:3000"
- "8000:8000"
volumes:
- ./data:/app/data
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
environment:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend

View File

@@ -1,28 +0,0 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
[program:backend]
directory=/app
command=uvicorn app.main:app --host 0.0.0.0 --port 8000
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=10
[program:frontend]
directory=/app/frontend
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=20

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,6 @@ const ALLOWED_SECTIONS = new Set([
'cache',
'logs',
'maintenance',
'magent',
'general',
'notifications',
'site',
])
type PageProps = {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
import { redirect } from 'next/navigation'
export default function AdminProfilesRedirectPage() {
redirect('/admin/invites')
}

View File

@@ -1,172 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type RequestRow = {
id: number
title?: string | null
year?: number | null
type?: string | null
statusLabel?: string | null
requestedBy?: string | null
createdAt?: string | null
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
export default function AdminRequestsAllPage() {
const router = useRouter()
const [rows, setRows] = useState<RequestRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pageSize, setPageSize] = useState(50)
const [page, setPage] = useState(1)
const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1
return Math.max(1, Math.ceil(total / pageSize))
}, [total, pageSize])
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const skip = (page - 1) * pageSize
const response = await authFetch(
`${baseUrl}/admin/requests/all?take=${pageSize}&skip=${skip}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error(`Load failed: ${response.status}`)
}
const data = await response.json()
setRows(Array.isArray(data?.results) ? data.results : [])
setTotal(Number(data?.total ?? 0))
} catch (err) {
console.error(err)
setError('Unable to load requests.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [page, pageSize])
useEffect(() => {
if (page > pageCount) {
setPage(pageCount)
}
}, [pageCount, page])
return (
<AdminShell
title="All requests"
subtitle="Paginated view of every cached request."
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section">
<div className="admin-toolbar">
<div className="admin-toolbar-info">
<span>{total.toLocaleString()} total</span>
</div>
<div className="admin-toolbar-actions">
<label className="admin-select">
<span>Per page</span>
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
</div>
</div>
{loading ? (
<div className="status-banner">Loading requests</div>
) : error ? (
<div className="error-banner">{error}</div>
) : rows.length === 0 ? (
<div className="status-banner">No requests found.</div>
) : (
<div className="admin-table">
<div className="admin-table-head">
<span>Request</span>
<span>Status</span>
<span>Requested by</span>
<span>Created</span>
</div>
{rows.map((row) => (
<button
key={row.id}
type="button"
className="admin-table-row"
onClick={() => router.push(`/requests/${row.id}`)}
>
<span>
{row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''}
</span>
<span>{row.statusLabel || 'Unknown'}</span>
<span>{row.requestedBy || 'Unknown'}</span>
<span>{formatDateTime(row.createdAt)}</span>
</button>
))}
</div>
)}
<div className="admin-pagination">
<button type="button" onClick={() => setPage(1)} disabled={page <= 1}>
First
</button>
<button type="button" onClick={() => setPage(page - 1)} disabled={page <= 1}>
Previous
</button>
<span>
Page {page} of {pageCount}
</span>
<button
type="button"
onClick={() => setPage(page + 1)}
disabled={page >= pageCount}
>
Next
</button>
<button
type="button"
onClick={() => setPage(pageCount)}
disabled={page >= pageCount}
>
Last
</button>
</div>
</section>
</AdminShell>
)
}

View File

@@ -1,211 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type FlowStage = {
title: string
input: string
action: string
output: string
}
const REQUEST_FLOW: FlowStage[] = [
{
title: 'Identity + access',
input: 'Jellyfin/local login',
action: 'Magent validates credentials and role',
output: 'JWT token + user scope',
},
{
title: 'Request intake',
input: 'Jellyseerr request ID',
action: 'Magent snapshots request + media metadata',
output: 'Unified request state',
},
{
title: 'Queue orchestration',
input: 'Approved request',
action: 'Sonarr/Radarr add/search operations',
output: 'Grab decision',
},
{
title: 'Download execution',
input: 'Selected release',
action: 'qBittorrent downloads + reports progress',
output: 'Import-ready payload',
},
{
title: 'Library import',
input: 'Completed download',
action: 'Sonarr/Radarr import and finalize',
output: 'Available media object',
},
{
title: 'Playback availability',
input: 'Imported media',
action: 'Jellyfin refresh + link resolution',
output: 'Ready-to-watch state',
},
]
export default function AdminSystemGuidePage() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
let active = true
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
router.push('/')
return
}
const me = await response.json()
if (!active) return
if (me?.role !== 'admin') {
router.push('/')
return
}
setAuthorized(true)
} catch (error) {
console.error(error)
router.push('/')
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [router])
if (loading) {
return <main className="card">Loading system guide...</main>
}
if (!authorized) {
return null
}
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Guide map</span>
<h2>Quick path</h2>
<p>Identity Intake Queue Download Import Playback.</p>
<span className="small-pill">Admin only</span>
</div>
</div>
)
return (
<AdminShell
title="System guide"
subtitle="Admin-only architecture and operational flow for Magent."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section system-guide">
<div className="admin-panel">
<h2>End-to-end system flow</h2>
<p className="lede">
This is the exact runtime path for request processing and availability in the current build.
</p>
<div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => (
<div key={stage.title} className="system-flow-segment">
<article className="system-flow-card">
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
<div className="system-flow-card-row">
<span>Input</span>
<strong>{stage.input}</strong>
</div>
<div className="system-flow-card-row">
<span>Action</span>
<strong>{stage.action}</strong>
</div>
<div className="system-flow-card-row">
<span>Output</span>
<strong>{stage.output}</strong>
</div>
</article>
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true"></div>}
</div>
))}
</div>
</div>
<div className="admin-panel">
<h2>Operational controls by area</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>General</h3>
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
</article>
<article className="system-guide-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
</article>
<article className="system-guide-card">
<h3>Users</h3>
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
</article>
<article className="system-guide-card">
<h3>Invite management</h3>
<p>Master template, profile assignment, invite access policy, and invite trace map lineage.</p>
</article>
<article className="system-guide-card">
<h3>Requests + cache</h3>
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
</article>
<article className="system-guide-card">
<h3>Live request page</h3>
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>Stall recovery path (decision flow)</h2>
<ol className="system-decision-list">
<li>
Request approved but not in Arr queue <span></span> run <strong>Re-add to Arr</strong>.
</li>
<li>
In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options.
</li>
<li>
Release exists and user should not pick manually <span></span> run <strong>Search + auto-download</strong>.
</li>
<li>
Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>.
</li>
<li>
Imported but not visible to user <span></span> validate Jellyfin visibility/link from request page.
</li>
</ol>
</div>
</section>
</AdminShell>
)
}

View File

@@ -1,85 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type SiteInfo = {
changelog?: string
}
const parseChangelog = (raw: string) =>
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
export default function ChangelogPage() {
const router = useRouter()
const [entries, setEntries] = useState<string[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = getToken()
if (!token) {
router.push('/login')
return
}
let active = true
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/site/info`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Failed to load changelog')
}
const data: SiteInfo = await response.json()
if (!active) return
setEntries(parseChangelog(data?.changelog ?? ''))
} catch (err) {
console.error(err)
if (!active) return
setEntries([])
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [router])
const content = useMemo(() => {
if (loading) {
return <div className="loading-text">Loading changelog...</div>
}
if (entries.length === 0) {
return <div className="meta">No updates posted yet.</div>
}
return (
<ul className="changelog-list">
{entries.map((entry, index) => (
<li key={`${entry}-${index}`}>{entry}</li>
))}
</ul>
)
}, [entries, loading])
return (
<div className="page">
<section className="card changelog-card">
<div className="changelog-header">
<h1>Changelog</h1>
<p className="lede">Latest updates and release notes.</p>
</div>
{content}
</section>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,10 @@ export default function HowItWorksPage() {
<main className="card how-page">
<header className="how-hero">
<p className="eyebrow">How this works</p>
<h1>How Magent works now</h1>
<h1>Your request, step by step</h1>
<p className="lede">
End-to-end request flow, live status updates, and the exact tools available to users and
admins.
Magent is a friendly status checker. It looks at a few helper apps, then shows you where
your request is and what you can safely do next.
</p>
</header>
@@ -52,172 +52,34 @@ export default function HowItWorksPage() {
</section>
<section className="how-flow">
<h2>The pipeline (request to ready)</h2>
<h2>The pipeline in plain English</h2>
<ol className="how-steps">
<li>
<strong>Request created</strong> in Jellyseerr.
<strong>You request a title</strong> in Jellyseerr.
</li>
<li>
<strong>Approved</strong> and sent to Sonarr/Radarr.
<strong>Sonarr/Radarr adds it</strong> to the library list.
</li>
<li>
<strong>Search runs</strong> against indexers via Prowlarr.
<strong>Prowlarr looks for sources</strong> and sends results back.
</li>
<li>
<strong>Grabbed</strong> and downloaded by qBittorrent.
<strong>qBittorrent downloads</strong> the match.
</li>
<li>
<strong>Imported</strong> by Sonarr/Radarr.
<strong>Sonarr/Radarr imports</strong> it into your library.
</li>
<li>
<strong>Available</strong> in Jellyfin.
<strong>Jellyfin shows it</strong> when it is ready to watch.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Live updates (no refresh needed)</h2>
<div className="how-step-grid">
<article className="how-step-card step-arr">
<div className="step-badge">1</div>
<h3>Request page updates in real time</h3>
<p className="step-note">
Status, timeline hops, and action history update automatically while you are viewing
the request.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Download progress updates live</h3>
<p className="step-note">
Torrent progress, queue state, and downloader details refresh automatically so users
do not need to hard refresh.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as import finishes</h3>
<p className="step-note">
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
shows it as ready.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Request actions and when to use them</h2>
<div className="how-step-grid">
<article className="how-step-card step-jellyseerr">
<div className="step-badge">1</div>
<h3>Re-add to Arr</h3>
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Missing NEEDS_ADD / ADDED state transitions</li>
<li>Queue repair after Arr-side cleanup</li>
</ul>
</article>
<article className="how-step-card step-arr">
<div className="step-badge">2</div>
<h3>Search releases</h3>
<p className="step-note">Runs a search and shows concrete release options.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Manual selection of a specific release/indexer</li>
<li>Checking whether results currently exist</li>
</ul>
</article>
<article className="how-step-card step-prowlarr">
<div className="step-badge">3</div>
<h3>Search + auto-download</h3>
<p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Fast recovery when users have auto-search access</li>
<li>Hands-off retry of stalled requests</li>
</ul>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">4</div>
<h3>Resume download</h3>
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Paused queue entries</li>
<li>Downloader restarts</li>
</ul>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">5</div>
<h3>Open in Jellyfin</h3>
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
<div className="step-fix-title">Best for</div>
<ul className="step-fix-list">
<li>Immediate playback confirmation</li>
<li>User handoff from request tracking to watching</li>
</ul>
</article>
</div>
</section>
<section className="how-flow">
<h2>Invite and account flow</h2>
<ol className="how-steps">
<li>
<strong>Invite created</strong> by admin or eligible user.
</li>
<li>
<strong>User signs up</strong> and Magent creates/links the account.
</li>
<li>
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
</li>
<li>
<strong>Admin trace map</strong> can show inviter invited lineage.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Admin controls available</h2>
<div className="how-grid">
<article className="how-card">
<h3>General</h3>
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
</article>
<article className="how-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
</article>
<article className="how-card">
<h3>Users</h3>
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
</article>
<article className="how-card">
<h3>Invite management</h3>
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
</article>
<article className="how-card">
<h3>Request sync + cache</h3>
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
</article>
<article className="how-card">
<h3>Maintenance + logs</h3>
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
</article>
</div>
</section>
<section className="how-callout">
<h2>Why a request can still wait</h2>
<h2>Why Magent sometimes says waiting</h2>
<p>
If indexers do not return a valid release yet, Magent will show waiting/search states.
That usually means content availability is the blocker, not a broken pipeline.
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
That does not mean it is broken. It usually means the release is not available yet.
</p>
</section>
</main>

View File

@@ -5,7 +5,6 @@ import HeaderIdentity from './ui/HeaderIdentity'
import ThemeToggle from './ui/ThemeToggle'
import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo'
import SiteStatus from './ui/SiteStatus'
export const metadata = {
title: 'Magent',
@@ -29,14 +28,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</a>
</div>
<div className="header-right">
<ThemeToggle />
<HeaderIdentity />
<ThemeToggle />
</div>
<div className="header-nav">
<HeaderActions />
</div>
</header>
<SiteStatus />
{children}
</div>
</body>

View File

@@ -23,18 +23,3 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
}
return fetch(input, { ...init, headers })
}
export const getEventStreamToken = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/stream-token`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Stream token request failed: ${response.status}`)
}
const data = await response.json()
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
if (!token) {
throw new Error('Stream token not returned')
}
return token
}

View File

@@ -52,7 +52,7 @@ export default function LoginPage() {
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1>
<p className="lede">Use your Jellyfin account, or sign in with a local Magent admin account.</p>
<p className="lede">Use your Jellyfin account, or sign in with Magent instead.</p>
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form">
<label>
Username
@@ -85,9 +85,6 @@ export default function LoginPage() {
>
Sign in with Magent account
</button>
<a className="ghost-button" href="/signup">
Have an invite? Create your account (Jellyfin + Magent)
</a>
</form>
</main>
)

View File

@@ -2,25 +2,7 @@
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,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
export default function HomePage() {
const router = useRouter()
@@ -32,7 +14,6 @@ export default function HomePage() {
year?: number
statusLabel?: string
artwork?: { poster_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
@@ -49,9 +30,6 @@ export default function HomePage() {
>(null)
const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
@@ -64,61 +42,6 @@ export default function HomePage() {
void runSearch(trimmed)
}
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
const updateServiceStatus = (name: string, status: string, message?: string) => {
setServicesStatus((prev) => {
if (!prev) return prev
return {
...prev,
services: prev.services.map((service) =>
service.name === name ? { ...service, status, message } : service
),
}
})
}
const testService = async (name: string) => {
const slug = toServiceSlug(name)
setServiceTesting((prev) => ({ ...prev, [name]: true }))
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const data = await response.json()
const status = data?.status ?? 'unknown'
const message =
data?.message ||
(status === 'up'
? 'API OK'
: status === 'down'
? 'API unreachable'
: status === 'degraded'
? 'Health warnings'
: status === 'not_configured'
? 'Not configured'
: 'Unknown')
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
updateServiceStatus(name, status, data?.message)
} catch (error) {
console.error(error)
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
} finally {
setServiceTesting((prev) => ({ ...prev, [name]: false }))
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
@@ -156,7 +79,24 @@ export default function HomePage() {
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
setRecent(
data.results
.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,
statusLabel: item.statusLabel,
artwork: item.artwork,
}
})
)
}
} catch (error) {
console.error(error)
@@ -197,92 +137,10 @@ export default function HomePage() {
}
}
void load()
if (liveStreamConnected) {
return
}
load()
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
if (!getToken()) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const streamUrl = `${baseUrl}/events/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}`
source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
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
}
if (payload.type === 'home_services') {
if (payload.status && typeof payload.status === 'object') {
setServicesStatus(payload.status)
setServicesError(null)
setServicesLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setServicesError('Service status is not available right now.')
setServicesLoading(false)
}
}
} catch (error) {
console.error(error)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
} catch (error) {
if (closed) return
console.error(error)
setLiveStreamConnected(false)
}
}
void connect()
return () => {
closed = true
setLiveStreamConnected(false)
source?.close()
}
}, [authReady, recentDays])
}, [authReady, router])
const runSearch = async (term: string) => {
try {
@@ -321,13 +179,6 @@ export default function HomePage() {
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()
}
return (
<main className="card">
<div className="layout-grid">
@@ -363,37 +214,21 @@ export default function HomePage() {
return order.map((name) => {
const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown'
const testing = serviceTesting[name] ?? false
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<div className="system-meta">
<span className="system-name">{name}</span>
{serviceTestResults[name] && (
<span className="system-test-message">{serviceTestResults[name]}</span>
)}
</div>
<div className="system-actions">
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
<span className="system-name">{name}</span>
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
</div>
)
})
@@ -404,12 +239,11 @@ export default function HomePage() {
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<label className="recent-filter">
<span>Show</span>
<span>Show last</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
@@ -456,7 +290,6 @@ export default function HomePage() {
<span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
{item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
</button>

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
@@ -8,143 +8,16 @@ type ProfileInfo = {
username: string
role: string
auth_provider: string
invite_management_enabled?: boolean
}
type ProfileStats = {
total: number
ready: number
pending: number
in_progress: number
declined: number
working: number
partial: number
approved: number
last_request_at?: string | null
share: number
global_total: number
most_active_user?: { username: string; total: number } | null
}
type ActivityEntry = {
ip: string
user_agent: string
first_seen_at: string
last_seen_at: string
hit_count: number
}
type ProfileActivity = {
last_ip?: string | null
last_user_agent?: string | null
last_seen_at?: string | null
device_count: number
recent: ActivityEntry[]
}
type ProfileResponse = {
user: ProfileInfo
stats: ProfileStats
activity: ProfileActivity
}
type OwnedInvite = {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
use_count: number
remaining_uses?: number | null
enabled: boolean
expires_at?: string | null
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
updated_at?: string | null
}
type OwnedInvitesResponse = {
invites?: OwnedInvite[]
count?: number
invite_access?: {
enabled?: boolean
managed_by_master?: boolean
}
master_invite?: {
id: number
code: string
label?: string | null
description?: string | null
max_uses?: number | null
enabled?: boolean
expires_at?: string | null
is_usable?: boolean
} | null
}
type OwnedInviteForm = {
code: string
label: string
description: string
max_uses: string
expires_at: string
enabled: boolean
}
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
max_uses: '',
expires_at: '',
enabled: true,
})
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const parseBrowser = (agent?: string | null) => {
if (!agent) return 'Unknown'
const value = agent.toLowerCase()
if (value.includes('edg/')) return 'Edge'
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
if (value.includes('firefox/')) return 'Firefox'
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
return 'Unknown'
}
export default function ProfilePage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const [inviteError, setInviteError] = useState<string | null>(null)
const [invites, setInvites] = useState<OwnedInvite[]>([])
const [inviteSaving, setInviteSaving] = useState(false)
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
const [loading, setLoading] = useState(true)
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
}, [])
useEffect(() => {
if (!getToken()) {
router.push('/login')
@@ -153,32 +26,18 @@ export default function ProfilePage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const [profileResponse, invitesResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/profile`),
authFetch(`${baseUrl}/auth/profile/invites`),
])
if (!profileResponse.ok || !invitesResponse.ok) {
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const [data, inviteData] = (await Promise.all([
profileResponse.json(),
invitesResponse.json(),
])) as [ProfileResponse, OwnedInvitesResponse]
const user = data?.user ?? {}
const data = await response.json()
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
username: data?.username ?? 'Unknown',
role: data?.role ?? 'user',
auth_provider: data?.auth_provider ?? 'local',
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(inviteData?.master_invite ?? null)
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
@@ -207,177 +66,18 @@ export default function ProfilePage() {
}),
})
if (!response.ok) {
let detail = 'Update failed'
try {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
detail = payload.detail
}
} catch {
const text = await response.text().catch(() => '')
if (text?.trim()) detail = text
}
throw new Error(detail)
const text = await response.text()
throw new Error(text || 'Update failed')
}
const data = await response.json().catch(() => ({}))
setCurrentPassword('')
setNewPassword('')
setStatus(
data?.provider === 'jellyfin'
? 'Password updated in Jellyfin (and Magent cache).'
: 'Password updated.'
)
setStatus('Password updated.')
} catch (err) {
console.error(err)
if (err instanceof Error && err.message) {
setStatus(`Could not update password. ${err.message}`)
} else {
setStatus('Could not update password. Check your current password.')
}
setStatus('Could not update password. Check your current password.')
}
}
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultOwnedInviteForm())
}
const editInvite = (invite: OwnedInvite) => {
setInviteEditingId(invite.id)
setInviteError(null)
setInviteStatus(null)
setInviteForm({
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
})
}
const reloadInvites = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Invite refresh failed: ${response.status}`)
}
const data = (await response.json()) as OwnedInvitesResponse
setInvites(Array.isArray(data?.invites) ? data.invites : [])
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
setMasterInviteTemplate(data?.master_invite ?? null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
setInviteSaving(true)
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
inviteEditingId == null
? `${baseUrl}/auth/profile/invites`
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
{
method: inviteEditingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled,
}),
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite save failed')
}
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
resetInviteEditor()
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
} finally {
setInviteSaving(false)
}
}
const deleteInvite = async (invite: OwnedInvite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setInviteError(null)
setInviteStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Invite delete failed')
}
if (inviteEditingId === invite.id) {
resetInviteEditor()
}
setInviteStatus(`Deleted invite ${invite.code}.`)
await reloadInvites()
} catch (err) {
console.error(err)
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
}
}
const copyInviteLink = async (invite: OwnedInvite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setInviteStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
window.prompt('Copy invite link', url)
}
}
const authProvider = profile?.auth_provider ?? 'local'
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
const securityHelpText =
authProvider === 'jellyfin'
? 'Changing your password here updates your Jellyfin account and refreshes Magents cached sign-in.'
: authProvider === 'local'
? 'Change your Magent account password.'
: 'Password changes are not available for this sign-in provider.'
useEffect(() => {
if (activeTab === 'invites' && !canManageInvites) {
setActiveTab('overview')
}
}, [activeTab, canManageInvites])
if (loading) {
return <main className="card">Loading profile...</main>
}
@@ -391,388 +91,35 @@ export default function ProfilePage() {
{profile.auth_provider}.
</div>
)}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'overview'}
className={activeTab === 'overview' ? 'is-active' : ''}
onClick={() => setActiveTab('overview')}
>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'activity'}
className={activeTab === 'activity' ? 'is-active' : ''}
onClick={() => setActiveTab('activity')}
>
Activity
</button>
{canManageInvites ? (
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
My invites
</button>
) : null}
<button
type="button"
role="tab"
aria-selected={activeTab === 'security'}
className={activeTab === 'security' ? 'is-active' : ''}
onClick={() => setActiveTab('security')}
>
Security
</button>
{profile?.auth_provider !== 'local' ? (
<div className="status-banner">
Password changes are only available for local Magent accounts.
</div>
</div>
{activeTab === 'overview' && (
<section className="profile-section profile-tab-panel">
<h2>Account stats</h2>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">Requests submitted</div>
<div className="stat-value">{stats?.total ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Ready to watch</div>
<div className="stat-value">{stats?.ready ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">In progress</div>
<div className="stat-value">{stats?.in_progress ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Pending approval</div>
<div className="stat-value">{stats?.pending ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Declined</div>
<div className="stat-value">{stats?.declined ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Working</div>
<div className="stat-value">{stats?.working ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Partial</div>
<div className="stat-value">{stats?.partial ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Approved</div>
<div className="stat-value">{stats?.approved ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Last request</div>
<div className="stat-value stat-value--small">
{formatDate(stats?.last_request_at)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Share of all requests</div>
<div className="stat-value">
{stats?.global_total
? `${Math.round((stats.share || 0) * 1000) / 10}%`
: '0%'}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Total requests (global)</div>
<div className="stat-value">{stats?.global_total ?? 0}</div>
</div>
{profile?.role === 'admin' ? (
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
</div>
</div>
) : null}
) : (
<form onSubmit={submit} className="auth-form">
<label>
Current password
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">Update password</button>
</div>
</section>
)}
{activeTab === 'activity' && (
<section className="profile-section profile-tab-panel">
<h2>Connection history</h2>
<div className="status-banner">
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
</div>
<div className="connection-list">
{(activity?.recent ?? []).map((entry, index) => (
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
<div>
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
<div className="meta">IP: {entry.ip}</div>
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
</div>
<div className="connection-count">{entry.hit_count} visits</div>
</div>
))}
{activity && activity.recent.length === 0 ? (
<div className="status-banner">No connection history yet.</div>
) : null}
</div>
</section>
)}
{activeTab === 'invites' && (
<section className="profile-section profile-invites-section profile-tab-panel">
<div className="user-directory-panel-header">
<div>
<h2>My invites</h2>
<p className="lede">
{inviteManagedByMaster
? 'Create and manage invite links youve issued. New invites use the admin master invite rule.'
: 'Create and manage invite links youve issued. New invites use your account defaults.'}
</p>
</div>
</div>
{inviteError && <div className="error-banner">{inviteError}</div>}
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
<div className="profile-invites-layout">
<div className="profile-invite-form-card">
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
<p className="meta profile-invite-form-lede">
Share the generated signup link with the person you want to invite.
</p>
{inviteManagedByMaster && masterInviteTemplate ? (
<div className="status-banner profile-invite-master-banner">
Using master invite rule <code>{masterInviteTemplate.code}</code>
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
</div>
) : null}
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Optional code and label for easier tracking.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(event) =>
setInviteForm((current) => ({ ...current, code: event.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(event) =>
setInviteForm((current) => ({ ...current, label: event.target.value }))
}
placeholder="Family invite"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Optional note shown on the signup page"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(event) =>
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
disabled={inviteManagedByMaster}
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(event) =>
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
disabled={inviteManagedByMaster}
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable this invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({
...current,
enabled: event.target.checked,
}))
}
disabled={inviteManagedByMaster}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving
? 'Saving…'
: inviteEditingId == null
? 'Create invite'
: 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
<div className="meta profile-invite-hint">
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
</div>
</div>
<div className="profile-invites-list">
{invites.length === 0 ? (
<div className="status-banner">You havent created any invites yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
<span className="small-pill is-muted">
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
</span>
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
</p>
)}
<div className="admin-meta-row">
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => copyInviteLink(invite)}
>
Copy link
</button>
<button
type="button"
className="ghost-button"
onClick={() => editInvite(invite)}
>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
)}
{activeTab === 'security' && (
<section className="profile-section profile-tab-panel">
<h2>Security</h2>
<div className="status-banner">{securityHelpText}</div>
{canChangePassword ? (
<form onSubmit={submit} className="auth-form profile-security-form">
<label>
Current password
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
/>
</label>
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
</button>
</div>
</form>
) : (
<div className="status-banner">
Password changes are not available for {authProvider} sign-in accounts from Magent.
</div>
)}
</section>
</form>
)}
</main>
)

View File

@@ -1,9 +1,8 @@
'use client'
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type TimelineHop = {
service: string
@@ -34,7 +33,6 @@ type ReleaseOption = {
seeders?: number
leechers?: number
protocol?: string
publishDate?: string
infoUrl?: string
downloadUrl?: string
}
@@ -125,7 +123,7 @@ const friendlyState = (value: string) => {
const map: Record<string, string> = {
REQUESTED: 'Waiting for approval',
APPROVED: 'Approved and queued',
NEEDS_ADD: 'Push to Sonarr/Radarr',
NEEDS_ADD: 'Needs adding to the library',
ADDED_TO_ARR: 'Added to the library queue',
SEARCHING: 'Searching for releases',
GRABBED: 'Download queued',
@@ -157,7 +155,7 @@ const friendlyTimelineStatus = (service: string, status: string) => {
}
if (service === 'Sonarr/Radarr') {
const map: Record<string, string> = {
missing: 'Push to Sonarr/Radarr',
missing: 'Not added yet',
added: 'Added to the library queue',
searching: 'Searching for releases',
available: 'Ready to watch',
@@ -252,64 +250,6 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
}
load()
}, [params.id, router])
useEffect(() => {
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 streamUrl = `${baseUrl}/events/requests/${encodeURIComponent(
params.id
)}/stream?stream_token=${encodeURIComponent(streamToken)}`
source = new EventSource(streamUrl)
source.onmessage = (event) => {
if (closed) return
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object' || payload.type !== 'request_live') {
return
}
if (String(payload.request_id ?? '') !== String(params.id)) {
return
}
if (payload.snapshot && typeof payload.snapshot === 'object') {
setSnapshot(payload.snapshot as Snapshot)
}
if (Array.isArray(payload.history)) {
setHistorySnapshots(payload.history as SnapshotHistory[])
}
if (Array.isArray(payload.actions)) {
setHistoryActions(payload.actions as ActionHistory[])
}
} catch (error) {
console.error(error)
}
}
source.onerror = () => {
if (closed) return
}
} catch (error) {
if (closed) return
console.error(error)
}
}
void connect()
return () => {
closed = true
source?.close()
}
}, [params.id])
if (loading) {
@@ -334,11 +274,9 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const downloadHop = snapshot.timeline.find((hop) => hop.service === 'qBittorrent')
const downloadState = downloadHop?.details?.summary ?? downloadHop?.status ?? 'Unknown'
const jellyfinAvailable = Boolean(snapshot.raw?.jellyfin?.available)
const arrStageLabel =
snapshot.state === 'NEEDS_ADD' ? 'Push to Sonarr/Radarr' : 'Library queue'
const pipelineSteps = [
{ key: 'Jellyseerr', label: 'Jellyseerr' },
{ key: 'Sonarr/Radarr', label: arrStageLabel },
{ key: 'Sonarr/Radarr', label: 'Library queue' },
{ key: 'Prowlarr', label: 'Search' },
{ key: 'qBittorrent', label: 'Download' },
{ key: 'Jellyfin', label: 'Jellyfin' },
@@ -370,14 +308,11 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
<div className="request-header">
<div className="request-header-main">
{resolvedPoster && (
<Image
<img
className="request-poster"
src={resolvedPoster}
alt={`${snapshot.title} poster`}
width={90}
height={135}
sizes="90px"
unoptimized
loading="lazy"
/>
)}
<div>
@@ -655,14 +590,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
body: JSON.stringify({
guid: release.guid,
indexerId: release.indexerId,
indexerName: release.indexer,
downloadUrl: release.downloadUrl,
title: release.title,
size: release.size,
protocol: release.protocol,
publishDate: release.publishDate,
seeders: release.seeders,
leechers: release.leechers,
}),
}
)
@@ -675,8 +603,8 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setActionMessage('Download sent to qBittorrent.')
setModalMessage('Download sent to qBittorrent.')
setActionMessage('Download sent to Sonarr/Radarr.')
setModalMessage('Download sent to Sonarr/Radarr.')
} catch (error) {
console.error(error)
const message = 'Download failed. Check the logs.'

View File

@@ -1,223 +0,0 @@
'use client'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = {
code: string
label?: string | null
description?: string | null
enabled: boolean
is_expired?: boolean
is_usable?: boolean
expires_at?: string | null
max_uses?: number | null
use_count?: number | null
remaining_uses?: number | null
profile?: {
id: number
name: string
description?: string | null
} | null
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
function SignupPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
const [invite, setInvite] = useState<InviteInfo | null>(null)
const [inviteLoading, setInviteLoading] = useState(false)
const [loading, setLoading] = useState(false)
const [username, setUsername] = 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])
const lookupInvite = async (code: string) => {
const trimmed = code.trim()
if (!trimmed) {
setInvite(null)
return
}
setInviteLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite not found')
}
const data = await response.json()
setInvite(data?.invite ?? null)
setStatus('Invite loaded.')
} catch (err) {
console.error(err)
setInvite(null)
setError('Invite code not found or unavailable.')
} finally {
setInviteLoading(false)
}
}
useEffect(() => {
const initialCode = searchParams.get('code') ?? ''
if (initialCode) {
setInviteCode(initialCode)
void lookupInvite(initialCode)
}
}, [searchParams])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
if (!inviteCode.trim()) {
setError('Invite code is required.')
return
}
if (!invite?.is_usable) {
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
clearToken()
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
password,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sign-up failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
window.location.href = '/'
return
}
throw new Error('Sign-up did not return a token')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to create account.')
} finally {
setLoading(false)
}
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create account</h1>
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
<form onSubmit={submit} className="auth-form">
<label>
Invite code
<div className="invite-lookup-row">
<input
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Paste your invite code"
autoCapitalize="characters"
/>
<button
type="button"
className="ghost-button"
disabled={inviteLoading}
onClick={() => void lookupInvite(inviteCode)}
>
{inviteLoading ? 'Checking…' : 'Check invite'}
</button>
</div>
</label>
{invite && (
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}>
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Profile: {invite.profile?.name || 'None'}</span>
</div>
</div>
)}
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
Back to sign in
</button>
</form>
</main>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading sign-up</main>}>
<SignupPageContent />
</Suspense>
)
}

View File

@@ -7,11 +7,10 @@ type AdminShellProps = {
title: string
subtitle?: string
actions?: ReactNode
rail?: ReactNode
children: ReactNode
}
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) {
return (
<div className="admin-shell">
<aside className="admin-shell-nav">
@@ -27,16 +26,6 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
</div>
{children}
</main>
<aside className="admin-shell-rail">
{rail ?? (
<div className="admin-rail-card admin-rail-card--placeholder">
<span className="admin-rail-eyebrow">Insights</span>
<h2>Stats rail</h2>
<p>Use this column for counters, live status, and quick metrics for this page.</p>
<span className="small-pill">{title}</span>
</div>
)}
</aside>
</div>
)
}

View File

@@ -6,7 +6,6 @@ const NAV_GROUPS = [
{
title: 'Services',
items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
@@ -18,19 +17,15 @@ const NAV_GROUPS = [
{
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' },
{ href: '/admin/requests', label: 'Request syncing' },
{ href: '/admin/artwork', label: 'Artwork' },
{ href: '/admin/cache', label: 'Cache' },
],
},
{
title: 'Admin',
items: [
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'System guide' },
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },
],

View File

@@ -32,6 +32,14 @@ export default function HeaderActions() {
void load()
}, [])
const signOut = () => {
clearToken()
setSignedIn(false)
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
if (!signedIn) {
return null
}
@@ -41,7 +49,11 @@ export default function HeaderActions() {
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<a href="/">Requests</a>
<a href="/how-it-works">How it works</a>
<a href="/profile">My profile</a>
{role === 'admin' && <a href="/admin">Settings</a>}
<button type="button" className="header-link" onClick={signOut}>
Sign out
</button>
</div>
)
}

View File

@@ -4,15 +4,13 @@ import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
const [buildNumber, setBuildNumber] = useState<string | null>(null)
const [identity, setIdentity] = useState<string | null>(null)
const [open, setOpen] = useState(false)
useEffect(() => {
const token = getToken()
if (!token) {
setIdentity(null)
setBuildNumber(null)
return
}
const load = async () => {
@@ -26,14 +24,7 @@ export default function HeaderIdentity() {
}
const data = await response.json()
if (data?.username) {
setIdentity({ username: data.username, role: data.role })
}
const siteResponse = await fetch(`${baseUrl}/site/public`)
if (siteResponse.ok) {
const siteInfo = await siteResponse.json()
if (siteInfo?.buildNumber) {
setBuildNumber(siteInfo.buildNumber)
}
setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`)
}
} catch (err) {
console.error(err)
@@ -47,42 +38,14 @@ export default function HeaderIdentity() {
return null
}
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = () => {
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
return (
<div className="signed-in-menu">
<button
type="button"
className="avatar-button"
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={open}
title={label}
>
{initial}
<button type="button" className="signed-in" onClick={() => setOpen((prev) => !prev)}>
Signed in as {identity}
</button>
{open && (
<div className="signed-in-dropdown">
<div className="signed-in-header">Signed in as {label}</div>
<div className="signed-in-actions">
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>
<button type="button" className="signed-in-signout" onClick={signOut}>
Sign out
</button>
</div>
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
<a href="/profile">My profile</a>
</div>
)}
</div>

View File

@@ -1,62 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type BannerInfo = {
enabled: boolean
message: string
tone?: string
}
type SiteInfo = {
buildNumber?: string
banner?: BannerInfo
}
const buildRequest = () => {
const token = getToken()
const baseUrl = getApiBase()
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`
const fetcher = token ? authFetch : fetch
return { token, url, fetcher }
}
export default function SiteStatus() {
const [info, setInfo] = useState<SiteInfo | null>(null)
useEffect(() => {
let active = true
const load = async () => {
try {
const { token, url, fetcher } = buildRequest()
const response = await fetcher(url)
if (!response.ok) {
if (response.status === 401 && token) {
clearToken()
}
return
}
const data = await response.json()
if (!active) return
setInfo(data)
} catch (err) {
console.error(err)
}
}
void load()
return () => {
active = false
}
}, [])
const banner = info?.banner
const tone = banner?.tone || 'info'
return (
<>
{banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div>
) : null}
</>
)
}

View File

@@ -1,691 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
}
type AdminUser = {
id?: number
username: string
role: string
auth_provider?: string | null
last_login_at?: string | null
is_blocked?: boolean
auto_search_enabled?: boolean
invite_management_enabled?: boolean
jellyseerr_user_id?: number | null
profile_id?: number | null
expires_at?: string | null
is_expired?: boolean
invited_by_code?: string | null
invited_at?: string | null
}
type UserLineage = {
invite_code?: string | null
invited_by?: string | null
invite?: {
id?: number
code?: string
label?: string | null
created_by?: string | null
created_at?: string | null
enabled?: boolean
is_usable?: boolean
} | null
} | null
type UserProfileOption = {
id: number
name: string
is_active?: boolean
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return ''
const offsetMs = date.getTimezoneOffset() * 60_000
const local = new Date(date.getTime() - offsetMs)
return local.toISOString().slice(0, 16)
}
const fromLocalDateTimeInput = (value: string) => {
if (!value.trim()) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return null
return date.toISOString()
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UserDetailPage() {
const params = useParams()
const router = useRouter()
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
const [user, setUser] = useState<AdminUser | null>(null)
const [stats, setStats] = useState<UserStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
const [profileSelection, setProfileSelection] = useState('')
const [expiryInput, setExpiryInput] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [savingExpiry, setSavingExpiry] = useState(false)
const [systemActionBusy, setSystemActionBusy] = useState(false)
const [actionStatus, setActionStatus] = useState<string | null>(null)
const [lineage, setLineage] = useState<UserLineage>(null)
const loadProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles`)
if (!response.ok) {
return
}
const data = await response.json()
if (!Array.isArray(data?.profiles)) {
setProfiles([])
return
}
setProfiles(
data.profiles.map((profile: any) => ({
id: Number(profile.id ?? 0),
name: String(profile.name ?? 'Unnamed profile'),
is_active: Boolean(profile.is_active ?? true),
}))
)
} catch (err) {
console.error(err)
}
}
const loadUser = async () => {
if (!idParam) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
if (response.status === 404) {
setError('User not found.')
return
}
throw new Error('Could not load user.')
}
const data = await response.json()
const nextUser = data?.user ?? null
setUser(nextUser)
setStats(normalizeStats(data?.stats))
setLineage((data?.lineage ?? null) as UserLineage)
setProfileSelection(
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
? ''
: String(nextUser.profile_id)
)
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
setError(null)
} catch (err) {
console.error(err)
setError('Could not load user.')
} finally {
setLoading(false)
}
}
const toggleUserBlock = async (blocked: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
} catch (err) {
console.error(err)
setError('Could not update user access.')
}
}
const updateUserRole = async (role: string) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Role updated to ${role}.`)
} catch (err) {
console.error(err)
setError('Could not update user role.')
}
}
const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
} catch (err) {
console.error(err)
setError('Could not update auto search access.')
}
}
const updateInviteManagementEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
} catch (err) {
console.error(err)
setError('Could not update invite access.')
}
}
const applyProfileToUser = async (profileOverride?: string | null) => {
if (!user) return
const profileValue = profileOverride ?? profileSelection
setSavingProfile(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileValue || null }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile update failed')
}
await loadUser()
setActionStatus(profileValue ? 'Profile applied to user.' : 'Profile assignment cleared.')
} catch (err) {
console.error(err)
setError('Could not update user profile.')
} finally {
setSavingProfile(false)
}
}
const saveUserExpiry = async () => {
if (!user) return
const expiresAt = fromLocalDateTimeInput(expiryInput)
if (expiryInput.trim() && !expiresAt) {
setError('Invalid expiry date/time.')
return
}
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expires_at: expiresAt }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry update failed')
}
await loadUser()
setActionStatus(expiresAt ? 'User expiry updated.' : 'User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not update user expiry.')
} finally {
setSavingExpiry(false)
}
}
const clearUserExpiry = async () => {
if (!user) return
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear: true }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry clear failed')
}
setExpiryInput('')
await loadUser()
setActionStatus('User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not clear user expiry.')
} finally {
setSavingExpiry(false)
}
}
const runSystemAction = async (action: 'ban' | 'unban' | 'remove') => {
if (!user) return
if (action === 'remove') {
const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.`
)
if (!confirmed) return
}
if (action === 'ban') {
const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?`
)
if (!confirmed) return
}
setSystemActionBusy(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}
)
const text = await response.text()
let data: any = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!response.ok) {
throw new Error(data?.detail || text || 'Cross-system action failed')
}
const state = data?.status === 'partial' ? 'partial' : 'complete'
if (action === 'remove') {
setActionStatus(`User removed (${state}).`)
router.push('/users')
return
}
await loadUser()
setActionStatus(`${action === 'ban' ? 'Ban' : 'Unban'} completed (${state}).`)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not run cross-system action.')
} finally {
setSystemActionBusy(false)
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
void loadUser()
void loadProfiles()
}, [router, idParam])
if (loading) {
return <main className="card">Loading user...</main>
}
return (
<AdminShell
title={user?.username || 'User'}
subtitle="User overview and request stats."
actions={
<button type="button" onClick={() => router.push('/users')}>
Back to users
</button>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{actionStatus && <div className="status-banner">{actionStatus}</div>}
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<div className="user-detail-page-grid">
<div className="user-detail-main-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<div className="user-detail-title-row">
<strong className="user-detail-name">{user.username}</strong>
<span className={`user-grid-pill ${user.is_blocked ? 'is-blocked' : ''}`}>
{user.is_blocked ? 'Blocked' : 'Active'}
</span>
<span className={`user-grid-pill ${user.is_expired ? 'is-blocked' : ''}`}>
{user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'}
</span>
</div>
<p className="lede">
User identity, access state, and request history for this account.
</p>
</div>
<div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Jellyseerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Role</span>
<strong>{user.role}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Login type</span>
<strong>{user.auth_provider || 'local'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Assigned profile</span>
<strong>{user.profile_id ?? 'None'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Invited by</span>
<strong>{lineage?.invited_by || 'Direct / unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Invite code used</span>
<strong>{lineage?.invite_code || user.invited_by_code || 'None'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Last login</span>
<strong>{formatDateTime(user.last_login_at)}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Account expiry</span>
<strong>{user.expires_at ? formatDateTime(user.expires_at) : 'Never'}</strong>
</div>
</div>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Request statistics</h2>
<p className="lede">Snapshot of request states and recent activity for this user.</p>
</div>
<div className="user-detail-grid">
<div className="user-detail-stat">
<span className="label">Total</span>
<span className="value">{stats?.total ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Ready</span>
<span className="value">{stats?.ready ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Approved</span>
<span className="value">{stats?.approved ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Working</span>
<span className="value">{stats?.working ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Partial</span>
<span className="value">{stats?.partial ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">Declined</span>
<span className="value">{stats?.declined ?? 0}</span>
</div>
<div className="user-detail-stat">
<span className="label">In progress</span>
<span className="value">{stats?.in_progress ?? 0}</span>
</div>
<div className="user-detail-stat user-detail-stat--wide">
<span className="label">Last request</span>
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
</div>
</div>
</div>
</div>
<div className="user-detail-side-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Access controls</h2>
<p className="lede">Role, login access, and auto-download behavior.</p>
</div>
<div className="user-detail-control-stack">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
/>
<span>Make admin</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.auto_search_enabled ?? true)}
disabled={user.role === 'admin'}
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
/>
<span>Allow auto search/download</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.invite_management_enabled ?? false)}
disabled={user.role === 'admin'}
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
/>
<span>Allow self-service invites</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download and invite-management access.
</div>
)}
</div>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Profile defaults</h2>
<p className="lede">Assign or clear an invite profile for this user.</p>
</div>
<div className="user-detail-actions user-detail-actions--stacked">
<label className="admin-select">
<span>Assigned profile</span>
<select
value={profileSelection}
onChange={(event) => setProfileSelection(event.target.value)}
disabled={savingProfile}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
{savingProfile ? 'Applying...' : 'Apply profile defaults'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setProfileSelection('')
void applyProfileToUser('')
}}
disabled={savingProfile}
>
Clear profile
</button>
</div>
</div>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Account expiry</h2>
<p className="lede">Set a specific expiry date/time for this user account.</p>
</div>
<div className="user-detail-actions user-detail-actions--stacked">
<label>
<span className="user-bulk-label">Account expiry</span>
<input
type="datetime-local"
value={expiryInput}
onChange={(event) => setExpiryInput(event.target.value)}
disabled={savingExpiry}
/>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
{savingExpiry ? 'Saving...' : 'Save expiry'}
</button>
<button
type="button"
className="ghost-button"
onClick={clearUserExpiry}
disabled={savingExpiry}
>
Clear expiry
</button>
</div>
</div>
</div>
</div>
</div>
)}
</section>
</AdminShell>
)
}

View File

@@ -2,34 +2,15 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminUser = {
id: number
username: string
role: string
authProvider?: string | null
lastLoginAt?: string | null
isBlocked?: boolean
autoSearchEnabled?: boolean
profileId?: number | null
expiresAt?: string | null
isExpired?: boolean
stats?: UserStats
}
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
}
const formatLastLogin = (value?: string | null) => {
@@ -39,59 +20,16 @@ const formatLastLogin = (value?: string | null) => {
return date.toLocaleString()
}
const formatLastRequest = (value?: string | null) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const formatExpiry = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const emptyStats: UserStats = {
total: 0,
ready: 0,
pending: 0,
approved: 0,
working: 0,
partial: 0,
declined: 0,
in_progress: 0,
last_request_at: null,
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UsersPage() {
const router = useRouter()
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const loadUsers = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/summary`)
const response = await authFetch(`${baseUrl}/admin/users`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
@@ -113,15 +51,6 @@ export default function UsersPage() {
authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked),
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
profileId:
user.profile_id == null || Number.isNaN(Number(user.profile_id))
? null
: Number(user.profile_id),
expiresAt: user.expires_at ?? null,
isExpired: Boolean(user.is_expired),
id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats),
}))
)
} else {
@@ -136,86 +65,44 @@ export default function UsersPage() {
}
}
const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true)
const toggleUserBlock = async (username: string, blocked: boolean) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not sync Jellyseerr users.')
} finally {
setJellyseerrSyncBusy(false)
setError('Could not update user access.')
}
}
const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Jellyseerr. Continue?'
)
if (!confirmed) return
setJellyseerrSyncStatus(null)
setJellyseerrResyncBusy(true)
const updateUserRole = async (username: string, role: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Resync failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not resync Jellyseerr users.')
} finally {
setJellyseerrResyncBusy(false)
setError('Could not update user role.')
}
}
const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Bulk update failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update auto search/download for all users.')
} finally {
setBulkAutoSearchBusy(false)
}
}
useEffect(() => {
if (!getToken()) {
@@ -229,238 +116,50 @@ export default function UsersPage() {
return <main className="card">Loading users...</main>
}
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
const blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length
const normalizedQuery = query.trim().toLowerCase()
const filteredUsers = normalizedQuery
? users.filter((user) => {
const fields = [
user.username,
user.role,
user.authProvider || '',
user.profileId != null ? String(user.profileId) : '',
]
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
})
: users
const filteredCountLabel =
filteredUsers.length === users.length
? `${users.length} users`
: `${filteredUsers.length} of ${users.length} users`
const usersRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card users-rail-summary">
<div className="user-directory-panel-header">
<div>
<h2>Directory summary</h2>
<p className="lede">A quick view of user access and account state.</p>
</div>
</div>
<div className="users-summary-grid">
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Total users</span>
<strong className="users-summary-value">{users.length}</strong>
</div>
<p className="users-summary-meta">{adminCount} admin accounts</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Auto search</span>
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
</div>
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Blocked</span>
<strong className="users-summary-value">{blockedCount}</strong>
</div>
<p className="users-summary-meta">
{blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Expired</span>
<strong className="users-summary-value">{expiredCount}</strong>
</div>
<p className="users-summary-meta">
{expiredCount ? 'Accounts with expired access' : 'No expiries'}
</p>
</div>
</div>
</div>
</div>
)
return (
<AdminShell
title="Users"
subtitle="Directory, access status, and request activity."
rail={usersRail}
subtitle="Manage who can use Magent."
actions={
<button type="button" onClick={loadUsers}>
Reload list
</button>
}
>
<section className="admin-section">
<div className="admin-panel users-page-toolbar">
<div className="users-page-toolbar-grid">
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Directory actions</span>
<div className="users-page-toolbar-actions">
<button
type="button"
className="ghost-button"
onClick={() => router.push('/admin/invites')}
>
Invite management
</button>
<button type="button" onClick={loadUsers}>
Reload list
</button>
</div>
</div>
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Jellyseerr sync</span>
<div className="users-page-toolbar-actions">
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
</button>
<button
type="button"
onClick={resyncJellyseerrUsers}
disabled={jellyseerrResyncBusy}
>
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
</button>
</div>
</div>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-panel user-directory-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Bulk controls</h2>
<p className="lede">
Auto search/download can be enabled or disabled for all non-admin users.
</p>
</div>
</div>
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Auto search/download</strong>
<span>
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
</span>
</div>
<div className="user-bulk-actions">
<button
type="button"
onClick={() => bulkUpdateAutoSearch(true)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => bulkUpdateAutoSearch(false)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
</button>
</div>
</div>
</div>
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
</div>
<div className="user-directory-toolbar">
<div className="user-directory-search">
<label>
<span className="user-bulk-label">Search users</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
/>
</label>
</div>
</div>
</div>
{filteredUsers.length === 0 ? (
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
<div className="user-directory-list">
<div className="user-directory-header">
<span>User</span>
<span>Access</span>
<span>Requests</span>
<span>Activity</span>
</div>
{filteredUsers.map((user) => (
<Link
key={user.username}
className="user-directory-row"
href={`/users/${user.id}`}
>
<div className="user-directory-cell user-directory-cell--identity">
<div className="user-directory-title-row">
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<div className="user-directory-subtext">
Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
</div>
<div className="admin-grid">
{users.map((user) => (
<div key={user.username} className="summary-card user-card">
<div>
<strong>{user.username}</strong>
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
</div>
<div className="user-directory-cell">
<div className="user-directory-pill-row">
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
{user.isBlocked ? 'Blocked' : 'Active'}
</span>
<span
className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}
>
Auto {user.autoSearchEnabled === false ? 'Off' : 'On'}
</span>
<span className={`user-grid-pill ${user.isExpired ? 'is-blocked' : ''}`}>
{user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'}
</span>
</div>
<div className="user-directory-subtext">
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
</div>
<div className="user-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) =>
updateUserRole(user.username, event.target.checked ? 'admin' : 'user')
}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(user.username, !user.isBlocked)}
>
{user.isBlocked ? 'Allow access' : 'Block access'}
</button>
</div>
<div className="user-directory-cell">
<div className="user-directory-stats-inline">
<span><strong>{user.stats?.total ?? 0}</strong> total</span>
<span><strong>{user.stats?.ready ?? 0}</strong> ready</span>
<span><strong>{user.stats?.pending ?? 0}</strong> pending</span>
<span><strong>{user.stats?.in_progress ?? 0}</strong> in progress</span>
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-subtext">
Last login: {formatLastLogin(user.lastLoginAt)}
</div>
<div className="user-directory-subtext">
Last request: {formatLastRequest(user.stats?.last_request_at)}
</div>
</div>
<div className="user-directory-row-chevron" aria-hidden="true">
Open
</div>
</Link>
</div>
))}
</div>
)}

View File

@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "2702261314",
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"build": "next build",

View File

@@ -1,26 +0,0 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
$now = Get-Date
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("M"), $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