Compare commits

...

36 Commits

Author SHA1 Message Date
50be0b6b57 Build 2602261523: live updates, invite cleanup and nuclear resync 2026-02-26 15:24:10 +13:00
5dfe614d15 Build 2602261442: tidy users and invite layouts 2026-02-26 14:42:49 +13:00
ec408df2a1 Build 2602261409: unify invite management controls 2026-02-26 14:10:18 +13:00
f78382c019 Build 2602260214: invites profiles and expiry admin controls 2026-02-26 02:15:21 +13:00
9be0ec75ec Build 2602260022: enterprise UI refresh and users bulk auto-search 2026-02-26 00:23:41 +13:00
be7b899837 Build 2502262321: fix auto-search quality and per-user toggle 2026-02-25 23:22:33 +13:00
d045dd0b07 Build 0202261541: allow FQDN service URLs 2026-02-02 15:43:08 +13:00
138069590b Build 3001262148: single container 2026-01-30 21:54:25 +13:00
8125b766c7 Build 2901262244: format changelog 2026-01-29 22:46:02 +13:00
d53e2917aa Build 2901262240: cache users 2026-01-29 22:42:00 +13:00
d7847652db Tidy full changelog 2026-01-29 22:13:04 +13:00
24ac54d606 Update full changelog 2026-01-29 22:08:17 +13:00
62f392ad37 Bake build number and changelog 2026-01-29 22:03:12 +13:00
e42ae8585d Hardcode build number in backend 2026-01-29 21:49:01 +13:00
06e0797722 release: 2901262102 2026-01-29 21:03:32 +13:00
914f478178 release: 2901262044 2026-01-29 20:45:20 +13:00
fb65d646f2 release: 2901262036 2026-01-29 20:38:37 +13:00
3493bf715e Hydrate missing artwork from Jellyseerr (build 271261539) 2026-01-27 15:40:36 +13:00
b98239ab3e Fallback to TMDB when artwork cache fails (build 271261524) 2026-01-27 15:26:10 +13:00
40dc46c0c5 Add service test buttons (build 271261335) 2026-01-27 13:36:35 +13:00
d23d84ea42 Bump build number (process 2) 271261322 2026-01-27 13:24:35 +13:00
7d6cdcbe02 Add cache load spinner (build 271261238) 2026-01-27 12:39:51 +13:00
0e95f94025 Fix snapshot title fallback (build 271261228) 2026-01-27 12:30:04 +13:00
8b1a09fbd4 Fix request titles in snapshots (build 271261219) 2026-01-27 12:20:12 +13:00
fe0c108363 Bump build number to 271261202 2026-01-27 12:04:42 +13:00
9e8d22ba85 Clarify request sync settings (build 271261159) 2026-01-27 12:00:32 +13:00
7863658a19 Fix backend cache stats import (build 271261149) 2026-01-27 11:51:01 +13:00
7c97934bb9 Improve cache stats performance (build 271261145) 2026-01-27 11:46:50 +13:00
3f51e24181 Add cache control artwork stats 2026-01-27 11:27:26 +13:00
ab27ebfadf Fix sync progress bar animation 2026-01-26 14:21:18 +13:00
b93b41713a Fix cache title hydration 2026-01-26 14:01:06 +13:00
ceb8c1c9eb Build 2501262041 2026-01-25 20:43:14 +13:00
86ca3bdeb2 Harden request cache titles and cache-only reads 2026-01-25 19:38:31 +13:00
22f90b7e07 Serve bundled branding assets by default 2026-01-25 18:20:30 +13:00
57a4883931 Seed branding logo from bundled assets 2026-01-25 18:01:54 +13:00
6ba41b854b Tidy request sync controls 2026-01-25 17:52:33 +13:00
40 changed files with 8905 additions and 472 deletions

View File

@@ -1 +1 @@
251260445
2602261523

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
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

@@ -2,11 +2,8 @@ FROM python:3.12-slim
WORKDIR /app
ARG BUILD_NUMBER=dev
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SITE_BUILD_NUMBER=${BUILD_NUMBER}
PYTHONUNBUFFERED=1
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,4 +1,5 @@
from typing import Dict, Any
from datetime import datetime, timezone
from typing import Dict, Any, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
@@ -8,6 +9,21 @@ 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:
@@ -22,7 +38,7 @@ def _extract_client_ip(request: Request) -> str:
return "unknown"
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
def _load_current_user_from_token(token: str, request: Optional[Request] = None) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
@@ -37,6 +53,8 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
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)
@@ -47,10 +65,40 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
"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)),
"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 query token here only."""
token = None
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.query_params.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(token, None)
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

@@ -0,0 +1,2 @@
BUILD_NUMBER = "2602261523"
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,3 +30,14 @@ 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()

View File

@@ -35,3 +35,12 @@ 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,
},
)

View File

@@ -9,6 +9,9 @@ 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")
@@ -44,6 +47,9 @@ 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})

View File

@@ -9,6 +9,9 @@ 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")
@@ -51,6 +54,9 @@ 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})

View File

@@ -2,6 +2,7 @@ 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="")
@@ -38,9 +39,7 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
)
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
@@ -50,9 +49,7 @@ class Settings(BaseSettings):
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_changelog: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
)
site_changelog: Optional[str] = Field(default=CHANGELOG)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .db import init_db, set_setting
from .db import init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
@@ -13,12 +13,13 @@ from .routers.requests import (
run_daily_db_cleanup,
)
from .routers.auth import router as auth_router
from .routers.admin import router as admin_router
from .routers.admin import router as admin_router, events_router as admin_events_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
@@ -41,8 +42,6 @@ async def health() -> dict:
@app.on_event("startup")
async def startup() -> None:
init_db()
if settings.site_build_number and settings.site_build_number.strip():
set_setting("site_build_number", settings.site_build_number.strip())
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())
@@ -55,8 +54,10 @@ 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

View File

