41 Commits

Author SHA1 Message Date
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
580b335268 Add Jellyfin login cache and admin-only stats 2026-01-25 17:47:03 +13:00
23549f1e45 Add user stats and activity tracking 2026-01-25 17:04:24 +13:00
2c45dd0065 Move account actions into avatar menu 2026-01-25 16:48:38 +13:00
92959d80ab Improve mobile header layout 2026-01-25 16:36:29 +13:00
615c4c1c29 Automate build number tagging and sync 2026-01-25 14:52:38 +13:00
38eee2407b Add site banner, build number, and changelog 2026-01-25 14:28:16 +13:00
cf4277d10c Improve request handling and qBittorrent categories 2026-01-24 21:48:55 +13:00
030480410b Map Prowlarr releases to Arr indexers for manual grab 2026-01-24 19:21:40 +13:00
3d414b4aeb Clarify how-it-works steps and fixes 2026-01-24 19:15:43 +13:00
18bbcbf660 Document fix buttons in how-it-works 2026-01-24 19:09:05 +13:00
5fa3aa6665 Route grabs through Sonarr/Radarr only 2026-01-24 19:06:40 +13:00
53 changed files with 4312 additions and 2673 deletions

1
.build_number Normal file
View File

@@ -0,0 +1 @@
0202261541

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,15 +1,28 @@
from typing import Dict, Any
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from .db import get_user_by_username
from .db import get_user_by_username, upsert_user_activity
from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if request.client and request.client.host:
return request.client.host
return "unknown"
def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
@@ -25,10 +38,16 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if request is not None:
ip = _extract_client_ip(request)
user_agent = request.headers.get("user-agent", "unknown")
upsert_user_activity(user["username"], ip, user_agent)
return {
"username": user["username"],
"role": user["role"],
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
}

View File

@@ -0,0 +1,2 @@
BUILD_NUMBER = "0202261541"
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

@@ -58,3 +58,13 @@ class JellyfinClient(ApiClient):
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Library/Refresh"
headers = {"X-Emby-Token": self.api_key}
params = {"Recursive": "true" if recursive else "false"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()

View File

@@ -18,16 +18,6 @@ class JellyseerrClient(ApiClient):
},
)
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,
"sort": "createdAt",
},
)
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/media/{media_id}")
@@ -45,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

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

View File

@@ -21,6 +21,9 @@ class RadarrClient(ApiClient):
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
@@ -43,3 +46,12 @@ class RadarrClient(ApiClient):
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release/push", payload=payload)
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post(
"/api/v3/command",
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
)

View File

@@ -18,6 +18,9 @@ class SonarrClient(ApiClient):
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"seriesId": series_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
@@ -50,3 +53,12 @@ class SonarrClient(ApiClient):
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/release/push", payload=payload)
async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
return await self.post(
"/api/v3/command",
payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id},
)

View File

@@ -2,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,6 +39,17 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
site_banner_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
)
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_changelog: Optional[str] = Field(default=CHANGELOG)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
@@ -70,6 +82,10 @@ class Settings(BaseSettings):
sonarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
)
sonarr_qbittorrent_category: Optional[str] = Field(
default="sonarr",
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
)
radarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
@@ -83,6 +99,10 @@ class Settings(BaseSettings):
radarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
)
radarr_qbittorrent_category: Optional[str] = Field(
default="radarr",
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
@@ -105,159 +125,6 @@ class Settings(BaseSettings):
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
invites_enabled: bool = Field(default=True, validation_alias=AliasChoices("INVITES_ENABLED"))
invites_require_captcha: bool = Field(
default=False, validation_alias=AliasChoices("INVITES_REQUIRE_CAPTCHA")
)
invite_default_profile_id: Optional[int] = Field(
default=None, validation_alias=AliasChoices("INVITE_DEFAULT_PROFILE_ID")
)
signup_allow_referrals: bool = Field(
default=True, validation_alias=AliasChoices("SIGNUP_ALLOW_REFERRALS")
)
referral_default_uses: int = Field(
default=1, validation_alias=AliasChoices("REFERRAL_DEFAULT_USES")
)
password_min_length: int = Field(
default=8, validation_alias=AliasChoices("PASSWORD_MIN_LENGTH")
)
password_require_upper: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_UPPER")
)
password_require_lower: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_LOWER")
)
password_require_number: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_NUMBER")
)
password_require_symbol: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_SYMBOL")
)
password_reset_enabled: bool = Field(
default=True, validation_alias=AliasChoices("PASSWORD_RESET_ENABLED")
)
captcha_provider: str = Field(
default="none", validation_alias=AliasChoices("CAPTCHA_PROVIDER")
)
hcaptcha_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("HCAPTCHA_SITE_KEY")
)
hcaptcha_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("HCAPTCHA_SECRET_KEY")
)
recaptcha_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RECAPTCHA_SITE_KEY")
)
recaptcha_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RECAPTCHA_SECRET_KEY")
)
turnstile_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TURNSTILE_SITE_KEY")
)
turnstile_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TURNSTILE_SECRET_KEY")
)
smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_HOST")
)
smtp_port: Optional[int] = Field(
default=587, validation_alias=AliasChoices("SMTP_PORT")
)
smtp_user: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_USER")
)
smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_PASSWORD")
)
smtp_from: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_FROM")
)
smtp_tls: bool = Field(default=False, validation_alias=AliasChoices("SMTP_TLS"))
smtp_starttls: bool = Field(default=True, validation_alias=AliasChoices("SMTP_STARTTLS"))
notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_EMAIL_ENABLED")
)
notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_DISCORD_ENABLED")
)
notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_TELEGRAM_ENABLED")
)
notify_matrix_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_MATRIX_ENABLED")
)
notify_pushover_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_PUSHOVER_ENABLED")
)
notify_pushbullet_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_PUSHBULLET_ENABLED")
)
notify_gotify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_GOTIFY_ENABLED")
)
notify_ntfy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_NTFY_ENABLED")
)
telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TELEGRAM_BOT_TOKEN")
)
telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TELEGRAM_CHAT_ID")
)
matrix_homeserver: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_HOMESERVER")
)
matrix_user: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_USER")
)
matrix_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_PASSWORD")
)
matrix_access_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_ACCESS_TOKEN")
)
matrix_room_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_ROOM_ID")
)
pushover_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHOVER_TOKEN")
)
pushover_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHOVER_USER_KEY")
)
pushbullet_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHBULLET_TOKEN")
)
gotify_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("GOTIFY_URL")
)
gotify_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("GOTIFY_TOKEN")
)
ntfy_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("NTFY_URL")
)
ntfy_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("NTFY_TOPIC")
)
expiry_default_days: int = Field(
default=0, validation_alias=AliasChoices("EXPIRY_DEFAULT_DAYS")
)
expiry_default_action: str = Field(
default="disable", validation_alias=AliasChoices("EXPIRY_DEFAULT_ACTION")
)
expiry_warning_days: int = Field(
default=3, validation_alias=AliasChoices("EXPIRY_WARNING_DAYS")
)
expiry_check_interval_minutes: int = Field(
default=60, validation_alias=AliasChoices("EXPIRY_CHECK_INTERVAL_MINUTES")
)
jellyseerr_sync_users: bool = Field(
default=True, validation_alias=AliasChoices("JELLYSEERR_SYNC_USERS")
)
jellyseerr_sync_interval_minutes: int = Field(
default=1440, validation_alias=AliasChoices("JELLYSEERR_SYNC_INTERVAL_MINUTES")
)
settings = Settings()

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,8 @@ 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 .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.jellyseerr_sync import run_jellyseerr_sync_loop
from .services.expiry import run_expiry_loop
from .logging_config import configure_logging
from .runtime import get_runtime_settings
@@ -45,12 +44,10 @@ async def startup() -> None:
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())
asyncio.create_task(run_jellyseerr_sync_loop())
asyncio.create_task(startup_warmup_requests_cache())
asyncio.create_task(run_requests_delta_loop())
asyncio.create_task(run_daily_requests_full_sync())
asyncio.create_task(run_daily_db_cleanup())
asyncio.create_task(run_expiry_loop())
app.include_router(requests_router)
@@ -60,3 +57,4 @@ 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)

View File

@@ -1,28 +1,27 @@
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import secrets
import ipaddress
import os
from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
from ..auth import require_admin
from ..auth import require_admin, get_current_user
from ..config import settings as env_settings
from ..db import (
delete_setting,
get_all_users,
get_invite_profile,
list_invite_profiles,
create_invite_profile,
create_invite,
list_invites,
disable_invite,
delete_invite,
delete_user,
get_all_contacts,
get_cached_requests,
get_cached_requests_count,
get_request_cache_overview,
save_announcement,
get_request_cache_missing_titles,
get_request_cache_stats,
get_settings_overrides,
get_user_by_id,
get_user_by_username,
get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
set_setting,
set_user_blocked,
set_user_password,
@@ -32,6 +31,9 @@ from ..db import (
clear_requests_cache,
clear_history,
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -39,12 +41,18 @@ from ..clients.radarr import RadarrClient
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.jellyseerr_sync import sync_jellyseerr_users
from ..services.user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyfin_users,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
save_jellyseerr_users_cache,
)
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
from ..services.notifications import send_notification
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
logger = logging.getLogger(__name__)
@@ -56,17 +64,16 @@ SENSITIVE_KEYS = {
"radarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
"smtp_password",
"hcaptcha_secret_key",
"recaptcha_secret_key",
"turnstile_secret_key",
"telegram_bot_token",
"matrix_password",
"matrix_access_token",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_token",
}
URL_SETTING_KEYS = {
"jellyseerr_base_url",
"jellyfin_base_url",
"jellyfin_public_url",
"sonarr_base_url",
"radarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
}
SETTING_KEYS: List[str] = [
@@ -81,10 +88,12 @@ SETTING_KEYS: List[str] = [
"sonarr_api_key",
"sonarr_quality_profile_id",
"sonarr_root_folder",
"sonarr_qbittorrent_category",
"radarr_base_url",
"radarr_api_key",
"radarr_quality_profile_id",
"radarr_root_folder",
"radarr_qbittorrent_category",
"prowlarr_base_url",
"prowlarr_api_key",
"qbittorrent_base_url",
@@ -99,61 +108,60 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
"invites_enabled",
"invites_require_captcha",
"invite_default_profile_id",
"signup_allow_referrals",
"referral_default_uses",
"password_min_length",
"password_require_upper",
"password_require_lower",
"password_require_number",
"password_require_symbol",
"password_reset_enabled",
"captcha_provider",
"hcaptcha_site_key",
"hcaptcha_secret_key",
"recaptcha_site_key",
"recaptcha_secret_key",
"turnstile_site_key",
"turnstile_secret_key",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from",
"smtp_tls",
"smtp_starttls",
"notify_email_enabled",
"notify_discord_enabled",
"notify_telegram_enabled",
"notify_matrix_enabled",
"notify_pushover_enabled",
"notify_pushbullet_enabled",
"notify_gotify_enabled",
"notify_ntfy_enabled",
"telegram_bot_token",
"telegram_chat_id",
"matrix_homeserver",
"matrix_user",
"matrix_password",
"matrix_access_token",
"matrix_room_id",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_url",
"gotify_token",
"ntfy_url",
"ntfy_topic",
"expiry_default_days",
"expiry_default_action",
"expiry_warning_days",
"expiry_check_interval_minutes",
"jellyseerr_sync_users",
"jellyseerr_sync_interval_minutes",
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
]
def _normalize_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
def _is_ip_host(host: str) -> bool:
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
def _normalize_service_url(value: str) -> str:
raw = value.strip()
if not raw:
raise ValueError("URL cannot be empty.")
candidate = raw
if "://" not in candidate:
authority = candidate.split("/", 1)[0].strip()
if authority.startswith("["):
closing = authority.find("]")
host = authority[1:closing] if closing > 0 else authority.strip("[]")
else:
host = authority.split(":", 1)[0]
host = host.strip().lower()
default_scheme = "http" if host in {"localhost"} or _is_ip_host(host) or "." not in host else "https"
candidate = f"{default_scheme}://{candidate}"
parsed = urlparse(candidate)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if not parsed.netloc:
raise ValueError("URL must include a host.")
if parsed.query or parsed.fragment:
raise ValueError("URL must not include query params or fragments.")
if not parsed.hostname:
raise ValueError("URL must include a valid host.")
normalized_path = parsed.path.rstrip("/")
normalized = parsed._replace(path=normalized_path, params="", query="", fragment="")
result = urlunparse(normalized).rstrip("/")
if not result:
raise ValueError("URL is invalid.")
return result
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
if not isinstance(folders, list):
return []
@@ -169,6 +177,38 @@ def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
return results
async def _hydrate_cache_titles_from_jellyseerr(limit: int) -> int:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return 0
missing = get_request_cache_missing_titles(limit)
if not missing:
return 0
hydrated = 0
for row in missing:
tmdb_id = row.get("tmdb_id")
media_type = row.get("media_type")
request_id = row.get("request_id")
if not tmdb_id or not media_type or not request_id:
continue
try:
title, year = await requests_router._hydrate_title_from_tmdb(
client, media_type, tmdb_id
)
except Exception:
logger.warning(
"Requests cache title hydrate failed: request_id=%s tmdb_id=%s",
request_id,
tmdb_id,
)
continue
if title:
update_request_cache_title(request_id, title, year)
hydrated += 1
return hydrated
def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
if not isinstance(profiles, list):
return []
@@ -218,7 +258,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
delete_setting(key)
updates += 1
continue
set_setting(key, str(value))
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1
if key in {"log_level", "log_file"}:
touched_logging = True
@@ -258,6 +305,9 @@ async def radarr_options() -> Dict[str, Any]:
@router.get("/jellyfin/users")
async def jellyfin_users() -> Dict[str, Any]:
cached = get_cached_jellyfin_users()
if cached is not None:
return {"users": cached}
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
@@ -265,18 +315,7 @@ async def jellyfin_users() -> Dict[str, Any]:
users = await client.get_users()
if not isinstance(users, list):
return {"users": []}
results = []
for user in users:
if not isinstance(user, dict):
continue
results.append(
{
"id": user.get("Id"),
"name": user.get("Name"),
"hasPassword": user.get("HasPassword"),
"lastLoginDate": user.get("LastLoginDate"),
}
)
results = save_jellyfin_users_cache(users)
return {"users": results}
@@ -285,12 +324,106 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
imported = await sync_jellyfin_users()
return {"status": "ok", "imported": imported}
async def _fetch_all_jellyseerr_users(
client: JellyseerrClient, use_cache: bool = True
) -> List[Dict[str, Any]]:
if use_cache:
cached = get_cached_jellyseerr_users()
if cached is not None:
return cached
users: List[Dict[str, Any]] = []
take = 100
skip = 0
while True:
payload = await client.get_users(take=take, skip=skip)
if not payload:
break
if isinstance(payload, list):
batch = payload
elif isinstance(payload, dict):
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
else:
batch = None
if not isinstance(batch, list) or not batch:
break
users.extend([user for user in batch if isinstance(user, dict)])
if len(batch) < take:
break
skip += take
if users:
return save_jellyseerr_users_cache(users)
return users
@router.post("/jellyseerr/users/sync")
async def jellyseerr_users_sync() -> Dict[str, Any]:
imported = await sync_jellyseerr_users()
return {"status": "ok", "imported": imported}
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")
jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
if not jellyseerr_users:
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
updated = 0
skipped = 0
users = get_all_users()
for user in users:
if user.get("jellyseerr_user_id") is not None:
skipped += 1
continue
username = user.get("username") or ""
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
updated += 1
else:
skipped += 1
return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
for key in ("email", "username", "displayName", "name"):
value = user.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
@router.post("/jellyseerr/users/resync")
async def jellyseerr_users_resync() -> Dict[str, Any]:
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")
jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
if not jellyseerr_users:
return {"status": "ok", "imported": 0, "cleared": 0}
cleared = delete_non_admin_users()
imported = 0
for user in jellyseerr_users:
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
username = _pick_jellyseerr_username(user)
if not username:
continue
created = create_user_if_missing(
username,
"jellyseerr-user",
role="user",
auth_provider="jellyseerr",
jellyseerr_user_id=user_id,
)
if created:
imported += 1
else:
set_user_jellyseerr_id(username, user_id)
return {"status": "ok", "imported": imported, "cleared": cleared}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
@@ -319,10 +452,12 @@ async def requests_sync_delta() -> Dict[str, Any]:
@router.post("/requests/artwork/prefetch")
async def requests_artwork_prefetch() -> Dict[str, Any]:
async def requests_artwork_prefetch(only_missing: bool = False) -> Dict[str, Any]:
runtime = get_runtime_settings()
state = await requests_router.start_artwork_prefetch(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key
runtime.jellyseerr_base_url,
runtime.jellyseerr_api_key,
only_missing=only_missing,
)
logger.info("Admin triggered artwork prefetch: status=%s", state.get("status"))
return {"status": "ok", "prefetch": state}
@@ -332,6 +467,25 @@ async def requests_artwork_prefetch() -> Dict[str, Any]:
async def requests_artwork_status() -> Dict[str, Any]:
return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()}
@router.get("/requests/artwork/summary")
async def requests_artwork_summary() -> Dict[str, Any]:
runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
stats = get_request_cache_stats()
if cache_mode != "cache":
stats["cache_bytes"] = 0
stats["cache_files"] = 0
stats["missing_artwork"] = 0
summary = {
"cache_mode": cache_mode,
"cache_bytes": stats.get("cache_bytes", 0),
"cache_files": stats.get("cache_files", 0),
"missing_artwork": stats.get("missing_artwork", 0),
"total_requests": stats.get("total_requests", 0),
"updated_at": stats.get("updated_at"),
}
return {"status": "ok", "summary": summary}
@router.get("/requests/sync/status")
async def requests_sync_status() -> Dict[str, Any]:
@@ -358,7 +512,48 @@ async def read_logs(lines: int = 200) -> Dict[str, Any]:
@router.get("/requests/cache")
async def requests_cache(limit: int = 50) -> Dict[str, Any]:
return {"rows": get_request_cache_overview(limit)}
repaired = repair_request_cache_titles()
if repaired:
logger.info("Requests cache titles repaired via settings view: %s", repaired)
hydrated = await _hydrate_cache_titles_from_jellyseerr(limit)
if hydrated:
logger.info("Requests cache titles hydrated via Jellyseerr: %s", hydrated)
rows = get_request_cache_overview(limit)
return {"rows": rows}
@router.get("/requests/all")
async def requests_all(
take: int = 50,
skip: int = 0,
days: Optional[int] = None,
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
take = max(1, min(int(take or 50), 200))
skip = max(0, int(skip or 0))
since_iso = None
if days is not None and int(days) > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso)
total = get_cached_requests_count(since_iso=since_iso)
results = []
for row in rows:
status = row.get("status")
results.append(
{
"id": row.get("request_id"),
"title": row.get("title"),
"year": row.get("year"),
"type": row.get("media_type"),
"status": status,
"statusLabel": requests_router._status_label(status),
"requestedBy": row.get("requested_by"),
"createdAt": row.get("created_at"),
}
)
return {"results": results, "total": total, "take": take, "skip": skip}
@router.post("/branding/logo")
@@ -410,9 +605,39 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users")
async def list_users() -> Dict[str, Any]:
users = get_all_users()
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
return {"users": users}
@router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
results.append({**user, "stats": stats})
return {"users": results}
@router.get("/users/{username}")
async def get_user_summary(username: str) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
@router.get("/users/id/{user_id}")
async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
user = get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
@router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]:
@@ -449,157 +674,3 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
)
set_user_password(username, new_password.strip())
return {"status": "ok", "username": username}
@router.post("/users/bulk")
async def bulk_user_action(payload: Dict[str, Any]) -> Dict[str, Any]:
action = str(payload.get("action") or "").strip().lower()
usernames = payload.get("usernames")
if not isinstance(usernames, list) or not usernames:
raise HTTPException(status_code=400, detail="User list required")
if action not in {"block", "unblock", "delete", "role"}:
raise HTTPException(status_code=400, detail="Invalid action")
updated = 0
for username in usernames:
if not isinstance(username, str) or not username.strip():
continue
name = username.strip()
if action == "block":
set_user_blocked(name, True)
elif action == "unblock":
set_user_blocked(name, False)
elif action == "delete":
delete_user(name)
elif action == "role":
role = str(payload.get("role") or "").strip().lower()
if role not in {"admin", "user"}:
raise HTTPException(status_code=400, detail="Invalid role")
set_user_role(name, role)
updated += 1
return {"status": "ok", "updated": updated}
@router.get("/invite-profiles")
async def invite_profiles() -> Dict[str, Any]:
return {"profiles": list_invite_profiles()}
@router.post("/invite-profiles")
async def create_profile(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Profile name required")
profile_id = create_invite_profile(
name=name,
description=str(payload.get("description") or "").strip() or None,
max_uses=payload.get("max_uses"),
expires_in_days=payload.get("expires_in_days"),
require_captcha=bool(payload.get("require_captcha")),
password_rules=payload.get("password_rules") if isinstance(payload.get("password_rules"), dict) else None,
allow_referrals=bool(payload.get("allow_referrals")),
referral_uses=payload.get("referral_uses"),
user_expiry_days=payload.get("user_expiry_days"),
user_expiry_action=str(payload.get("user_expiry_action") or "").strip() or None,
)
return {"status": "ok", "id": profile_id}
@router.get("/invites")
async def list_invites_endpoint(limit: int = 200) -> Dict[str, Any]:
return {"invites": list_invites(limit)}
@router.post("/invites")
async def create_invite_endpoint(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
runtime = get_runtime_settings()
profile_id = payload.get("profile_id")
profile = None
if profile_id is not None:
try:
profile = get_invite_profile(int(profile_id))
except (TypeError, ValueError):
profile = None
expires_in_days = payload.get("expires_in_days") or (profile.get("expires_in_days") if profile else None)
expires_at = None
if expires_in_days:
try:
expires_at = (
datetime.now(timezone.utc) + timedelta(days=float(expires_in_days))
).isoformat()
except (TypeError, ValueError):
expires_at = None
require_captcha = bool(payload.get("require_captcha"))
if not require_captcha and profile:
require_captcha = bool(profile.get("require_captcha"))
if not require_captcha:
require_captcha = runtime.invites_require_captcha
password_rules = payload.get("password_rules")
if not isinstance(password_rules, dict):
password_rules = profile.get("password_rules") if profile else None
allow_referrals = bool(payload.get("allow_referrals"))
if not allow_referrals and profile:
allow_referrals = bool(profile.get("allow_referrals"))
user_expiry_days = payload.get("user_expiry_days") or (profile.get("user_expiry_days") if profile else None)
user_expiry_action = payload.get("user_expiry_action") or (profile.get("user_expiry_action") if profile else None)
code = secrets.token_urlsafe(8)
create_invite(
code=code,
created_by=user.get("username"),
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
max_uses=payload.get("max_uses") or (profile.get("max_uses") if profile else None),
require_captcha=require_captcha,
password_rules=password_rules if isinstance(password_rules, dict) else None,
allow_referrals=allow_referrals,
referral_uses=payload.get("referral_uses") or (profile.get("referral_uses") if profile else None),
user_expiry_days=user_expiry_days,
user_expiry_action=str(user_expiry_action) if user_expiry_action else None,
is_referral=bool(payload.get("is_referral")),
)
return {"status": "ok", "code": code}
@router.post("/invites/{code}/disable")
async def disable_invite_endpoint(code: str) -> Dict[str, Any]:
disable_invite(code)
return {"status": "ok", "code": code, "disabled": True}
@router.delete("/invites/{code}")
async def delete_invite_endpoint(code: str) -> Dict[str, Any]:
delete_invite(code)
return {"status": "ok", "code": code, "deleted": True}
@router.post("/announcements")
async def send_announcement(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
subject = str(payload.get("subject") or "").strip()
body = str(payload.get("body") or "").strip()
channels = payload.get("channels") if isinstance(payload.get("channels"), list) else []
if not subject or not body:
raise HTTPException(status_code=400, detail="Subject and message required")
results: Dict[str, Any] = {}
email_count = 0
email_failed = 0
if "email" in [str(c).lower() for c in channels]:
for contact in get_all_contacts():
email = contact.get("email")
if not email:
continue
outcome = await send_notification(subject, body, channels=["email"], email=email)
if outcome.get("email") == "sent":
email_count += 1
else:
email_failed += 1
results["email"] = {"sent": email_count, "failed": email_failed}
other_channels = [c for c in channels if str(c).lower() != "email"]
if other_channels:
results.update(await send_notification(subject, body, channels=other_channels))
save_announcement(user.get("username"), subject, body, ",".join(channels))
return {"status": "ok", "results": results}

View File

@@ -1,58 +1,82 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
import secrets
from ..db import (
verify_user_password,
create_user_if_missing,
create_user,
set_last_login,
get_user_by_username,
get_user_by_email,
set_user_password,
get_invite_by_code,
get_invite_profile,
increment_invite_use,
list_invites_by_creator,
create_invite,
upsert_user_contact,
get_user_contact,
set_user_expiry,
create_password_reset,
get_password_reset,
mark_password_reset_used,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
get_global_request_leader,
get_global_request_total,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token
from ..security import create_access_token, verify_password
from ..auth import get_current_user
from ..services.captcha import verify_captcha
from ..services.notifications import send_notification
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 _validate_password(password: str, rules: dict | None = None) -> Optional[str]:
runtime = get_runtime_settings()
rules = rules or {}
min_length = int(rules.get("min_length") or runtime.password_min_length or 8)
require_upper = bool(rules.get("require_upper", runtime.password_require_upper))
require_lower = bool(rules.get("require_lower", runtime.password_require_lower))
require_number = bool(rules.get("require_number", runtime.password_require_number))
require_symbol = bool(rules.get("require_symbol", runtime.password_require_symbol))
if len(password) < min_length:
return f"Password must be at least {min_length} characters."
if require_upper and password.lower() == password:
return "Password must include an uppercase letter."
if require_lower and password.upper() == password:
return "Password must include a lowercase letter."
if require_number and not any(char.isdigit() for char in password):
return "Password must include a number."
if require_symbol and password.isalnum():
return "Password must include a symbol."
def _normalize_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
if not last_auth_at:
return False
try:
parsed = datetime.fromisoformat(last_auth_at)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(days=7)
def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
if not user or not password:
return False
cached_hash = user.get("jellyfin_password_hash")
last_auth_at = user.get("last_jellyfin_auth_at")
if not cached_hash or not last_auth_at:
return False
if not verify_password(password, cached_hash):
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
@@ -78,30 +102,47 @@ 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")
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user")
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
try:
response = await client.authenticate_by_name(form_data.username, form_data.password)
response = await client.authenticate_by_name(username, password)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(form_data.username)
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")
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
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"}}
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"}}
@router.post("/jellyseerr/login")
@@ -117,10 +158,19 @@ 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")
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"}}
@@ -131,6 +181,32 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@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, 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 {}
activity_recent = get_user_activity(username, limit=5) if username else []
stats_payload = {
**stats,
"share": share,
"global_total": global_total,
}
if current_user.get("role") == "admin":
stats_payload["most_active_user"] = get_global_request_leader()
return {
"user": current_user,
"stats": stats_payload,
"activity": {
**activity_summary,
"recent": activity_recent,
},
}
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if current_user.get("auth_provider") != "local":
@@ -142,216 +218,12 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
error = _validate_password(new_password.strip())
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
if len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
user = verify_user_password(current_user["username"], current_password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
set_user_password(current_user["username"], new_password.strip())
return {"status": "ok"}
@router.get("/contact")
async def get_contact(current_user: dict = Depends(get_current_user)) -> dict:
contact = get_user_contact(current_user["username"])
return {"contact": contact or {}}
@router.post("/contact")
async def update_contact(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
upsert_user_contact(
current_user["username"],
email=str(payload.get("email") or "").strip() or None,
discord=str(payload.get("discord") or "").strip() or None,
telegram=str(payload.get("telegram") or "").strip() or None,
matrix=str(payload.get("matrix") or "").strip() or None,
)
return {"status": "ok"}
@router.post("/register")
async def register(payload: dict, request: Request) -> dict:
runtime = get_runtime_settings()
if not runtime.invites_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invites are disabled")
invite_code = str(payload.get("invite_code") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "").strip()
contact = payload.get("contact") if isinstance(payload, dict) else None
captcha_token = str(payload.get("captcha_token") or "").strip()
if not invite_code or not username or not password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite, username, and password required")
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists")
invite = get_invite_by_code(invite_code)
if not invite or invite.get("disabled"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite not found or disabled")
profile = None
if invite.get("profile_id"):
profile = get_invite_profile(int(invite["profile_id"]))
max_uses = invite.get("max_uses")
if max_uses is not None and invite.get("uses_count", 0) >= max_uses:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has been fully used")
expires_at = invite.get("expires_at")
if expires_at:
try:
if datetime.fromisoformat(expires_at) <= datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite has expired")
except ValueError:
pass
require_captcha = (
bool(invite.get("require_captcha"))
or (bool(profile.get("require_captcha")) if profile else False)
or runtime.invites_require_captcha
)
if require_captcha:
ok = await verify_captcha(captcha_token, request.client.host if request.client else None)
if not ok:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Captcha failed")
rules = invite.get("password_rules") or (profile.get("password_rules") if profile else None)
error = _validate_password(password, rules)
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
try:
create_user(username, password, role="user", auth_provider="local")
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(contact, dict):
upsert_user_contact(
username,
email=str(contact.get("email") or "").strip() or None,
discord=str(contact.get("discord") or "").strip() or None,
telegram=str(contact.get("telegram") or "").strip() or None,
matrix=str(contact.get("matrix") or "").strip() or None,
)
expiry_days = (
invite.get("user_expiry_days")
or (profile.get("user_expiry_days") if profile else None)
or runtime.expiry_default_days
)
expiry_action = (
invite.get("user_expiry_action")
or (profile.get("user_expiry_action") if profile else None)
or runtime.expiry_default_action
)
if expiry_days and expiry_action:
try:
expiry_days_float = float(expiry_days)
except (TypeError, ValueError):
expiry_days_float = 0
if expiry_days_float > 0:
expires_at = (
datetime.now(timezone.utc) + timedelta(days=expiry_days_float)
).isoformat()
set_user_expiry(username, expires_at, str(expiry_action))
increment_invite_use(invite_code)
token = create_access_token(username, "user")
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@router.post("/password/reset")
async def request_password_reset(payload: dict) -> dict:
runtime = get_runtime_settings()
if not runtime.password_reset_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset disabled")
identifier = str(payload.get("identifier") or "").strip()
if not identifier:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email required")
user = get_user_by_username(identifier)
if not user:
user = get_user_by_email(identifier)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.get("auth_provider") != "local":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset for local users only")
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
create_password_reset(token, user["username"], expires_at)
contact = get_user_contact(user["username"])
email = contact.get("email") if isinstance(contact, dict) else None
if not runtime.notify_email_enabled or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email notifications are not configured for password resets.",
)
await send_notification(
"Password reset request",
f"Your reset token is: {token}",
channels=["email"],
email=email,
)
return {"status": "ok"}
@router.post("/password/reset/confirm")
async def confirm_password_reset(payload: dict) -> dict:
token = str(payload.get("token") or "").strip()
new_password = str(payload.get("new_password") or "").strip()
if not token or not new_password:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token and new password required")
reset = get_password_reset(token)
if not reset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reset token not found")
if reset.get("used_at"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token already used")
try:
expires_at = datetime.fromisoformat(reset["expires_at"])
if expires_at <= datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token expired")
except ValueError:
pass
error = _validate_password(new_password)
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
set_user_password(reset["username"], new_password)
mark_password_reset_used(token)
return {"status": "ok"}
@router.get("/signup/config")
async def signup_config() -> dict:
runtime = get_runtime_settings()
return {
"invites_enabled": runtime.invites_enabled,
"captcha_provider": runtime.captcha_provider,
"hcaptcha_site_key": runtime.hcaptcha_site_key,
"recaptcha_site_key": runtime.recaptcha_site_key,
"turnstile_site_key": runtime.turnstile_site_key,
"password_min_length": runtime.password_min_length,
"password_require_upper": runtime.password_require_upper,
"password_require_lower": runtime.password_require_lower,
"password_require_number": runtime.password_require_number,
"password_require_symbol": runtime.password_require_symbol,
}
@router.get("/referrals")
async def list_referrals(current_user: dict = Depends(get_current_user)) -> dict:
invites = list_invites_by_creator(current_user["username"], is_referral=True)
return {"invites": invites}
@router.post("/referrals")
async def create_referral(current_user: dict = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
if not runtime.signup_allow_referrals:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Referrals are disabled")
code = secrets.token_urlsafe(8)
create_invite(
code=code,
created_by=current_user["username"],
profile_id=runtime.invite_default_profile_id,
expires_at=None,
max_uses=int(runtime.referral_default_uses or 1),
require_captcha=runtime.invites_require_captcha,
password_rules=None,
allow_referrals=False,
referral_uses=None,
user_expiry_days=None,
user_expiry_action=None,
is_referral=True,
)
return {"status": "ok", "code": code}

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

@@ -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,10 +71,12 @@ _artwork_prefetch_state: Dict[str, Any] = {
"processed": 0,
"total": 0,
"message": "",
"only_missing": False,
"started_at": None,
"finished_at": None,
}
_artwork_prefetch_task: Optional[asyncio.Task] = None
_media_endpoint_supported: Optional[bool] = None
STATUS_LABELS = {
1: "Waiting for approval",
@@ -104,6 +113,10 @@ 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
@@ -155,6 +168,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
@@ -197,6 +225,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,
@@ -207,6 +236,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,
}
@@ -225,6 +255,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}"
@@ -269,10 +401,17 @@ async def _hydrate_title_from_tmdb(
async def _hydrate_media_details(client: JellyseerrClient, media_id: Optional[int]) -> Optional[Dict[str, Any]]:
if not media_id:
return None
global _media_endpoint_supported
if _media_endpoint_supported is False:
return None
try:
details = await client.get_media(int(media_id))
except httpx.HTTPStatusError:
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 405:
_media_endpoint_supported = False
logger.info("Jellyseerr media endpoint rejected GET requests; skipping media lookups.")
return None
_media_endpoint_supported = True
return details if isinstance(details, dict) else None
@@ -393,14 +532,23 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
continue
payload = _parse_request_payload(item)
request_id = payload.get("request_id")
cached_title = None
if isinstance(request_id, int):
if not payload.get("title"):
cached = get_request_cache_by_id(request_id)
if cached and cached.get("title"):
cached_title = cached.get("title")
if not payload.get("title") or not payload.get("media_id"):
logger.debug("Jellyseerr sync hydrate request_id=%s", request_id)
details = await _get_request_details(client, request_id)
if isinstance(details, dict):
payload = _parse_request_payload(details)
item = details
if not payload.get("title") and payload.get("media_id"):
if (
not payload.get("title")
and payload.get("media_id")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
media_details = await _hydrate_media_details(client, payload.get("media_id"))
if isinstance(media_details, dict):
media_title = media_details.get("title") or media_details.get("name")
@@ -428,7 +576,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
if isinstance(details, dict):
item = details
payload = _parse_request_payload(details)
if not payload.get("title") and payload.get("tmdb_id"):
if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"):
hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
client, payload.get("media_type"), payload.get("tmdb_id")
)
@@ -436,6 +584,8 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
payload["title"] = hydrated_title
if hydrated_year:
payload["year"] = hydrated_year
if not payload.get("title") and cached_title:
payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int):
continue
payload_json = json.dumps(item, ensure_ascii=True)
@@ -448,10 +598,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:
@@ -471,6 +624,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
@@ -516,6 +674,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if isinstance(request_id, int):
cached = get_request_cache_by_id(request_id)
incoming_updated = payload.get("updated_at")
cached_title = cached.get("title") if cached else None
if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"):
continue
if not payload.get("title") or not payload.get("media_id"):
@@ -523,7 +682,11 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if isinstance(details, dict):
payload = _parse_request_payload(details)
item = details
if not payload.get("title") and payload.get("media_id"):
if (
not payload.get("title")
and payload.get("media_id")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
media_details = await _hydrate_media_details(client, payload.get("media_id"))
if isinstance(media_details, dict):
media_title = media_details.get("title") or media_details.get("name")
@@ -551,7 +714,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if isinstance(details, dict):
payload = _parse_request_payload(details)
item = details
if not payload.get("title") and payload.get("tmdb_id"):
if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"):
hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
client, payload.get("media_type"), payload.get("tmdb_id")
)
@@ -559,6 +722,8 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
payload["title"] = hydrated_title
if hydrated_year:
payload["year"] = hydrated_year
if not payload.get("title") and cached_title:
payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int):
continue
payload_json = json.dumps(item, ensure_ascii=True)
@@ -571,10 +736,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
@@ -602,10 +770,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":
@@ -618,74 +796,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(
@@ -693,6 +898,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",
@@ -703,25 +917,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(
@@ -768,19 +1009,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]
@@ -788,13 +1049,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()
@@ -942,7 +1204,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
@@ -999,6 +1264,148 @@ def _normalize_categories(categories: Any) -> List[str]:
return names
def _normalize_indexer_name(value: Optional[str]) -> str:
if not isinstance(value, str):
return ""
return "".join(ch for ch in value.lower().strip() if ch.isalnum())
def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None:
if exc.response is None:
logger.warning("%s %s failed: %s", service_label, action, exc)
return
status = exc.response.status_code
body = exc.response.text
if isinstance(body, str):
body = body.strip()
if len(body) > 800:
body = f"{body[:800]}...(truncated)"
logger.warning("%s %s failed: status=%s body=%s", service_label, action, status, body)
def _format_rejections(rejections: Any) -> Optional[str]:
if isinstance(rejections, str):
return rejections.strip() or None
if isinstance(rejections, list):
reasons = []
for item in rejections:
reason = None
if isinstance(item, dict):
reason = (
item.get("reason")
or item.get("message")
or item.get("errorMessage")
)
if not reason and item is not None:
reason = str(item)
if isinstance(reason, str) and reason.strip():
reasons.append(reason.strip())
if reasons:
return "; ".join(reasons)
return None
def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
if not isinstance(response, dict):
return True, None
rejections = response.get("rejections") or response.get("rejectionReasons")
reason = _format_rejections(rejections)
if reason:
return False, reason
if response.get("rejected") is True:
return False, "rejected"
if response.get("downloadAllowed") is False:
return False, "download not allowed"
if response.get("approved") is False:
return False, "not approved"
return True, None
def _resolve_arr_indexer_id(
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
) -> Optional[int]:
if not isinstance(indexers, list):
return None
if not indexer_name:
if indexer_id is None:
return None
by_id = next(
(item for item in indexers if isinstance(item, dict) and item.get("id") == indexer_id),
None,
)
if by_id and by_id.get("id") is not None:
logger.debug("%s indexer id match: %s", service_label, by_id.get("id"))
return int(by_id["id"])
return None
target = indexer_name.lower().strip()
target_compact = _normalize_indexer_name(indexer_name)
exact = next(
(
item
for item in indexers
if isinstance(item, dict)
and str(item.get("name", "")).lower().strip() == target
),
None,
)
if exact and exact.get("id") is not None:
logger.debug("%s indexer match: '%s' -> %s", service_label, indexer_name, exact.get("id"))
return int(exact["id"])
compact = next(
(
item
for item in indexers
if isinstance(item, dict)
and _normalize_indexer_name(str(item.get("name", ""))) == target_compact
),
None,
)
if compact and compact.get("id") is not None:
logger.debug("%s indexer compact match: '%s' -> %s", service_label, indexer_name, compact.get("id"))
return int(compact["id"])
contains = next(
(
item
for item in indexers
if isinstance(item, dict)
and target in str(item.get("name", "")).lower()
),
None,
)
if contains and contains.get("id") is not None:
logger.debug("%s indexer contains match: '%s' -> %s", service_label, indexer_name, contains.get("id"))
return int(contains["id"])
logger.warning(
"%s indexer not found for name '%s'. Check indexer names in the Arr app.",
service_label,
indexer_name,
)
return None
async def _fallback_qbittorrent_download(download_url: Optional[str], category: str) -> bool:
if not download_url:
return False
runtime = get_runtime_settings()
client = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not client.configured():
return False
await client.add_torrent_url(download_url, category=category)
return True
def _resolve_qbittorrent_category(value: Optional[str], default: str) -> str:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return default
def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Dict[str, Any]]:
if not isinstance(results, list):
return []
@@ -1081,27 +1488,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] = {}
@@ -1174,6 +1583,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),
@@ -1221,6 +1631,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),
@@ -1239,6 +1650,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),
@@ -1293,8 +1705,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")
@@ -1607,78 +2025,42 @@ async def action_grab(
snapshot = await build_snapshot(request_id)
guid = payload.get("guid")
indexer_id = payload.get("indexerId")
indexer_name = payload.get("indexerName") or payload.get("indexer")
download_url = payload.get("downloadUrl")
release_title = payload.get("title")
release_size = payload.get("size")
release_protocol = payload.get("protocol") or "torrent"
release_publish = payload.get("publishDate")
release_seeders = payload.get("seeders")
release_leechers = payload.get("leechers")
if not guid or not indexer_id:
raise HTTPException(status_code=400, detail="Missing guid or indexerId")
runtime = get_runtime_settings()
if snapshot.request_type.value == "tv":
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Sonarr not configured")
try:
response = await client.grab_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else 502
if status_code == 404 and download_url:
qbit = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr."
)
return {"status": "ok", "response": response}
if snapshot.request_type.value == "movie":
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
response = await client.grab_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else 502
if status_code == 404 and download_url:
qbit = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr."
)
return {"status": "ok", "response": response}
logger.info(
"Grab requested: request_id=%s guid=%s indexer_id=%s indexer_name=%s has_download_url=%s has_title=%s",
request_id,
guid,
indexer_id,
indexer_name,
bool(download_url),
bool(release_title),
)
raise HTTPException(status_code=400, detail="Unknown request type")
runtime = get_runtime_settings()
if not download_url:
raise HTTPException(status_code=400, detail="Missing downloadUrl")
if snapshot.request_type.value == "tv":
category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
if snapshot.request_type.value == "movie":
category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
if snapshot.request_type.value not in {"tv", "movie"}:
raise HTTPException(status_code=400, detail="Unknown request type")
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
if not qbittorrent_added:
raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent")
action_message = f"Grab sent to qBittorrent (category {category})."
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", action_message
)
return {"status": "ok", "response": {"qbittorrent": "queued"}}