@@ -5,11 +5,16 @@ from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
verify_user_password,
create_user,
create_user_if_missing,
set_last_login,
get_user_by_username,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
get_signup_invite_by_code,
increment_signup_invite_use,
get_user_profile,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
@@ -21,12 +26,21 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password
from ..auth import get_current_user
from ..services.user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
router = APIRouter(prefix="/auth", tags=["auth"])
def _normalize_username(value: str) -> str:
return value.strip().lower()
normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
@@ -53,14 +67,77 @@ def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
return False
return _is_recent_jellyfin_auth(last_auth_at)
def _extract_jellyseerr_user_id(response: dict) -> int | None:
if not isinstance(response, dict):
return None
candidate = response
if isinstance(response.get("user"), dict):
candidate = response.get("user")
for key in ("id", "userId", "Id"):
value = candidate.get(key) if isinstance(candidate, dict) else None
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _is_user_expired(user: dict | None) -> bool:
if not user:
return False
expires_at = user.get("expires_at")
if not expires_at:
return False
try:
parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _assert_user_can_login(user: dict | None) -> None:
if not user:
return
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_user_expired(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"label": invite.get("label"),
"description": invite.get("description"),
"enabled": bool(invite.get("enabled")),
"expires_at": invite.get("expires_at"),
"max_uses": invite.get("max_uses"),
"use_count": invite.get("use_count", 0),
"remaining_uses": invite.get("remaining_uses"),
"is_expired": bool(invite.get("is_expired")),
"is_usable": bool(invite.get("is_usable")),
"profile": (
{
"id": profile.get("id"),
"name": profile.get("name"),
"description": profile.get("description"),
}
if profile
else None
),
}
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
set_last_login(user["username"])
return {
@@ -76,11 +153,12 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
username = form_data.username
password = form_data.password
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user")
set_last_login(username)
@@ -93,20 +171,24 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
try:
users = await client.get_users()
if isinstance(users, list):
for user in users:
if not isinstance(user, dict):
save_jellyfin_users_cache(users)
for jellyfin_user in users:
if not isinstance(jellyfin_user, dict):
continue
name = user.get("Name")
name = jellyfin_user.get("Name")
if isinstance(name, str) and name:
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
except Exception:
pass
set_jellyfin_auth_cache(username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map:
matched_id = match_jellyseerr_user_id(username, candidate_map)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
token = create_access_token(username, "user")
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@@ -125,10 +207,18 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
create_user_if_missing(form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
create_user_if_missing(
form_data.username,
"jellyseerr-user",
role="user",
auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id,
)
user = get_user_by_username(form_data.username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
_assert_user_can_login(user)
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
token = create_access_token(form_data.username, "user")
set_last_login(form_data.username)
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
@@ -139,11 +229,112 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@router.get("/invites/{code}")
async def invite_details(code: str) -> dict:
invite = get_signup_invite_by_code(code.strip())
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if profile and not profile.get("is_active", True):
invite = {**invite, "is_usable": False}
return {"invite": _public_invite_payload(invite, profile)}
@router.post("/signup")
async def signup(payload: dict) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "")
if not invite_code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
if len(password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
invite = get_signup_invite_by_code(invite_code)
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if not invite.get("enabled"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled")
if invite.get("is_expired"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired")
remaining_uses = invite.get("remaining_uses")
if remaining_uses is not None and int(remaining_uses) <= 0:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if not profile:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled")
invite_role = invite.get("role")
profile_role = profile.get("role") if profile else None
role = invite_role if invite_role in {"user", "admin"} else profile_role
if role not in {"user", "admin"}:
role = "user"
auto_search_enabled = (
bool(profile.get("auto_search_enabled", True))
if profile is not None
else True
)
expires_at = None
account_expires_days = profile.get("account_expires_days") if profile else None
if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
try:
create_user(
username,
password.strip(),
role=role,
auth_provider="local",
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
return {
"access_token": token,
"token_type": "bearer",
"user": {
"username": username,
"role": role,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
}
@router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm)
stats = get_user_request_stats(username_norm, current_user.get("jellyseerr_user_id"))
global_total = get_global_request_total()
share = (stats.get("total", 0) / global_total) if global_total else 0
activity_summary = get_user_activity_summary(username) if username else {}

View File

@@ -11,6 +11,10 @@ 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:
@@ -41,6 +45,18 @@ 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)
@@ -65,24 +81,32 @@ 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:
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
if not os.path.exists(_LOGO_PATH):
logo_path, _ = _resolve_branding_paths()
if not os.path.exists(logo_path):
raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
headers = {"Cache-Control": "no-store"}
return FileResponse(logo_path, media_type="image/png", headers=headers)
@router.get("/favicon.ico")
async def branding_favicon() -> FileResponse:
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
if not os.path.exists(_FAVICON_PATH):
_, favicon_path = _resolve_branding_paths()
if not os.path.exists(favicon_path):
raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
headers = {"Cache-Control": "no-store"}
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:

View File

@@ -0,0 +1,112 @@
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, 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"
@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)

View File

@@ -1,6 +1,8 @@
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
@@ -11,6 +13,7 @@ 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:
@@ -19,13 +22,24 @@ def _safe_filename(path: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
return safe or "image"
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
if not path or "://" in path or ".." in path:
return False
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
runtime = get_runtime_settings()
@@ -33,9 +47,10 @@ async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if cache_mode != "cache":
return False
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))
file_path = tmdb_cache_path(path, size)
if not file_path:
return False
os.makedirs(os.path.dirname(file_path), exist_ok=True)
if os.path.exists(file_path):
return True
@@ -64,9 +79,10 @@ async def tmdb_image(path: str, size: str = "w342"):
if cache_mode != "cache":
return RedirectResponse(url=url)
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))
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)
headers = {"Cache-Control": "public, max-age=86400"}
if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
@@ -77,6 +93,8 @@ 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)
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
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)

View File

@@ -3,6 +3,7 @@ import asyncio
import httpx
import json
import logging
import os
import time
from urllib.parse import quote
from datetime import datetime, timezone, timedelta
@@ -17,7 +18,7 @@ from ..clients.prowlarr import ProwlarrClient
from ..ai.triage import triage_snapshot
from ..auth import get_current_user
from ..runtime import get_runtime_settings
from .images import cache_tmdb_image
from .images import cache_tmdb_image, is_tmdb_cached
from ..db import (
save_action,
get_recent_actions,
@@ -30,10 +31,16 @@ from ..db import (
get_request_cache_last_updated,
get_request_cache_count,
get_request_cache_payloads,
get_request_cache_payloads_missing,
repair_request_cache_titles,
prune_duplicate_requests_cache,
upsert_request_cache,
upsert_artwork_cache_status,
get_artwork_cache_missing_count,
get_artwork_cache_status_count,
get_setting,
set_setting,
update_artwork_cache_stats,
cleanup_history,
)
from ..models import Snapshot, TriageResult, RequestType
@@ -64,6 +71,7 @@ _artwork_prefetch_state: Dict[str, Any] = {
"processed": 0,
"total": 0,
"message": "",
"only_missing": False,
"started_at": None,
"finished_at": None,
}
@@ -105,9 +113,34 @@ def _normalize_username(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip().lower()
if not normalized:
return None
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized if normalized else None
def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
if user.get("role") == "admin":
return True
return bool(user.get("auto_search_enabled", True))
def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
if _user_can_use_search_auto(user):
return snapshot
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
return snapshot
def _quality_profile_id(value: Any) -> Optional[int]:
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return None
def _request_matches_user(request_data: Any, username: str) -> bool:
requested_by = None
if isinstance(request_data, dict):
@@ -156,6 +189,21 @@ def _normalize_requested_by(request_data: Any) -> Optional[str]:
normalized = normalized.split("@", 1)[0]
return normalized
def _extract_requested_by_id(request_data: Any) -> Optional[int]:
if not isinstance(request_data, dict):
return None
requested_by = request_data.get("requestedBy") or request_data.get("requestedByUser")
if isinstance(requested_by, dict):
for key in ("id", "userId", "Id"):
value = requested_by.get(key)
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str:
response = exc.response
@@ -198,6 +246,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
updated_at = item.get("updatedAt") or created_at
requested_by = _request_display_name(item)
requested_by_norm = _normalize_requested_by(item)
requested_by_id = _extract_requested_by_id(item)
return {
"request_id": item.get("id"),
"media_id": media_id,
@@ -208,6 +257,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
"year": year,
"requested_by": requested_by,
"requested_by_norm": requested_by_norm,
"requested_by_id": requested_by_id,
"created_at": created_at,
"updated_at": updated_at,
}
@@ -226,6 +276,108 @@ def _extract_artwork_paths(item: Dict[str, Any]) -> tuple[Optional[str], Optiona
backdrop_path = item.get("backdropPath") or item.get("backdrop_path")
return poster_path, backdrop_path
def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Optional[str]]:
media = payload.get("media") or {}
if not isinstance(media, dict):
media = {}
tmdb_id = media.get("tmdbId") or payload.get("tmdbId")
media_type = (
media.get("mediaType")
or payload.get("mediaType")
or payload.get("type")
)
try:
tmdb_id = int(tmdb_id) if tmdb_id is not None else None
except (TypeError, ValueError):
tmdb_id = None
if isinstance(media_type, str):
media_type = media_type.strip().lower() or None
else:
media_type = None
return tmdb_id, media_type
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
poster_path, backdrop_path = _extract_artwork_paths(payload)
tmdb_id, media_type = _extract_tmdb_lookup(payload)
can_hydrate = bool(tmdb_id and media_type)
if poster_path:
if not is_tmdb_cached(poster_path, "w185") or not is_tmdb_cached(poster_path, "w342"):
return True
elif can_hydrate:
return True
if backdrop_path:
if not is_tmdb_cached(backdrop_path, "w780"):
return True
elif can_hydrate:
return True
return False
def _compute_cached_flags(
poster_path: Optional[str],
backdrop_path: Optional[str],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> tuple[bool, bool]:
if cache_mode != "cache":
return True, True
poster = poster_cached
backdrop = backdrop_cached
if poster is None:
poster = bool(poster_path) and is_tmdb_cached(poster_path, "w185") and is_tmdb_cached(
poster_path, "w342"
)
if backdrop is None:
backdrop = bool(backdrop_path) and is_tmdb_cached(backdrop_path, "w780")
return bool(poster), bool(backdrop)
def _upsert_artwork_status(
payload: Dict[str, Any],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> None:
parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id")
if not isinstance(request_id, int):
return
tmdb_id, media_type = _extract_tmdb_lookup(payload)
poster_path, backdrop_path = _extract_artwork_paths(payload)
has_tmdb = bool(tmdb_id and media_type)
poster_cached_flag, backdrop_cached_flag = _compute_cached_flags(
poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached
)
upsert_artwork_cache_status(
request_id=request_id,
tmdb_id=tmdb_id,
media_type=media_type,
poster_path=poster_path,
backdrop_path=backdrop_path,
has_tmdb=has_tmdb,
poster_cached=poster_cached_flag,
backdrop_cached=backdrop_cached_flag,
)
def _collect_artwork_cache_disk_stats() -> tuple[int, int]:
cache_root = os.path.join(os.getcwd(), "data", "artwork")
total_bytes = 0
total_files = 0
if not os.path.isdir(cache_root):
return 0, 0
for root, _, files in os.walk(cache_root):
for name in files:
path = os.path.join(root, name)
try:
total_bytes += os.path.getsize(path)
total_files += 1
except OSError:
continue
return total_bytes, total_files
async def _get_request_details(client: JellyseerrClient, request_id: int) -> Optional[Dict[str, Any]]:
cache_key = f"request:{request_id}"
@@ -467,10 +619,13 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
)
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
stored += 1
_sync_state["stored"] = stored
if len(items) < take:
@@ -490,6 +645,11 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
)
set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
_refresh_recent_cache_from_db()
if cache_mode == "cache":
update_artwork_cache_stats(
missing_count=get_artwork_cache_missing_count(),
total_requests=get_request_cache_count(),
)
return stored
@@ -597,10 +757,13 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
)
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
stored += 1
page_changed = True
_sync_state["stored"] = stored
@@ -628,10 +791,20 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
)
set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
_refresh_recent_cache_from_db()
if cache_mode == "cache":
update_artwork_cache_stats(
missing_count=get_artwork_cache_missing_count(),
total_requests=get_request_cache_count(),
)
return stored
async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
async def _prefetch_artwork_cache(
client: JellyseerrClient,
only_missing: bool = False,
total: Optional[int] = None,
use_missing_query: bool = False,
) -> None:
runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
if cache_mode != "cache":
@@ -644,74 +817,101 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
)
return
total = get_request_cache_count()
total = total if total is not None else get_request_cache_count()
_artwork_prefetch_state.update(
{
"status": "running",
"processed": 0,
"total": total,
"message": "Starting artwork prefetch",
"message": "Starting missing artwork prefetch"
if only_missing
else "Starting artwork prefetch",
"only_missing": only_missing,
"started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None,
}
)
if only_missing and total == 0:
_artwork_prefetch_state.update(
{
"status": "completed",
"processed": 0,
"message": "No missing artwork to cache.",
"finished_at": datetime.now(timezone.utc).isoformat(),
}
)
return
offset = 0
limit = 200
processed = 0
while True:
batch = get_request_cache_payloads(limit=limit, offset=offset)
if use_missing_query:
batch = get_request_cache_payloads_missing(limit=limit, offset=offset)
else:
batch = get_request_cache_payloads(limit=limit, offset=offset)
if not batch:
break
for row in batch:
payload = row.get("payload")
if not isinstance(payload, dict):
processed += 1
if not only_missing:
processed += 1
continue
if only_missing and not use_missing_query and not _artwork_missing_for_payload(payload):
continue
poster_path, backdrop_path = _extract_artwork_paths(payload)
if not (poster_path or backdrop_path) and client.configured():
tmdb_id, media_type = _extract_tmdb_lookup(payload)
if (not poster_path or not backdrop_path) and client.configured() and tmdb_id and media_type:
media = payload.get("media") or {}
tmdb_id = media.get("tmdbId") or payload.get("tmdbId")
media_type = media.get("mediaType") or payload.get("type")
if tmdb_id and media_type:
hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb(
client, media_type, tmdb_id
)
poster_path = poster_path or hydrated_poster
backdrop_path = backdrop_path or hydrated_backdrop
if hydrated_poster or hydrated_backdrop:
media = dict(media) if isinstance(media, dict) else {}
if hydrated_poster:
media["posterPath"] = hydrated_poster
if hydrated_backdrop:
media["backdropPath"] = hydrated_backdrop
payload["media"] = media
parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id")
if isinstance(request_id, int):
upsert_request_cache(
request_id=request_id,
media_id=parsed.get("media_id"),
media_type=parsed.get("media_type"),
status=parsed.get("status"),
title=parsed.get("title"),
year=parsed.get("year"),
requested_by=parsed.get("requested_by"),
requested_by_norm=parsed.get("requested_by_norm"),
created_at=parsed.get("created_at"),
updated_at=parsed.get("updated_at"),
payload_json=json.dumps(payload, ensure_ascii=True),
)
hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb(
client, media_type, tmdb_id
)
poster_path = poster_path or hydrated_poster
backdrop_path = backdrop_path or hydrated_backdrop
if hydrated_poster or hydrated_backdrop:
media = dict(media) if isinstance(media, dict) else {}
if hydrated_poster:
media["posterPath"] = hydrated_poster
if hydrated_backdrop:
media["backdropPath"] = hydrated_backdrop
payload["media"] = media
parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id")
if isinstance(request_id, int):
upsert_request_cache(
request_id=request_id,
media_id=parsed.get("media_id"),
media_type=parsed.get("media_type"),
status=parsed.get("status"),
title=parsed.get("title"),
year=parsed.get("year"),
requested_by=parsed.get("requested_by"),
requested_by_norm=parsed.get("requested_by_norm"),
requested_by_id=parsed.get("requested_by_id"),
created_at=parsed.get("created_at"),
updated_at=parsed.get("updated_at"),
payload_json=json.dumps(payload, ensure_ascii=True),
)
poster_cached_flag = False
backdrop_cached_flag = False
if poster_path:
try:
await cache_tmdb_image(poster_path, "w185")
await cache_tmdb_image(poster_path, "w342")
poster_cached_flag = bool(
await cache_tmdb_image(poster_path, "w185")
) and bool(await cache_tmdb_image(poster_path, "w342"))
except httpx.HTTPError:
pass
poster_cached_flag = False
if backdrop_path:
try:
await cache_tmdb_image(backdrop_path, "w780")
backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780"))
except httpx.HTTPError:
pass
backdrop_cached_flag = False
_upsert_artwork_status(
payload,
cache_mode,
poster_cached=poster_cached_flag if poster_path else None,
backdrop_cached=backdrop_cached_flag if backdrop_path else None,
)
processed += 1
if processed % 25 == 0:
_artwork_prefetch_state.update(
@@ -719,6 +919,15 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
)
offset += limit
total_requests = get_request_cache_count()
missing_count = get_artwork_cache_missing_count()
cache_bytes, cache_files = _collect_artwork_cache_disk_stats()
update_artwork_cache_stats(
cache_bytes=cache_bytes,
cache_files=cache_files,
missing_count=missing_count,
total_requests=total_requests,
)
_artwork_prefetch_state.update(
{
"status": "completed",
@@ -729,25 +938,52 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
)
async def start_artwork_prefetch(base_url: Optional[str], api_key: Optional[str]) -> Dict[str, Any]:
async def start_artwork_prefetch(
base_url: Optional[str], api_key: Optional[str], only_missing: bool = False
) -> Dict[str, Any]:
global _artwork_prefetch_task
if _artwork_prefetch_task and not _artwork_prefetch_task.done():
return dict(_artwork_prefetch_state)
client = JellyseerrClient(base_url, api_key)
status_count = get_artwork_cache_status_count()
total_requests = get_request_cache_count()
use_missing_query = only_missing and status_count >= total_requests and total_requests > 0
if only_missing and use_missing_query:
total = get_artwork_cache_missing_count()
else:
total = total_requests
_artwork_prefetch_state.update(
{
"status": "running",
"processed": 0,
"total": get_request_cache_count(),
"message": "Starting artwork prefetch",
"total": total,
"message": "Seeding artwork cache status"
if only_missing and not use_missing_query
else ("Starting missing artwork prefetch" if only_missing else "Starting artwork prefetch"),
"only_missing": only_missing,
"started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None,
}
)
if only_missing and total == 0:
_artwork_prefetch_state.update(
{
"status": "completed",
"processed": 0,
"message": "No missing artwork to cache.",
"finished_at": datetime.now(timezone.utc).isoformat(),
}
)
return dict(_artwork_prefetch_state)
async def _runner() -> None:
try:
await _prefetch_artwork_cache(client)
await _prefetch_artwork_cache(
client,
only_missing=only_missing,
total=total,
use_missing_query=use_missing_query,
)
except Exception:
logger.exception("Artwork prefetch failed")
_artwork_prefetch_state.update(
@@ -794,19 +1030,39 @@ def _recent_cache_stale() -> bool:
return (datetime.now(timezone.utc) - parsed).total_seconds() > RECENT_CACHE_TTL_SECONDS
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _get_recent_from_cache(
requested_by_norm: Optional[str],
requested_by_id: Optional[int],
limit: int,
offset: int,
since_iso: Optional[str],
) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or []
results = []
since_dt = _parse_iso_datetime(since_iso)
for item in items:
if requested_by_norm and item.get("requested_by_norm") != requested_by_norm:
continue
if since_iso and item.get("created_at") and item["created_at"] < since_iso:
if requested_by_id is not None:
if item.get("requested_by_id") != requested_by_id:
continue
elif requested_by_norm and item.get("requested_by_norm") != requested_by_norm:
continue
if since_dt:
candidate = item.get("created_at") or item.get("updated_at")
item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt:
continue
results.append(item)
return results[offset : offset + limit]
@@ -814,13 +1070,14 @@ def _get_recent_from_cache(
async def startup_warmup_requests_cache() -> None:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return
try:
await _ensure_requests_cache(client)
except httpx.HTTPError as exc:
logger.warning("Requests warmup skipped: %s", exc)
return
if client.configured():
try:
await _ensure_requests_cache(client)
except httpx.HTTPError as exc:
logger.warning("Requests warmup skipped: %s", exc)
repaired = repair_request_cache_titles()
if repaired:
logger.info("Requests cache titles repaired: %s", repaired)
_refresh_recent_cache_from_db()
@@ -968,7 +1225,10 @@ async def _ensure_request_access(
runtime = get_runtime_settings()
mode = (runtime.requests_data_source or "prefer_cache").lower()
cached = get_request_cache_payload(request_id)
if mode != "always_js" and cached is not None:
if mode != "always_js":
if cached is None:
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
raise HTTPException(status_code=404, detail="Request not found in cache")
logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode)
if _request_matches_user(cached, user.get("username", "")):
return
@@ -1237,7 +1497,8 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
return await build_snapshot(request_id)
snapshot = await build_snapshot(request_id)
return _filter_snapshot_actions_for_user(snapshot, user)
@router.get("/recent")
@@ -1249,27 +1510,29 @@ async def recent_requests(
) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
try:
await _ensure_requests_cache(client)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
mode = (runtime.requests_data_source or "prefer_cache").lower()
allow_remote = mode == "always_js"
if allow_remote:
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
try:
await _ensure_requests_cache(client)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
requested_by_id = None if user.get("role") == "admin" else requested_by_id
since_iso = None
if days > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
if _recent_cache_stale():
_refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, take, skip, since_iso)
rows = _get_recent_from_cache(requested_by, requested_by_id, take, skip, since_iso)
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
mode = (runtime.requests_data_source or "prefer_cache").lower()
allow_remote = mode == "always_js"
allow_title_hydrate = mode == "prefer_cache"
allow_artwork_hydrate = allow_remote or allow_title_hydrate
allow_title_hydrate = False
allow_artwork_hydrate = client.configured()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
@@ -1342,6 +1605,7 @@ async def recent_requests(
year=year or payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True),
@@ -1389,6 +1653,7 @@ async def recent_requests(
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True),
@@ -1407,6 +1672,7 @@ async def recent_requests(
"status": status,
"statusLabel": status_label,
"mediaId": row.get("media_id"),
"createdAt": row.get("created_at") or row.get("updated_at"),
"artwork": {
"poster_url": _artwork_url(poster_path, "w185", cache_mode),
"backdrop_url": _artwork_url(backdrop_path, "w780", cache_mode),
@@ -1461,8 +1727,14 @@ async def search_requests(
status_label = _status_label(status)
elif isinstance(media_info_id, int):
username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
cached = get_cached_request_by_media_id(media_info_id, requested_by_norm=requested_by)
requested_by_id = None if user.get("role") == "admin" else requested_by_id
cached = get_cached_request_by_media_id(
media_info_id,
requested_by_norm=requested_by,
requested_by_id=requested_by_id,
)
if cached:
request_id = cached.get("request_id")
status = cached.get("status")
@@ -1497,7 +1769,7 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id)
snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
return triage_snapshot(snapshot)
@@ -1534,6 +1806,8 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
@router.post("/{request_id}/actions/search_auto")
async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
@@ -1547,10 +1821,23 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Sonarr not configured")
target_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
profile_message = None
series_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and series_id and current_profile_id != target_profile_id:
series = await client.get_series(series_id)
if not isinstance(series, dict):
raise HTTPException(status_code=502, detail="Could not load Sonarr series before search")
series["qualityProfileId"] = target_profile_id
await client.update_series(series)
profile_message = f"Sonarr quality profile updated to {target_profile_id} before search."
episodes = await client.get_episodes(int(arr_item["id"]))
missing_by_season = _missing_episode_ids_by_season(episodes)
if not missing_by_season:
message = "No missing monitored episodes found."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
)
@@ -1564,6 +1851,8 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
{"season": season_number, "episodeCount": len(episode_ids), "response": response}
)
message = "Search sent to Sonarr."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
)
@@ -1572,8 +1861,21 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
profile_message = None
movie_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and movie_id and current_profile_id != target_profile_id:
movie = await client.get_movie(movie_id)
if not isinstance(movie, dict):
raise HTTPException(status_code=502, detail="Could not load Radarr movie before search")
movie["qualityProfileId"] = target_profile_id
await client.update_movie(movie)
profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
response = await client.search(int(arr_item["id"]))
message = "Search sent to Radarr."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
)
@@ -1814,4 +2116,3 @@ async def action_grab(
save_action, request_id, "grab", "Grab release", "ok", action_message
)
return {"status": "ok", "response": {"qbittorrent": "queued"}}

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..runtime import get_runtime_settings
@@ -93,3 +93,42 @@ 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