View File

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

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends
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

@@ -2,44 +2,19 @@ from .config import settings
from .db import get_settings_overrides
_INT_FIELDS = {
"expiry_check_interval_minutes",
"expiry_default_days",
"expiry_warning_days",
"sonarr_quality_profile_id",
"radarr_quality_profile_id",
"invite_default_profile_id",
"referral_default_uses",
"jwt_exp_minutes",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
"smtp_port",
"jellyseerr_sync_interval_minutes",
"password_min_length",
}
_BOOL_FIELDS = {
"invites_enabled",
"invites_require_captcha",
"signup_allow_referrals",
"password_require_upper",
"password_require_lower",
"password_require_number",
"password_require_symbol",
"password_reset_enabled",
"smtp_tls",
"smtp_starttls",
"notify_email_enabled",
"notify_discord_enabled",
"notify_telegram_enabled",
"notify_matrix_enabled",
"notify_pushover_enabled",
"notify_pushbullet_enabled",
"notify_gotify_enabled",
"notify_ntfy_enabled",
"jellyfin_sync_to_arr",
"jellyseerr_sync_users",
"site_banner_enabled",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
def get_runtime_settings():
@@ -48,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

@@ -1,43 +0,0 @@
from typing import Optional
import httpx
from ..runtime import get_runtime_settings
async def verify_captcha(token: Optional[str], remote_ip: Optional[str] = None) -> bool:
runtime = get_runtime_settings()
provider = (runtime.captcha_provider or "none").strip().lower()
if provider in {"", "none", "off", "disabled"}:
return True
if not token:
return False
if provider == "hcaptcha":
secret = runtime.hcaptcha_secret_key
url = "https://hcaptcha.com/siteverify"
payload = {"secret": secret, "response": token}
elif provider == "recaptcha":
secret = runtime.recaptcha_secret_key
url = "https://www.google.com/recaptcha/api/siteverify"
payload = {"secret": secret, "response": token}
elif provider == "turnstile":
secret = runtime.turnstile_secret_key
url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
payload = {"secret": secret, "response": token}
else:
return False
if not secret:
return False
if remote_ip:
payload["remoteip"] = remote_ip
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
data = response.json()
return bool(data.get("success"))
except httpx.HTTPError:
return False

View File

@@ -1,47 +0,0 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from ..db import (
get_users_expiring_by,
get_expired_users,
get_user_contact,
mark_expiry_warning_sent,
mark_expiry_disabled,
mark_expiry_deleted,
set_user_blocked,
delete_user,
)
from ..runtime import get_runtime_settings
from .notifications import send_notification
logger = logging.getLogger(__name__)
async def run_expiry_loop() -> None:
while True:
runtime = get_runtime_settings()
now = datetime.now(timezone.utc)
warn_days = int(runtime.expiry_warning_days or 0)
if warn_days > 0:
cutoff = (now + timedelta(days=warn_days)).isoformat()
for user in get_users_expiring_by(cutoff):
contact = get_user_contact(user["username"]) or {}
email = contact.get("email")
await send_notification(
"Account expiring soon",
f"Your account expires on {user['expires_at']}.",
channels=["email"] if email else [],
email=email,
)
mark_expiry_warning_sent(user["username"])
for expired in get_expired_users(now.isoformat()):
action = (expired.get("action") or "disable").lower()
if action in {"disable", "disable_then_delete"}:
set_user_blocked(expired["username"], True)
mark_expiry_disabled(expired["username"])
if action in {"delete", "disable_then_delete"}:
delete_user(expired["username"])
mark_expiry_deleted(expired["username"])
delay = max(60, int(runtime.expiry_check_interval_minutes or 60) * 60)
await asyncio.sleep(delay)

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

@@ -1,56 +0,0 @@
import asyncio
import logging
from typing import Any
from fastapi import HTTPException
from ..clients.jellyseerr import JellyseerrClient
from ..db import create_user_if_missing, upsert_user_contact
from ..runtime import get_runtime_settings
logger = logging.getLogger(__name__)
async def sync_jellyseerr_users() -> int:
runtime = get_runtime_settings()
if not runtime.jellyseerr_base_url or not runtime.jellyseerr_api_key:
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
take = 50
skip = 0
imported = 0
while True:
data = await client.get_users(take=take, skip=skip)
if not isinstance(data, dict):
break
results = data.get("results")
if not isinstance(results, list) or not results:
break
for user in results:
if not isinstance(user, dict):
continue
username = user.get("username") or user.get("email") or user.get("displayName")
if not isinstance(username, str) or not username.strip():
continue
email = user.get("email") if isinstance(user.get("email"), str) else None
if create_user_if_missing(username.strip(), "jellyseerr-user", role="user", auth_provider="jellyseerr"):
imported += 1
if email:
upsert_user_contact(username.strip(), email=email.strip())
skip += take
return imported
async def run_jellyseerr_sync_loop() -> None:
while True:
runtime = get_runtime_settings()
if runtime.jellyseerr_sync_users:
try:
imported = await sync_jellyseerr_users()
logger.info("Jellyseerr sync complete: imported=%s", imported)
except HTTPException as exc:
logger.warning("Jellyseerr sync skipped: %s", exc.detail)
except Exception:
logger.exception("Jellyseerr sync failed")
delay = max(60, int(runtime.jellyseerr_sync_interval_minutes or 1440) * 60)
await asyncio.sleep(delay)

View File

@@ -1,216 +0,0 @@
from __future__ import annotations
from typing import Iterable, Optional
import asyncio
import logging
import smtplib
from email.message import EmailMessage
import httpx
from ..db import log_notification
from ..runtime import get_runtime_settings
logger = logging.getLogger(__name__)
def _normalize_channels(channels: Optional[Iterable[str]]) -> list[str]:
if not channels:
return []
return [str(channel).strip().lower() for channel in channels if str(channel).strip()]
def _send_email_sync(
smtp_host: str,
smtp_port: int,
smtp_user: Optional[str],
smtp_password: Optional[str],
smtp_from: str,
to_address: str,
subject: str,
body: str,
use_tls: bool,
use_starttls: bool,
) -> None:
message = EmailMessage()
message["From"] = smtp_from
message["To"] = to_address
message["Subject"] = subject
message.set_content(body)
if use_tls:
with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) as server:
if smtp_user and smtp_password:
server.login(smtp_user, smtp_password)
server.send_message(message)
else:
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server:
if use_starttls:
server.starttls()
if smtp_user and smtp_password:
server.login(smtp_user, smtp_password)
server.send_message(message)
async def _send_email(to_address: str, subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_email_enabled:
raise RuntimeError("Email notifications disabled")
if not runtime.smtp_host or not runtime.smtp_from:
raise RuntimeError("SMTP not configured")
await asyncio.to_thread(
_send_email_sync,
runtime.smtp_host,
int(runtime.smtp_port or 587),
runtime.smtp_user,
runtime.smtp_password,
runtime.smtp_from,
to_address,
subject,
body,
bool(runtime.smtp_tls),
bool(runtime.smtp_starttls),
)
async def _send_discord(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_discord_enabled:
raise RuntimeError("Discord notifications disabled")
if not runtime.discord_webhook_url:
raise RuntimeError("Discord webhook not configured")
payload = {"content": f"**{subject}**\n{body}"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(runtime.discord_webhook_url, json=payload)
response.raise_for_status()
async def _send_telegram(subject: str, body: str, chat_id: Optional[str]) -> None:
runtime = get_runtime_settings()
if not runtime.notify_telegram_enabled:
raise RuntimeError("Telegram notifications disabled")
if not runtime.telegram_bot_token:
raise RuntimeError("Telegram bot token not configured")
target_chat = chat_id or runtime.telegram_chat_id
if not target_chat:
raise RuntimeError("Telegram chat ID not configured")
url = f"https://api.telegram.org/bot{runtime.telegram_bot_token}/sendMessage"
payload = {"chat_id": target_chat, "text": f"{subject}\n{body}"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
async def _send_matrix(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_matrix_enabled:
raise RuntimeError("Matrix notifications disabled")
if not runtime.matrix_homeserver or not runtime.matrix_access_token or not runtime.matrix_room_id:
raise RuntimeError("Matrix not configured")
url = (
f"{runtime.matrix_homeserver}/_matrix/client/v3/rooms/"
f"{runtime.matrix_room_id}/send/m.room.message"
)
payload = {"msgtype": "m.text", "body": f"{subject}\n{body}"}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=payload, params={"access_token": runtime.matrix_access_token})
response.raise_for_status()
async def _send_pushover(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_pushover_enabled:
raise RuntimeError("Pushover notifications disabled")
if not runtime.pushover_token or not runtime.pushover_user_key:
raise RuntimeError("Pushover not configured")
payload = {
"token": runtime.pushover_token,
"user": runtime.pushover_user_key,
"title": subject,
"message": body,
}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=payload)
response.raise_for_status()
async def _send_pushbullet(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_pushbullet_enabled:
raise RuntimeError("Pushbullet notifications disabled")
if not runtime.pushbullet_token:
raise RuntimeError("Pushbullet not configured")
payload = {"type": "note", "title": subject, "body": body}
headers = {"Access-Token": runtime.pushbullet_token}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post("https://api.pushbullet.com/v2/pushes", json=payload, headers=headers)
response.raise_for_status()
async def _send_gotify(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_gotify_enabled:
raise RuntimeError("Gotify notifications disabled")
if not runtime.gotify_url or not runtime.gotify_token:
raise RuntimeError("Gotify not configured")
payload = {"title": subject, "message": body, "priority": 5}
url = f"{runtime.gotify_url.rstrip('/')}/message"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, params={"token": runtime.gotify_token}, json=payload)
response.raise_for_status()
async def _send_ntfy(subject: str, body: str) -> None:
runtime = get_runtime_settings()
if not runtime.notify_ntfy_enabled:
raise RuntimeError("ntfy notifications disabled")
if not runtime.ntfy_url or not runtime.ntfy_topic:
raise RuntimeError("ntfy not configured")
url = f"{runtime.ntfy_url.rstrip('/')}/{runtime.ntfy_topic}"
headers = {"Title": subject}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, content=body.encode("utf-8"), headers=headers)
response.raise_for_status()
async def send_notification(
subject: str,
body: str,
channels: Optional[Iterable[str]] = None,
email: Optional[str] = None,
telegram_chat_id: Optional[str] = None,
) -> dict[str, str]:
requested = _normalize_channels(channels)
results: dict[str, str] = {}
if not requested:
return results
for channel in requested:
try:
if channel == "email":
if not email:
raise RuntimeError("Email address not provided")
await _send_email(email, subject, body)
elif channel == "discord":
await _send_discord(subject, body)
elif channel == "telegram":
await _send_telegram(subject, body, telegram_chat_id)
elif channel == "matrix":
await _send_matrix(subject, body)
elif channel == "pushover":
await _send_pushover(subject, body)
elif channel == "pushbullet":
await _send_pushbullet(subject, body)
elif channel == "gotify":
await _send_gotify(subject, body)
elif channel == "ntfy":
await _send_ntfy(subject, body)
else:
results[channel] = "unsupported"
continue
results[channel] = "sent"
log_notification(channel, email or telegram_chat_id, "sent", None)
except Exception as exc: # noqa: BLE001
logger.warning("Notification failed: channel=%s error=%s", channel, exc)
results[channel] = "failed"
log_notification(channel, email or telegram_chat_id, "failed", str(exc))
return results

View File

@@ -11,9 +11,21 @@ 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
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__)
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
_jellyfin_scan_key = "jellyfin_scan_last_at"
STATUS_LABELS = {
1: "Waiting for approval",
@@ -41,6 +53,35 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
return
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return
last_scan = get_setting(_jellyfin_scan_key)
if last_scan:
try:
parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00"))
if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS:
return
except ValueError:
pass
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
if previous:
prev_state = previous[0].get("state")
if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
return
try:
await client.refresh_library()
except Exception as exc:
logger.warning("Jellyfin library refresh failed: %s", exc)
return
set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat())
logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id)
def _queue_records(queue: Any) -> List[Dict[str, Any]]:
if isinstance(queue, dict):
records = queue.get("records")
@@ -185,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"))
@@ -193,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(
@@ -218,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:
@@ -381,10 +450,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
if arr_state is None:
arr_state = "unknown"
if arr_state == "missing" and media_status_code in {4}:
arr_state = "available"
elif arr_state == "missing" and media_status_code in {6}:
arr_state = "added"
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
@@ -524,7 +589,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state_reason = "Waiting for download to start in qBittorrent."
elif arr_state == "missing" and derived_approved:
snapshot.state = NormalizedState.needs_add
snapshot.state_reason = "Approved, but not added to the library yet."
snapshot.state_reason = "Approved, but not yet added to Sonarr/Radarr."
elif arr_state == "searching":
snapshot.state = NormalizedState.searching
snapshot.state_reason = "Searching for a matching release."
@@ -548,7 +613,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
actions.append(
ActionOption(
id="readd_to_arr",
label="Add to the library queue (Sonarr/Radarr)",
label="Push to Sonarr/Radarr",
risk="medium",
)
)
@@ -604,5 +669,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
},
}
await _maybe_refresh_jellyfin(snapshot)
await asyncio.to_thread(save_snapshot, snapshot)
return snapshot

View File

@@ -0,0 +1,144 @@
import json
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from ..db import get_setting, set_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)

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,23 +1,12 @@
services:
backend:
magent:
build:
context: .
dockerfile: backend/Dockerfile
dockerfile: Dockerfile
env_file:
- ./.env
ports:
- "8001:8000"
- "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:
- "3001: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 { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
@@ -21,35 +21,48 @@ 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'])
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.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
artwork: 'artwork',
artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
prowlarr: 'prowlarr',
@@ -58,6 +71,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
cache: null,
logs: 'log',
maintenance: null,
site: 'site',
}
const labelFromKey = (key: string) =>
@@ -68,16 +82,34 @@ 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')
.replace('site build number', 'Build number')
.replace('site banner enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message')
.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
@@ -102,12 +134,15 @@ 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 loadSettings = async () => {
const loadSettings = useCallback(async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`)
if (!response.ok) {
@@ -139,9 +174,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
setFormValues(initialValues)
setStatus(null)
}
}, [router])
const loadArtworkPrefetchStatus = async () => {
const loadArtworkPrefetchStatus = useCallback(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
@@ -153,10 +188,30 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} catch (err) {
console.error(err)
}
}
}, [])
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 = async (service: 'sonarr' | 'radarr') => {
const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/${service}/options`)
@@ -185,7 +240,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRadarrError('Could not load Radarr options.')
}
}
}
}, [])
useEffect(() => {
const load = async () => {
@@ -195,8 +250,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)
@@ -213,7 +269,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') {
void loadOptions('radarr')
}
}, [router, section])
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {}
@@ -228,28 +284,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
@@ -260,35 +339,60 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
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.',
radarr_base_url: 'Radarr server URL for movies.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
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.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
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).',
site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.',
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 = (
@@ -446,6 +550,31 @@ 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') {
return
@@ -463,6 +592,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)
@@ -472,7 +602,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [artworkPrefetch?.status])
}, [artworkPrefetch, loadArtworkSummary])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -482,7 +612,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(null)
}, 5000)
return () => clearTimeout(timer)
}, [artworkPrefetch?.status])
}, [artworkPrefetch])
useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') {
@@ -510,7 +640,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [requestsSync?.status])
}, [requestsSync])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
@@ -520,9 +650,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRequestsSync(null)
}, 5000)
return () => clearTimeout(timer)
}, [requestsSync?.status])
}, [requestsSync])
const loadLogs = async () => {
const loadLogs = useCallback(async () => {
setLogsStatus(null)
try {
const baseUrl = getApiBase()
@@ -547,7 +677,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
: 'Could not load logs.'
setLogsStatus(message)
}
}
}, [logsCount])
useEffect(() => {
if (!showLogs) {
@@ -558,10 +688,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [logsCount, showLogs])
}, [loadLogs, showLogs])
const loadCache = async () => {
setCacheStatus(null)
setCacheLoading(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(
@@ -584,6 +715,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load cache.'
setCacheStatus(message)
} finally {
setCacheLoading(false)
}
}
@@ -698,7 +831,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
@@ -714,24 +849,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 && (
@@ -743,17 +892,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>
@@ -826,6 +1006,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}>
@@ -1007,6 +1191,34 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label>
)
}
if (setting.key === 'site_banner_tone') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'info'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
{BANNER_TONES.map((tone) => (
<option key={tone} value={tone}>
{tone.charAt(0).toUpperCase() + tone.slice(1)}
</option>
))}
</select>
</label>
)
}
if (
setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time'
@@ -1080,11 +1292,42 @@ 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>
)
}
if (TEXTAREA_SETTINGS.has(setting.key)) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? '  stored' : ''}
</span>
</span>
<textarea
name={setting.key}
rows={setting.key === 'site_changelog' ? 6 : 3}
placeholder={
setting.key === 'site_changelog'
? 'One update per line.'
: ''
}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
@@ -1097,9 +1340,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) => ({
@@ -1121,7 +1363,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 && (
@@ -1167,8 +1409,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>

View File

@@ -11,14 +11,9 @@ const ALLOWED_SECTIONS = new Set([
'qbittorrent',
'requests',
'cache',
'invites',
'password',
'captcha',
'smtp',
'notifications',
'expiry',
'logs',
'maintenance',
'site',
])
type PageProps = {

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>
)
}

View File

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

View File

@@ -175,30 +175,35 @@ body {
margin-right: auto;
}
.signed-in {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
padding: 6px 10px;
border-radius: 999px;
border: 1px dashed var(--border);
background: transparent;
cursor: pointer;
}
.signed-in-menu {
position: relative;
display: inline-flex;
align-items: center;
}
.avatar-button {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(130deg, rgba(28, 107, 255, 0.35), rgba(17, 214, 198, 0.25));
color: var(--ink);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 20px rgba(28, 107, 255, 0.25);
cursor: pointer;
}
.signed-in-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
background: rgba(14, 20, 32, 0.95);
width: min(260px, 90vw);
background: rgba(14, 20, 32, 0.96);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px;
@@ -206,17 +211,50 @@ body {
z-index: 20;
}
.signed-in-dropdown a {
.signed-in-header {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
padding: 8px 10px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.signed-in-actions {
display: grid;
gap: 6px;
padding: 8px 4px 4px;
}
.signed-in-actions a,
.signed-in-signout {
display: block;
padding: 8px 12px;
border-radius: 10px;
color: var(--ink);
text-decoration: none;
text-align: center;
text-align: left;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.signed-in-dropdown a:hover {
background: rgba(255, 255, 255, 0.08);
.signed-in-signout {
cursor: pointer;
font: inherit;
}
.signed-in-actions a:hover,
.signed-in-signout:hover {
background: rgba(255, 255, 255, 0.12);
}
.signed-in-build {
margin-top: 6px;
padding: 6px 10px 8px;
font-size: 11px;
color: var(--ink-muted);
text-align: left;
letter-spacing: 0.04em;
}
.theme-toggle {
@@ -521,6 +559,73 @@ button span {
margin-top: 4px;
}
.profile-grid {
display: grid;
gap: 20px;
}
.profile-section {
display: grid;
gap: 12px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat-card {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
display: grid;
gap: 6px;
}
.stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.stat-value {
font-size: 20px;
font-weight: 700;
}
.stat-value--small {
font-size: 14px;
font-weight: 600;
}
.connection-list {
display: grid;
gap: 10px;
}
.connection-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
}
.connection-label {
font-weight: 600;
}
.connection-count {
font-size: 12px;
color: var(--ink-muted);
white-space: nowrap;
}
.state {
display: grid;
gap: 6px;
@@ -647,12 +752,28 @@ button span {
}
.user-card {
display: flex;
justify-content: space-between;
align-items: center;
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 16px;
}
.user-card strong {
display: block;
font-size: 16px;
margin-bottom: 6px;
}
.user-meta {
display: grid;
gap: 6px;
font-size: 13px;
}
.user-meta .meta {
display: block;
}
.user-actions {
display: grid;
gap: 8px;
@@ -906,6 +1027,85 @@ button span {
gap: 12px;
}
.admin-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.admin-toolbar-info {
color: var(--ink-muted);
font-size: 13px;
}
.admin-toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.admin-select {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--ink-muted);
font-size: 13px;
}
.admin-table {
display: grid;
gap: 8px;
}
.admin-table-head {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 12px;
font-size: 12px;
color: var(--ink-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0 12px;
}
.admin-table-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 12px;
align-items: center;
text-align: left;
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.admin-table-row:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(15, 20, 45, 0.18);
}
.admin-pagination {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
color: var(--ink-muted);
font-size: 13px;
}
.admin-pagination button {
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
}
.admin-pagination span {
padding: 0 6px;
}
.section-header {
display: flex;
justify-content: space-between;
@@ -926,6 +1126,17 @@ button span {
flex-wrap: wrap;
}
.sync-actions-block {
display: grid;
gap: 6px;
justify-items: end;
text-align: right;
}
.sync-note {
margin-top: 0;
}
.section-header button {
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
@@ -952,6 +1163,118 @@ button span {
line-height: 1.4;
}
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.user-grid-card {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--ink);
text-decoration: none;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.user-grid-card:hover {
border-color: rgba(59, 130, 246, 0.5);
transform: translateY(-2px);
}
.user-grid-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.user-grid-meta {
display: block;
font-size: 12px;
color: var(--ink-muted);
}
.user-grid-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid rgba(59, 130, 246, 0.4);
color: var(--ink);
background: rgba(59, 130, 246, 0.2);
}
.user-grid-pill.is-blocked {
border-color: rgba(255, 82, 82, 0.5);
background: rgba(255, 82, 82, 0.2);
}
.user-grid-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.user-grid-stats .label {
font-size: 12px;
color: var(--ink-muted);
display: block;
}
.user-grid-stats .value {
font-size: 16px;
font-weight: 600;
}
.user-grid-footer {
display: grid;
gap: 6px;
}
.user-detail-card {
display: grid;
gap: 16px;
padding: 18px;
border-radius: 18px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
}
.user-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.user-detail-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.user-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.user-detail-grid .label {
font-size: 12px;
color: var(--ink-muted);
display: block;
}
.user-detail-grid .value {
font-size: 18px;
font-weight: 600;
}
.label-row {
display: flex;
justify-content: space-between;
@@ -968,6 +1291,49 @@ button span {
border: 1px solid var(--border);
}
.site-banner {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
font-size: 14px;
}
.site-banner--info {
background: rgba(59, 130, 246, 0.18);
border-color: rgba(59, 130, 246, 0.4);
}
.site-banner--warning {
background: rgba(255, 200, 87, 0.22);
border-color: rgba(255, 200, 87, 0.5);
}
.site-banner--error {
background: rgba(255, 59, 48, 0.2);
border-color: rgba(255, 59, 48, 0.4);
}
.site-banner--maintenance {
background: rgba(255, 107, 43, 0.18);
border-color: rgba(255, 107, 43, 0.4);
}
.site-version {
position: fixed;
left: 16px;
bottom: 12px;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--ink-muted);
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.08);
z-index: 30;
}
.recent-header {
display: flex;
justify-content: space-between;
@@ -1151,6 +1517,17 @@ button span {
.progress-indeterminate .progress-fill {
position: absolute;
width: 100%;
left: 0;
top: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0),
var(--accent-2),
var(--accent-3),
rgba(255, 255, 255, 0)
);
background-size: 200% 100%;
animation: progress-indeterminate 1.6s ease-in-out infinite;
}
@@ -1229,6 +1606,24 @@ button span {
font-size: 13px;
}
.system-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.system-test-message {
font-size: 11px;
color: var(--ink-muted);
}
.system-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.system-dot {
width: 10px;
height: 10px;
@@ -1258,10 +1653,28 @@ button span {
}
.system-state {
margin-left: auto;
color: var(--ink-muted);
}
.system-test {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-muted);
font-size: 11px;
letter-spacing: 0.02em;
}
.system-test:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
}
.system-test:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pipeline-map {
border-radius: 16px;
border: 1px solid var(--border);
@@ -1334,13 +1747,10 @@ button span {
@keyframes progress-indeterminate {
0% {
transform: translateX(-50%);
}
50% {
transform: translateX(120%);
background-position: 200% 0;
}
100% {
transform: translateX(-50%);
background-position: -200% 0;
}
}
@@ -1356,19 +1766,83 @@ button span {
}
@media (max-width: 720px) {
.page {
padding: 28px 18px 60px;
gap: 24px;
}
.header {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
align-items: flex-start;
}
.header-left {
width: 100%;
}
.brand-link {
width: 100%;
gap: 12px;
}
.brand-logo--header {
width: 64px;
height: 64px;
}
.brand {
font-size: 26px;
}
.tagline {
font-size: 13px;
}
.header-right {
grid-column: 1 / -1;
justify-content: flex-start;
width: 100%;
flex-wrap: wrap;
gap: 10px;
}
.header-nav {
justify-content: flex-start;
width: 100%;
}
.signed-in-menu {
margin-left: auto;
}
.avatar-button {
width: 40px;
height: 40px;
}
.signed-in-dropdown {
right: 0;
left: auto;
width: min(260px, 92vw);
}
.header-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.header-actions a,
.header-actions .header-link {
font-size: 12px;
padding: 8px 10px;
}
.header-actions .header-cta--left {
grid-column: 1 / -1;
margin-right: 0;
}
.summary {
@@ -1406,6 +1880,21 @@ button span {
.cache-row {
grid-template-columns: 1fr;
}
.user-card {
grid-template-columns: 1fr;
}
.connection-item {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 480px) {
.header-actions {
grid-template-columns: 1fr;
}
}
/* Loading spinner */
@@ -1427,6 +1916,16 @@ button span {
animation: spin 0.9s linear infinite;
}
.button-spinner {
width: 16px;
height: 16px;
border-width: 2px;
box-shadow: none;
margin-right: 8px;
vertical-align: middle;
display: inline-block;
}
.loading-text {
font-size: 16px;
color: var(--ink-muted);
@@ -1496,6 +1995,91 @@ button span {
border: 1px solid var(--border);
}
.how-step-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.how-step-card {
border-radius: 18px;
padding: 18px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
display: grid;
gap: 10px;
position: relative;
overflow: hidden;
}
.how-step-card::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.35;
pointer-events: none;
}
.step-jellyseerr::before {
background: linear-gradient(135deg, rgba(255, 187, 92, 0.35), transparent 60%);
}
.step-arr::before {
background: linear-gradient(135deg, rgba(94, 204, 255, 0.35), transparent 60%);
}
.step-prowlarr::before {
background: linear-gradient(135deg, rgba(120, 255, 189, 0.35), transparent 60%);
}
.step-qbit::before {
background: linear-gradient(135deg, rgba(255, 133, 200, 0.35), transparent 60%);
}
.step-jellyfin::before {
background: linear-gradient(135deg, rgba(170, 140, 255, 0.35), transparent 60%);
}
.step-badge {
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
background: rgba(255, 255, 255, 0.12);
border: 1px solid var(--border);
color: var(--ink);
}
.step-note {
color: var(--ink-muted);
font-size: 14px;
}
.step-fix-title {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
}
.step-fix-list {
list-style: none;
display: grid;
gap: 6px;
padding: 0;
margin: 0;
}
.step-fix-list li {
padding: 8px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border);
font-size: 13px;
}
.how-callout {
border-left: 4px solid var(--accent);
padding: 16px 18px;
@@ -1504,3 +2088,21 @@ button span {
display: grid;
gap: 8px;
}
.changelog-card {
gap: 18px;
}
.changelog-header {
display: grid;
gap: 8px;
}
.changelog-list {
list-style: disc;
padding-left: 22px;
display: grid;
gap: 10px;
color: var(--ink-muted);
font-size: 15px;
}

View File

@@ -75,8 +75,64 @@ export default function HowItWorksPage() {
</ol>
</section>
<section className="how-flow">
<h2>Steps and fixes (simple and visual)</h2>
<div className="how-step-grid">
<article className="how-step-card step-jellyseerr">
<div className="step-badge">1</div>
<h3>Request sent</h3>
<p className="step-note">Jellyseerr holds your request and approval.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Add to library queue (if it was approved but never added)</li>
</ul>
</article>
<article className="how-step-card step-arr">
<div className="step-badge">2</div>
<h3>Added to the library list</h3>
<p className="step-note">Sonarr/Radarr decide what quality to get.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Search for releases (see options)</li>
<li>Search and auto-download (let it pick for you)</li>
</ul>
</article>
<article className="how-step-card step-prowlarr">
<div className="step-badge">3</div>
<h3>Searching for sources</h3>
<p className="step-note">Prowlarr checks your torrent providers.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Search for releases (show a list to choose)</li>
</ul>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">4</div>
<h3>Downloading the file</h3>
<p className="step-note">qBittorrent downloads the selected match.</p>
<div className="step-fix-title">Fixes you can try</div>
<ul className="step-fix-list">
<li>Resume download (only if it already exists there)</li>
</ul>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">5</div>
<h3>Ready to watch</h3>
<p className="step-note">Jellyfin shows it in your library.</p>
<div className="step-fix-title">What to do next</div>
<ul className="step-fix-list">
<li>Open in Jellyfin (watch it)</li>
</ul>
</article>
</div>
</section>
<section className="how-callout">
<h2>Why Magent sometimes says waiting</h2>
<h2>Why Magent sometimes says &quot;waiting&quot;</h2>
<p>
If the search helper cannot find a match yet, Magent will say there is nothing to grab.
That does not mean it is broken. It usually means the release is not available yet.

View File

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

View File

@@ -86,9 +86,6 @@ export default function LoginPage() {
Sign in with Magent account
</button>
</form>
<div className="status-banner">
Have an invite? <a href="/register">Create an account</a>
</div>
</main>
)
}

View File

@@ -14,6 +14,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 +31,8 @@ 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 submit = (event: React.FormEvent) => {
event.preventDefault()
@@ -42,6 +45,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')
@@ -94,6 +152,7 @@ export default function HomePage() {
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
)
@@ -179,6 +238,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 +280,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 +321,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 +373,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

@@ -10,25 +10,68 @@ type ProfileInfo = {
auth_provider: string
}
type ContactInfo = {
email?: string | null
discord?: string | null
telegram?: string | null
matrix?: string | null
type ProfileStats = {
total: number
ready: number
pending: number
in_progress: number
declined: number
working: number
partial: number
approved: number
last_request_at?: string | null
share: number
global_total: number
most_active_user?: { username: string; total: number } | null
}
type ActivityEntry = {
ip: string
user_agent: string
first_seen_at: string
last_seen_at: string
hit_count: number
}
type ProfileActivity = {
last_ip?: string | null
last_user_agent?: string | null
last_seen_at?: string | null
device_count: number
recent: ActivityEntry[]
}
type ProfileResponse = {
user: ProfileInfo
stats: ProfileStats
activity: ProfileActivity
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const parseBrowser = (agent?: string | null) => {
if (!agent) return 'Unknown'
const value = agent.toLowerCase()
if (value.includes('edg/')) return 'Edge'
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
if (value.includes('firefox/')) return 'Firefox'
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
return 'Unknown'
}
export default function ProfilePage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [contact, setContact] = useState<ContactInfo>({})
const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [contactStatus, setContactStatus] = useState<string | null>(null)
const [referrals, setReferrals] = useState<
{ code: string; uses_count?: number; max_uses?: number | null }[]
>([])
const [referralStatus, setReferralStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -39,35 +82,21 @@ export default function ProfilePage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const response = await authFetch(`${baseUrl}/auth/profile`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const data = await response.json()
const user = data?.user ?? {}
setProfile({
username: data?.username ?? 'Unknown',
role: data?.role ?? 'user',
auth_provider: data?.auth_provider ?? 'local',
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
})
const contactResponse = await authFetch(`${baseUrl}/auth/contact`)
if (contactResponse.ok) {
const contactData = await contactResponse.json()
setContact({
email: contactData?.contact?.email ?? '',
discord: contactData?.contact?.discord ?? '',
telegram: contactData?.contact?.telegram ?? '',
matrix: contactData?.contact?.matrix ?? '',
})
}
const referralResponse = await authFetch(`${baseUrl}/auth/referrals`)
if (referralResponse.ok) {
const referralData = await referralResponse.json()
if (Array.isArray(referralData?.invites)) {
setReferrals(referralData.invites)
}
}
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
@@ -108,55 +137,6 @@ export default function ProfilePage() {
}
}
const submitContact = async (event: React.FormEvent) => {
event.preventDefault()
setContactStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: contact.email,
discord: contact.discord,
telegram: contact.telegram,
matrix: contact.matrix,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setContactStatus('Contact details saved.')
} catch (err) {
console.error(err)
setContactStatus('Could not update contact details.')
}
}
const createReferral = async () => {
setReferralStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/referrals`, { method: 'POST' })
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Could not create referral invite')
}
const data = await response.json()
if (data?.code) {
setReferrals((current) => [
{ code: data.code, uses_count: 0, max_uses: 1 },
...current,
])
}
setReferralStatus('Referral invite created.')
} catch (err) {
console.error(err)
setReferralStatus('Could not create a referral invite.')
}
}
if (loading) {
return <main className="card">Loading profile...</main>
}
@@ -170,6 +150,78 @@ export default function ProfilePage() {
{profile.auth_provider}.
</div>
)}
<div className="profile-grid">
<section className="profile-section">
<h2>Account stats</h2>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">Requests submitted</div>
<div className="stat-value">{stats?.total ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Ready to watch</div>
<div className="stat-value">{stats?.ready ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">In progress</div>
<div className="stat-value">{stats?.in_progress ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Pending approval</div>
<div className="stat-value">{stats?.pending ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Declined</div>
<div className="stat-value">{stats?.declined ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Last request</div>
<div className="stat-value stat-value--small">
{formatDate(stats?.last_request_at)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Share of all requests</div>
<div className="stat-value">
{stats?.global_total
? `${Math.round((stats.share || 0) * 1000) / 10}%`
: '0%'}
</div>
</div>
{profile?.role === 'admin' ? (
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
</div>
</div>
) : null}
</div>
</section>
<section className="profile-section">
<h2>Connection history</h2>
<div className="status-banner">
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
</div>
<div className="connection-list">
{(activity?.recent ?? []).map((entry, index) => (
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
<div>
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
<div className="meta">IP: {entry.ip}</div>
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
</div>
<div className="connection-count">{entry.hit_count} visits</div>
</div>
))}
{activity && activity.recent.length === 0 ? (
<div className="status-banner">No connection history yet.</div>
) : null}
</div>
</section>
</div>
{profile?.auth_provider !== 'local' ? (
<div className="status-banner">
Password changes are only available for local Magent accounts.
@@ -200,82 +252,6 @@ export default function ProfilePage() {
</div>
</form>
)}
<form onSubmit={submitContact} className="auth-form">
<h2>Contact details</h2>
<label>
Email address
<input
type="email"
value={contact.email || ''}
onChange={(event) => setContact((current) => ({ ...current, email: event.target.value }))}
autoComplete="email"
/>
</label>
<label>
Discord handle
<input
type="text"
value={contact.discord || ''}
onChange={(event) =>
setContact((current) => ({ ...current, discord: event.target.value }))
}
/>
</label>
<label>
Telegram ID
<input
type="text"
value={contact.telegram || ''}
onChange={(event) =>
setContact((current) => ({ ...current, telegram: event.target.value }))
}
/>
</label>
<label>
Matrix ID
<input
type="text"
value={contact.matrix || ''}
onChange={(event) =>
setContact((current) => ({ ...current, matrix: event.target.value }))
}
/>
</label>
{contactStatus && <div className="status-banner">{contactStatus}</div>}
<div className="auth-actions">
<button type="submit">Save contact details</button>
</div>
</form>
<section className="summary-card">
<h2>Referral invites</h2>
<p className="meta">
Share a referral invite with friends or family. Each invite has limited uses.
</p>
{referralStatus && <div className="status-banner">{referralStatus}</div>}
<div className="auth-actions">
<button type="button" onClick={createReferral}>
Create referral invite
</button>
</div>
{referrals.length === 0 ? (
<div className="meta">No referral invites yet.</div>
) : (
<div className="cache-table">
<div className="cache-row cache-head">
<span>Code</span>
<span>Uses</span>
</div>
{referrals.map((invite) => (
<div key={invite.code} className="cache-row">
<span>{invite.code}</span>
<span>
{invite.uses_count ?? 0}/{invite.max_uses ?? '∞'}
</span>
</div>
))}
</div>
)}
</section>
</main>
)
}

View File

@@ -1,194 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase, setToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo'
type SignupConfig = {
invites_enabled: boolean
captcha_provider: string
hcaptcha_site_key?: string | null
recaptcha_site_key?: string | null
turnstile_site_key?: string | null
}
export default function RegisterPage() {
const router = useRouter()
const [config, setConfig] = useState<SignupConfig | null>(null)
const [inviteCode, setInviteCode] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [discord, setDiscord] = useState('')
const [telegram, setTelegram] = useState('')
const [matrix, setMatrix] = useState('')
const [captchaToken, setCaptchaToken] = useState('')
const [status, setStatus] = useState<string | null>(null)
useEffect(() => {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/signup/config`)
if (!response.ok) {
throw new Error('Signup unavailable')
}
const data = await response.json()
setConfig(data)
} catch (err) {
console.error(err)
setStatus('Sign-up is not available right now.')
}
}
void load()
}, [])
useEffect(() => {
if (!config?.captcha_provider || config.captcha_provider === 'none') {
return
}
const provider = config.captcha_provider
const script = document.createElement('script')
if (provider === 'hcaptcha') {
script.src = 'https://js.hcaptcha.com/1/api.js'
} else if (provider === 'recaptcha') {
script.src = 'https://www.google.com/recaptcha/api.js'
} else if (provider === 'turnstile') {
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
}
script.async = true
script.defer = true
document.body.appendChild(script)
;(window as any).magentCaptchaCallback = (token: string) => {
setCaptchaToken(token)
}
return () => {
document.body.removeChild(script)
}
}, [config?.captcha_provider])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_code: inviteCode,
username,
password,
contact: { email, discord, telegram, matrix },
captcha_token: captchaToken,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Registration failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
}
router.push('/')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not complete sign-up.'
setStatus(message)
}
}
const captchaProvider = config?.captcha_provider || 'none'
const captchaKey =
captchaProvider === 'hcaptcha'
? config?.hcaptcha_site_key
: captchaProvider === 'recaptcha'
? config?.recaptcha_site_key
: config?.turnstile_site_key
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create your account</h1>
{!config?.invites_enabled ? (
<div className="status-banner">Sign-ups are currently closed.</div>
) : (
<form onSubmit={submit} className="auth-form">
<label>
Invite code
<input
type="text"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
required
/>
</label>
<label>
Username
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<label>
Email address (optional)
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</label>
<label>
Discord handle (optional)
<input
type="text"
value={discord}
onChange={(event) => setDiscord(event.target.value)}
/>
</label>
<label>
Telegram ID (optional)
<input
type="text"
value={telegram}
onChange={(event) => setTelegram(event.target.value)}
/>
</label>
<label>
Matrix ID (optional)
<input type="text" value={matrix} onChange={(event) => setMatrix(event.target.value)} />
</label>
{captchaProvider !== 'none' && captchaKey ? (
<div className="captcha-wrap">
{captchaProvider === 'hcaptcha' && (
<div className="h-captcha" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
)}
{captchaProvider === 'recaptcha' && (
<div className="g-recaptcha" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
)}
{captchaProvider === 'turnstile' && (
<div className="cf-turnstile" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
)}
</div>
) : null}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit">Create account</button>
</div>
</form>
)}
</main>
)
}

View File

@@ -1,5 +1,6 @@
'use client'
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
@@ -33,6 +34,7 @@ type ReleaseOption = {
seeders?: number
leechers?: number
protocol?: string
publishDate?: string
infoUrl?: string
downloadUrl?: string
}
@@ -123,7 +125,7 @@ const friendlyState = (value: string) => {
const map: Record<string, string> = {
REQUESTED: 'Waiting for approval',
APPROVED: 'Approved and queued',
NEEDS_ADD: 'Needs adding to the library',
NEEDS_ADD: 'Push to Sonarr/Radarr',
ADDED_TO_ARR: 'Added to the library queue',
SEARCHING: 'Searching for releases',
GRABBED: 'Download queued',
@@ -155,7 +157,7 @@ const friendlyTimelineStatus = (service: string, status: string) => {
}
if (service === 'Sonarr/Radarr') {
const map: Record<string, string> = {
missing: 'Not added yet',
missing: 'Push to Sonarr/Radarr',
added: 'Added to the library queue',
searching: 'Searching for releases',
available: 'Ready to watch',
@@ -250,7 +252,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
}
load()
}, [params.id])
}, [params.id, router])
if (loading) {
return (
@@ -274,9 +276,11 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const downloadHop = snapshot.timeline.find((hop) => hop.service === 'qBittorrent')
const downloadState = downloadHop?.details?.summary ?? downloadHop?.status ?? 'Unknown'
const jellyfinAvailable = Boolean(snapshot.raw?.jellyfin?.available)
const arrStageLabel =
snapshot.state === 'NEEDS_ADD' ? 'Push to Sonarr/Radarr' : 'Library queue'
const pipelineSteps = [
{ key: 'Jellyseerr', label: 'Jellyseerr' },
{ key: 'Sonarr/Radarr', label: 'Library queue' },
{ key: 'Sonarr/Radarr', label: arrStageLabel },
{ key: 'Prowlarr', label: 'Search' },
{ key: 'qBittorrent', label: 'Download' },
{ key: 'Jellyfin', label: 'Jellyfin' },
@@ -308,11 +312,14 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
<div className="request-header">
<div className="request-header-main">
{resolvedPoster && (
<img
<Image
className="request-poster"
src={resolvedPoster}
alt={`${snapshot.title} poster`}
loading="lazy"
width={90}
height={135}
sizes="90px"
unoptimized
/>
)}
<div>
@@ -590,7 +597,14 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
body: JSON.stringify({
guid: release.guid,
indexerId: release.indexerId,
indexerName: release.indexer,
downloadUrl: release.downloadUrl,
title: release.title,
size: release.size,
protocol: release.protocol,
publishDate: release.publishDate,
seeders: release.seeders,
leechers: release.leechers,
}),
}
)
@@ -603,8 +617,8 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setActionMessage('Download sent to Sonarr/Radarr.')
setModalMessage('Download sent to Sonarr/Radarr.')
setActionMessage('Download sent to qBittorrent.')
setModalMessage('Download sent to qBittorrent.')
} catch (error) {
console.error(error)
const message = 'Download failed. Check the logs.'

View File

@@ -17,25 +17,15 @@ const NAV_GROUPS = [
{
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request syncing' },
{ href: '/admin/artwork', label: 'Artwork' },
{ href: '/admin/cache', label: 'Cache' },
],
},
{
title: 'Accounts',
items: [
{ href: '/admin/invites', label: 'Invites' },
{ href: '/admin/password', label: 'Password rules' },
{ href: '/admin/captcha', label: 'Captcha' },
{ href: '/admin/smtp', label: 'Email (SMTP)' },
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/expiry', label: 'Account expiry' },
{ href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' },
],
},
{
title: 'Admin',
items: [
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,234 @@
'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
jellyseerr_user_id?: number | null
}
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 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 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()
setUser(data?.user ?? null)
setStats(normalizeStats(data?.stats))
setError(null)
} catch (err) {
console.error(err)
setError('Could not load user.')
} finally {
setLoading(false)
}
}
const toggleUserBlock = async (blocked: boolean) => {
if (!user) return
try {
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()
} catch (err) {
console.error(err)
setError('Could not update user access.')
}
}
const updateUserRole = async (role: string) => {
if (!user) return
try {
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()
} catch (err) {
console.error(err)
setError('Could not update user role.')
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
void loadUser()
}, [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>}
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<>
<div className="user-detail-card">
<div className="user-detail-header">
<div>
<strong>{user.username}</strong>
<div className="user-detail-meta">
<span className="meta">Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</span>
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.auth_provider || 'local'}</span>
<span className="meta">Last login: {formatDateTime(user.last_login_at)}</span>
</div>
</div>
<div className="user-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
</div>
</div>
<div className="user-detail-grid">
<div>
<span className="label">Total</span>
<span className="value">{stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">Approved</span>
<span className="value">{stats?.approved ?? 0}</span>
</div>
<div>
<span className="label">Working</span>
<span className="value">{stats?.working ?? 0}</span>
</div>
<div>
<span className="label">Partial</span>
<span className="value">{stats?.partial ?? 0}</span>
</div>
<div>
<span className="label">Declined</span>
<span className="value">{stats?.declined ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{stats?.in_progress ?? 0}</span>
</div>
<div>
<span className="label">Last request</span>
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
</div>
</div>
</div>
</>
)}
</section>
</AdminShell>
)
}

View File

@@ -2,15 +2,30 @@
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
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,19 +35,50 @@ 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 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 [selected, setSelected] = useState<string[]>([])
const [bulkAction, setBulkAction] = useState('block')
const [bulkRole, setBulkRole] = useState('user')
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = 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()
@@ -54,6 +100,8 @@ export default function UsersPage() {
authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked),
id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats),
}))
)
} else {
@@ -106,43 +154,59 @@ export default function UsersPage() {
}
}
const toggleSelect = (username: string, isChecked: boolean) => {
setSelected((current) =>
isChecked ? [...new Set([...current, username])] : current.filter((name) => name !== username)
)
}
const toggleSelectAll = (isChecked: boolean) => {
setSelected(isChecked ? users.map((user) => user.username) : [])
}
const runBulkAction = async () => {
if (selected.length === 0) {
setError('Select at least one user to run a bulk action.')
return
}
const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/bulk`, {
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: bulkAction,
role: bulkRole,
usernames: selected,
}),
})
if (!response.ok) {
throw new Error('Bulk update failed')
const text = await response.text()
throw new Error(text || 'Sync failed')
}
setSelected([])
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 run the bulk action.')
setJellyseerrSyncStatus('Could not sync Jellyseerr users.')
} finally {
setJellyseerrSyncBusy(false)
}
}
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/jellyseerr/users/resync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Resync failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not resync Jellyseerr users.')
} finally {
setJellyseerrResyncBusy(false)
}
}
useEffect(() => {
if (!getToken()) {
@@ -161,82 +225,67 @@ export default function UsersPage() {
title="Users"
subtitle="Manage who can use Magent."
actions={
<button type="button" onClick={loadUsers}>
Reload list
</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>
</>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{users.length > 0 && (
<div className="summary-card user-bulk-bar">
<label className="toggle">
<input
type="checkbox"
checked={selected.length === users.length}
onChange={(event) => toggleSelectAll(event.target.checked)}
/>
<span>Select all</span>
</label>
<div className="user-bulk-actions">
<select value={bulkAction} onChange={(event) => setBulkAction(event.target.value)}>
<option value="block">Block access</option>
<option value="unblock">Allow access</option>
<option value="role">Set role</option>
<option value="delete">Delete users</option>
</select>
{bulkAction === 'role' && (
<select value={bulkRole} onChange={(event) => setBulkRole(event.target.value)}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
)}
<button type="button" onClick={runBulkAction}>
Apply to {selected.length} selected
</button>
</div>
</div>
)}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
<div className="admin-grid">
<div className="user-grid">
{users.map((user) => (
<div key={user.username} className="summary-card user-card">
<div>
<label className="toggle">
<input
type="checkbox"
checked={selected.includes(user.username)}
onChange={(event) => toggleSelect(user.username, event.target.checked)}
/>
<span>Select</span>
</label>
<strong>{user.username}</strong>
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.authProvider || 'local'}</span>
<Link
key={user.username}
className="user-grid-card"
href={`/users/${user.id}`}
>
<div className="user-grid-header">
<div>
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
{user.isBlocked ? 'Blocked' : 'Active'}
</span>
</div>
<div className="user-grid-stats">
<div>
<span className="label">Total</span>
<span className="value">{user.stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{user.stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{user.stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{user.stats?.in_progress ?? 0}</span>
</div>
</div>
<div className="user-grid-footer">
<span className="meta">Login: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
<span className="meta">
Last request: {formatLastRequest(user.stats?.last_request_at)}
</span>
</div>
<div className="user-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) =>
updateUserRole(user.username, event.target.checked ? 'admin' : 'user')
}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(user.username, !user.isBlocked)}
>
{user.isBlocked ? 'Allow access' : 'Block access'}
</button>
</div>
</div>
</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",

26
scripts/build_release.ps1 Normal file
View File

@@ -0,0 +1,26 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
$now = Get-Date
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("M"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
Write-Host "Build number: $buildNumber"
git tag $buildNumber
git push origin $buildNumber
$backendImage = "rephl3xnz/magent-backend:$buildNumber"
$frontendImage = "rephl3xnz/magent-frontend:$buildNumber"
docker build -f backend/Dockerfile -t $backendImage --build-arg BUILD_NUMBER=$buildNumber .
docker build -f frontend/Dockerfile -t $frontendImage frontend
docker tag $backendImage rephl3xnz/magent-backend:latest
docker tag $frontendImage rephl3xnz/magent-frontend:latest
docker push $backendImage
docker push $frontendImage
docker push rephl3xnz/magent-backend:latest
docker push rephl3xnz/magent-frontend:latest