@@ -14,6 +14,7 @@ _BOOL_FIELDS = {
"jellyfin_sync_to_arr",
"site_banner_enabled",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
def get_runtime_settings():
@@ -22,6 +23,8 @@ 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

@@ -3,8 +3,14 @@ import logging
from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient
from ..db import create_user_if_missing
from ..db import create_user_if_missing, set_user_jellyseerr_id
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__)
@@ -17,6 +23,9 @@ async def sync_jellyfin_users() -> int:
users = await client.get_users()
if not isinstance(users, list):
return 0
save_jellyfin_users_cache(users)
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):
@@ -24,8 +33,18 @@ async def sync_jellyfin_users() -> int:
name = user.get("Name")
if not name:
continue
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
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:
imported += 1
elif matched_id is not None:
set_user_jellyseerr_id(name, matched_id)
return imported

View File

@@ -11,7 +11,14 @@ 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_recent_snapshots, get_setting, set_setting
from ..db import (
save_snapshot,
get_request_cache_payload,
get_request_cache_by_id,
get_recent_snapshots,
get_setting,
set_setting,
)
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
logger = logging.getLogger(__name__)
@@ -219,7 +226,21 @@ 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"))
@@ -227,9 +248,15 @@ 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 (jelly_request is None or mode == "always_js") and jellyseerr.configured():
if allow_remote and (jelly_request is None or mode == "always_js"):
try:
jelly_request = await jellyseerr.get_request(request_id)
logging.getLogger(__name__).debug(
@@ -252,17 +279,25 @@ 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"
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 {}
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.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
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 jellyseerr.configured():
if snapshot.title in {None, "", "Unknown"} and allow_remote:
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
try:

View File

@@ -0,0 +1,158 @@
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,19 +1,10 @@
services:
backend:
image: rephl3xnz/magent-backend:latest
magent:
image: rephl3xnz/magent: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,25 +1,12 @@
services:
backend:
magent:
build:
context: .
dockerfile: backend/Dockerfile
args:
BUILD_NUMBER: ${BUILD_NUMBER}
dockerfile: 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

28
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,28 @@
[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

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
@@ -21,31 +21,40 @@ type ServiceOptions = {
const SECTION_LABELS: Record<string, string> = {
jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin',
artwork: 'Artwork',
cache: 'Cache',
artwork: 'Artwork cache',
cache: 'Cache Control',
sonarr: 'Sonarr',
radarr: 'Radarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
log: 'Activity log',
requests: 'Request syncing',
requests: 'Request sync',
site: 'Site',
}
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const URL_SETTINGS = new Set([
'jellyseerr_base_url',
'jellyfin_base_url',
'jellyfin_public_url',
'sonarr_base_url',
'radarr_base_url',
'prowlarr_base_url',
'qbittorrent_base_url',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Configure how posters and artwork are loaded.',
cache: 'Manage saved request data and offline artwork.',
artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'TV automation settings.',
radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.',
requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
}
@@ -53,7 +62,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
artwork: 'artwork',
artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
prowlarr: 'prowlarr',
@@ -73,13 +82,13 @@ const labelFromKey = (key: string) =>
.replace('quality profile id', 'Quality profile ID')
.replace('root folder', 'Root folder')
.replace('qbittorrent', 'qBittorrent')
.replace('requests sync ttl minutes', 'Refresh saved requests if older than (minutes)')
.replace('requests poll interval seconds', 'Background refresh check (seconds)')
.replace('requests delta sync interval minutes', 'Check for new or updated requests every (minutes)')
.replace('requests full sync time', 'Full refresh time (24h)')
.replace('requests cleanup time', 'Clean up old history time (24h)')
.replace('requests cleanup days', 'Remove history older than (days)')
.replace('requests data source', 'Where requests are loaded from')
.replace('requests sync ttl minutes', 'Saved request refresh TTL (minutes)')
.replace('requests poll interval seconds', 'Full refresh check interval (seconds)')
.replace('requests delta sync interval minutes', 'Delta sync interval (minutes)')
.replace('requests full sync time', 'Daily full refresh time (24h)')
.replace('requests cleanup time', 'Daily history cleanup time (24h)')
.replace('requests cleanup days', 'History retention window (days)')
.replace('requests data source', 'Request source (cache vs Jellyseerr)')
.replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode')
@@ -89,6 +98,19 @@ const labelFromKey = (key: string) =>
.replace('site banner tone', 'Sitewide banner tone')
.replace('site changelog', 'Changelog text')
const formatBytes = (value?: number | null) => {
if (!value || value <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
const decimals = unitIndex === 0 || size >= 10 ? 0 : 1
return `${size.toFixed(decimals)} ${units[unitIndex]}`
}
type SettingsPageProps = {
section: string
}
@@ -112,10 +134,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [cacheRows, setCacheRows] = useState<any[]>([])
const [cacheCount, setCacheCount] = useState(50)
const [cacheStatus, setCacheStatus] = useState<string | null>(null)
const [cacheLoading, setCacheLoading] = useState(false)
const [requestsSync, setRequestsSync] = useState<any | null>(null)
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [artworkSummary, setArtworkSummary] = useState<any | null>(null)
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const loadSettings = useCallback(async () => {
const baseUrl = getApiBase()
@@ -165,6 +193,27 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
}, [])
const loadArtworkSummary = useCallback(async () => {
setArtworkSummaryStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/summary`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Artwork summary fetch failed')
}
const data = await response.json()
setArtworkSummary(data?.summary ?? null)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load artwork stats.'
setArtworkSummaryStatus(message)
}
}, [])
const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
try {
const baseUrl = getApiBase()
@@ -204,8 +253,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
try {
await loadSettings()
if (section === 'artwork') {
if (section === 'cache' || section === 'artwork') {
await loadArtworkPrefetchStatus()
await loadArtworkSummary()
}
} catch (err) {
console.error(err)
@@ -222,7 +272,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') {
void loadOptions('radarr')
}
}, [loadArtworkPrefetchStatus, loadOptions, loadSettings, router, section])
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {}
@@ -237,28 +287,51 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set([
'requests_sync_ttl_minutes',
'requests_data_source',
'artwork_cache_mode',
])
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys])
const requestSettingOrder = [
'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_full_sync_time',
'requests_cleanup_time',
'requests_cleanup_days',
]
const sortByOrder = (items: AdminSetting[], order: string[]) => {
const position = new Map(order.map((key, index) => [key, index]))
return [...items].sort((a, b) => {
const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
if (aIndex !== bIndex) return aIndex - bIndex
return a.key.localeCompare(b.key)
})
}
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const settingsSections = isCacheSection
? [{ key: 'cache', title: 'Cache settings', items: cacheSettings }]
? [
{ key: 'cache', title: 'Cache control', items: cacheSettings },
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
]
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
items:
sectionKey === 'requests' || sectionKey === 'artwork'
? (groupedSettings[sectionKey] ?? []).filter(
(setting) => !cacheSettingKeys.has(setting.key)
)
: groupedSettings[sectionKey] ?? [],
items: (() => {
const sectionItems = groupedSettings[sectionKey] ?? []
const filtered =
sectionKey === 'requests' || sectionKey === 'artwork'
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
}
return filtered
})(),
}))
const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork'
const showArtworkExtras = section === 'cache'
const showCacheExtras = section === 'cache'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true
@@ -268,36 +341,52 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return false
}
useEffect(() => {
requestsSyncRef.current = requestsSync
}, [requestsSync])
useEffect(() => {
artworkPrefetchRef.current = artworkPrefetch
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: 'Base URL for your Jellyseerr server.',
jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.',
jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.',
jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking.',
sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies.',
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.',
prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.',
qbittorrent_base_url:
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
requests_poll_interval_seconds: 'How often the background checker runs.',
requests_delta_sync_interval_minutes: 'How often we check for new or updated requests.',
requests_full_sync_time: 'Daily time to refresh the full request list.',
requests_cleanup_time: 'Daily time to trim old history.',
requests_poll_interval_seconds:
'How often Magent checks if a full refresh should run.',
requests_delta_sync_interval_minutes:
'How often we poll for new or updated requests.',
requests_full_sync_time: 'Daily time to rebuild the full request cache.',
requests_cleanup_time: 'Daily time to trim old request history.',
requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source: 'Pick where Magent should read requests from.',
requests_data_source:
'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.',
log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.',
site_build_number: 'Build number shown in the account menu (auto-set from releases).',
@@ -307,6 +396,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_changelog: 'One update per line for the public changelog.',
}
const settingPlaceholders: Record<string, string> = {
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
}
const buildSelectOptions = (
currentValue: string,
options: { id: number; label: string; path?: string }[],
@@ -462,8 +561,126 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
}
const prefetchArtworkMissing = async () => {
setArtworkPrefetchStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/requests/artwork/prefetch?only_missing=1`,
{ method: 'POST' }
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Missing artwork prefetch failed')
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
setArtworkPrefetchStatus('Missing artwork caching started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not cache missing artwork.'
setArtworkPrefetchStatus(message)
}
}
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
if (!shouldSubscribe) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
const params = new URLSearchParams()
params.set('access_token', token)
if (showLogs) {
params.set('include_logs', '1')
params.set('log_lines', String(logsCount))
}
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
let closed = false
const 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 || payload.type !== 'admin_live_state') {
return
}
const rawSync =
payload.requestsSync && typeof payload.requestsSync === 'object'
? payload.requestsSync
: null
const nextSync = rawSync?.status === 'idle' ? null : rawSync
const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync
setRequestsSync(nextSync)
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
}
const rawArtwork =
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
? payload.artworkPrefetch
: null
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
const prevArtwork = artworkPrefetchRef.current
artworkPrefetchRef.current = nextArtwork
setArtworkPrefetch(nextArtwork)
if (
prevArtwork?.status === 'running' &&
nextArtwork?.status &&
nextArtwork.status !== 'running'
) {
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
if (showArtworkExtras) {
void loadArtworkSummary()
}
}
if (payload.logs && typeof payload.logs === 'object') {
if (Array.isArray(payload.logs.lines)) {
setLogsLines(payload.logs.lines)
setLogsStatus(null)
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
setLogsStatus(payload.logs.error)
}
}
} catch (err) {
console.error(err)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
}
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
useEffect(() => {
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
return
}
let active = true
@@ -479,6 +696,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(data?.prefetch ?? null)
if (data?.prefetch?.status && data.prefetch.status !== 'running') {
setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.')
void loadArtworkSummary()
}
} catch (err) {
console.error(err)
@@ -488,7 +706,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [artworkPrefetch])
}, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -501,7 +719,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch])
useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') {
if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
return
}
let active = true
@@ -526,7 +744,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [requestsSync])
}, [liveStreamConnected, requestsSync])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
@@ -569,15 +787,19 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (!showLogs) {
return
}
if (liveStreamConnected) {
return
}
void loadLogs()
const timer = setInterval(() => {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [loadLogs, showLogs])
}, [liveStreamConnected, loadLogs, showLogs])
const loadCache = async () => {
setCacheStatus(null)
setCacheLoading(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(
@@ -600,6 +822,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load cache.'
setCacheStatus(message)
} finally {
setCacheLoading(false)
}
}
@@ -646,7 +870,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true)
if (typeof window !== 'undefined') {
const ok = window.confirm(
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
)
if (!ok) {
setMaintenanceBusy(false)
@@ -655,7 +879,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
try {
const baseUrl = getApiBase()
setMaintenanceStatus('Flushing database...')
setMaintenanceStatus('Running nuclear flush...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST',
})
@@ -663,12 +887,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await flushResponse.text()
throw new Error(text || 'Flush failed')
}
setMaintenanceStatus('Database flushed. Starting re-sync...')
const flushData = await flushResponse.json()
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!usersResyncResponse.ok) {
const text = await usersResyncResponse.text()
throw new Error(text || 'User resync failed')
}
const usersResyncData = await usersResyncResponse.json()
setMaintenanceStatus(
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
)
await syncRequests()
setMaintenanceStatus('Database flushed. Re-sync running now.')
setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
} catch (err) {
console.error(err)
setMaintenanceStatus('Flush + resync failed.')
setMaintenanceStatus('Nuclear flush + resync failed.')
} finally {
setMaintenanceBusy(false)
}
@@ -714,7 +951,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
.map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section">
<div className="section-header">
<h2>{sectionGroup.title}</h2>
<h2>
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
</h2>
{sectionGroup.key === 'sonarr' && (
<button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options
@@ -730,24 +969,38 @@ export default function SettingsPage({ section }: SettingsPageProps) {
Import Jellyfin users
</button>
)}
{(showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache') ? (
<button type="button" onClick={prefetchArtwork}>
Cache all artwork now
</button>
{showArtworkExtras && sectionGroup.key === 'artwork' ? (
<div className="sync-actions">
<button type="button" onClick={prefetchArtwork}>
Cache all artwork now
</button>
<button
type="button"
className="ghost-button"
onClick={prefetchArtworkMissing}
>
Sync only missing artwork
</button>
</div>
) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="sync-actions">
<button type="button" onClick={syncRequests}>
Full refresh
</button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Quick refresh (new changes)
</button>
<div className="sync-actions-block">
<div className="sync-actions">
<button type="button" onClick={syncRequests}>
Run full refresh (rebuild cache)
</button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Run delta sync (recent changes)
</button>
</div>
<div className="meta sync-note">
Full refresh rebuilds the entire cache. Delta sync only checks new or updated
requests.
</div>
</div>
)}
</div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && (
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
)}
{sectionGroup.key === 'sonarr' && sonarrError && (
@@ -759,17 +1012,48 @@ export default function SettingsPage({ section }: SettingsPageProps) {
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
<div className="status-banner">{jellyfinSyncStatus}</div>
)}
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache')) &&
artworkPrefetchStatus && (
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
<div className="status-banner">{artworkPrefetchStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkSummaryStatus && (
<div className="status-banner">{artworkSummaryStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && (
<div className="summary">
<div className="summary-card">
<strong>Missing artwork</strong>
<p>{artworkSummary?.missing_artwork ?? '--'}</p>
<div className="meta">Requests missing poster/backdrop or cache files.</div>
</div>
<div className="summary-card">
<strong>Artwork cache size</strong>
<p>{formatBytes(artworkSummary?.cache_bytes)}</p>
<div className="meta">
{artworkSummary?.cache_files ?? '--'} cached files
</div>
</div>
<div className="summary-card">
<strong>Total requests</strong>
<p>{artworkSummary?.total_requests ?? '--'}</p>
<div className="meta">Requests currently tracked in cache.</div>
</div>
<div className="summary-card">
<strong>Cache mode</strong>
<p>{artworkSummary?.cache_mode ?? '--'}</p>
<div className="meta">Artwork setting applied to posters/backdrops.</div>
</div>
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
<div className="status-banner">{requestsSyncStatus}</div>
)}
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache')) &&
artworkPrefetch && (
{showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="status-banner">
Full refresh checks only decide when to run a full refresh. The delta sync interval
polls for new or updated requests.
</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetch && (
<div className="sync-progress">
<div className="sync-meta">
<span>Status: {artworkPrefetch.status}</span>
@@ -842,6 +1126,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
const isRadarrRoot = setting.key === 'radarr_root_folder'
const isBoolSetting = BOOL_SETTINGS.has(setting.key)
const isUrlSetting = URL_SETTINGS.has(setting.key)
const inputPlaceholder = setting.sensitive && setting.isSet
? 'Configured (enter to replace)'
: settingPlaceholders[setting.key] ?? ''
if (isBoolSetting) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
@@ -1124,7 +1412,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
>
<option value="always_js">Always use Jellyseerr (slower)</option>
<option value="prefer_cache">Use saved requests first (faster)</option>
<option value="prefer_cache">
Use saved requests only (fastest)
</option>
</select>
</label>
)
@@ -1170,9 +1460,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<input
name={setting.key}
type={setting.sensitive ? 'password' : 'text'}
placeholder={
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : ''
}
placeholder={inputPlaceholder}
autoComplete={isUrlSetting ? 'url' : undefined}
value={value}
onChange={(event) =>
setFormValues((current) => ({
@@ -1194,7 +1483,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</form>
) : (
<div className="status-banner">
No settings to show here yet. Try the Cache page for artwork and saved-request controls.
No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.
</div>
)}
{showLogs && (
@@ -1240,8 +1529,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache}>
Load saved requests
<button type="button" onClick={loadCache} disabled={cacheLoading}>
{cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button>
</div>
</div>
@@ -1276,7 +1572,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<h2>Maintenance</h2>
</div>
<div className="status-banner">
Emergency tools. Use with care: flush will clear saved requests and history.
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
</div>
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<div className="maintenance-grid">
@@ -1295,7 +1591,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
onClick={runFlushAndResync}
disabled={maintenanceBusy}
>
Flush database + resync
Nuclear flush + resync
</button>
</div>
</section>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,172 @@
'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>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,9 @@ export default function LoginPage() {
>
Sign in with Magent account
</button>
<a className="ghost-button" href="/signup">
Have an invite? Create a Magent account
</a>
</form>
</main>
)

View File

@@ -4,6 +4,24 @@ import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken } 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,
}
})
export default function HomePage() {
const router = useRouter()
const [query, setQuery] = useState('')
@@ -14,6 +32,7 @@ export default function HomePage() {
year?: number
statusLabel?: string
artwork?: { poster_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
@@ -30,6 +49,9 @@ 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()
@@ -42,6 +64,61 @@ 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')
@@ -79,24 +156,7 @@ export default function HomePage() {
}
const data = await response.json()
if (Array.isArray(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,
}
})
)
setRecent(normalizeRecentResults(data.results))
}
} catch (error) {
console.error(error)
@@ -137,10 +197,79 @@ export default function HomePage() {
}
}
load()
void load()
if (liveStreamConnected) {
return
}
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, router])
}, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
let closed = false
const 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)
}
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
}
}, [authReady, recentDays])
const runSearch = async (term: string) => {
try {
@@ -179,6 +308,13 @@ 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">
@@ -214,21 +350,37 @@ 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" />
<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 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>
</div>
)
})
@@ -239,11 +391,12 @@ export default function HomePage() {
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<label className="recent-filter">
<span>Show last</span>
<span>Show</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>
@@ -290,6 +443,7 @@ 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

@@ -0,0 +1,223 @@
'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 a 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'}
</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

@@ -17,9 +17,9 @@ const NAV_GROUPS = [
{
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request syncing' },
{ href: '/admin/artwork', label: 'Artwork' },
{ href: '/admin/cache', label: 'Cache' },
{ href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' },
],
},
{
@@ -27,6 +27,7 @@ const NAV_GROUPS = [
items: [
{ 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

@@ -0,0 +1,554 @@
'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
jellyseerr_user_id?: number | null
profile_id?: number | null
expires_at?: string | null
is_expired?: boolean
}
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 [actionStatus, setActionStatus] = useState<string | null>(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))
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 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)
}
}
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">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>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download 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,15 +2,34 @@
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) => {
@@ -20,16 +39,59 @@ 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`)
const response = await authFetch(`${baseUrl}/admin/users/summary`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
@@ -51,6 +113,15 @@ 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 {
@@ -65,44 +136,86 @@ export default function UsersPage() {
}
}
const toggleUserBlock = async (username: string, blocked: boolean) => {
const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Update failed')
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}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update user access.')
setJellyseerrSyncStatus('Could not sync Jellyseerr users.')
} finally {
setJellyseerrSyncBusy(false)
}
}
const updateUserRole = async (username: string, role: string) => {
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)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Update failed')
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}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update user role.')
setJellyseerrSyncStatus('Could not resync Jellyseerr users.')
} finally {
setJellyseerrResyncBusy(false)
}
}
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()) {
@@ -116,52 +229,198 @@ 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`
return (
<AdminShell
title="Users"
subtitle="Manage who can use Magent."
subtitle="Directory, access status, and request activity."
actions={
<button type="button" onClick={loadUsers}>
Reload list
</button>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/admin/invites')}>
Invite management
</button>
<button type="button" onClick={loadUsers}>
Reload list
</button>
<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>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{users.length === 0 ? (
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-summary-grid user-summary-grid">
<div className="admin-summary-tile">
<span className="label">Total users</span>
<strong>{users.length}</strong>
<small>{adminCount} admin</small>
</div>
<div className="admin-summary-tile">
<span className="label">Auto search</span>
<strong>{autoSearchEnabledCount}</strong>
<small>of {nonAdminUsers.length} non-admin users</small>
</div>
<div className="admin-summary-tile">
<span className="label">Blocked</span>
<strong>{blockedCount}</strong>
<small>{blockedCount ? 'Needs review' : 'No blocked users'}</small>
</div>
<div className="admin-summary-tile">
<span className="label">Expired</span>
<strong>{expiredCount}</strong>
<small>{expiredCount ? 'Access expired' : 'No expiries'}</small>
</div>
</div>
<div className="user-directory-control-grid">
<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>
<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>
{filteredUsers.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
<div className="admin-grid">
{users.map((user) => (
<div key={user.username} className="summary-card user-card">
<div>
<strong>{user.username}</strong>
<div className="user-meta">
<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 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>
<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 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>
</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>
)}

View File

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