2 Commits

Author SHA1 Message Date
886783eae4 Expose backend on port 8001 2026-01-24 18:53:01 +13:00
d2ff2b3e41 Sync dev changes into release-1.0 2026-01-24 18:51:15 +13:00
42 changed files with 2451 additions and 1945 deletions

View File

@@ -1 +0,0 @@
261261420

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,28 +1,15 @@
from typing import Dict, Any
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from .db import get_user_by_username, upsert_user_activity
from .db import get_user_by_username
from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _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), request: Request = None) -> Dict[str, Any]:
def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
@@ -38,11 +25,6 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
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"],

View File

@@ -58,13 +58,3 @@ 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,6 +18,16 @@ 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}")

View File

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

View File

@@ -21,9 +21,6 @@ class RadarrClient(ApiClient):
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
@@ -46,12 +43,3 @@ 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,9 +18,6 @@ class SonarrClient(ApiClient):
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"seriesId": series_id})
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
@@ -53,12 +50,3 @@ 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

@@ -38,21 +38,6 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_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=None, validation_alias=AliasChoices("SITE_CHANGELOG")
)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
@@ -85,10 +70,6 @@ class Settings(BaseSettings):
sonarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
)
sonarr_qbittorrent_category: Optional[str] = Field(
default="sonarr",
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
)
radarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
@@ -102,10 +83,6 @@ class Settings(BaseSettings):
radarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
)
radarr_qbittorrent_category: Optional[str] = Field(
default="radarr",
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
@@ -128,6 +105,159 @@ 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

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .db import init_db, set_setting
from .db import init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
@@ -18,8 +18,9 @@ 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
@@ -41,15 +42,15 @@ async def health() -> dict:
@app.on_event("startup")
async def startup() -> None:
init_db()
if settings.site_build_number and settings.site_build_number.strip():
set_setting("site_build_number", settings.site_build_number.strip())
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())
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)
@@ -59,4 +60,3 @@ 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,4 +1,6 @@
from typing import Any, Dict, List
from datetime import datetime, timedelta, timezone
import secrets
import os
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -8,8 +10,17 @@ 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_request_cache_overview,
get_request_cache_missing_titles,
save_announcement,
get_settings_overrides,
get_user_by_username,
set_setting,
@@ -21,8 +32,6 @@ from ..db import (
clear_requests_cache,
clear_history,
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -30,10 +39,12 @@ 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
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__)
@@ -45,6 +56,17 @@ 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",
}
SETTING_KEYS: List[str] = [
@@ -59,12 +81,10 @@ 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",
@@ -79,11 +99,59 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
"site_build_number",
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
"site_changelog",
"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",
]
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
@@ -101,38 +169,6 @@ 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 []
@@ -250,6 +286,12 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
return {"status": "ok", "imported": imported}
@router.post("/jellyseerr/users/sync")
async def jellyseerr_users_sync() -> Dict[str, Any]:
imported = await sync_jellyseerr_users()
return {"status": "ok", "imported": imported}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
runtime = get_runtime_settings()
@@ -316,14 +358,7 @@ async def read_logs(lines: int = 200) -> Dict[str, Any]:
@router.get("/requests/cache")
async def requests_cache(limit: int = 50) -> Dict[str, Any]:
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}
return {"rows": get_request_cache_overview(limit)}
@router.post("/branding/logo")
@@ -414,3 +449,157 @@ 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,57 +1,59 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, status, Depends
from typing import Optional
from fastapi import APIRouter, HTTPException, status, Depends, Request
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,
set_jellyfin_auth_cache,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
get_global_request_leader,
get_global_request_total,
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,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password
from ..security import create_access_token
from ..auth import get_current_user
from ..services.captcha import verify_captcha
from ..services.notifications import send_notification
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))
def _normalize_username(value: str) -> str:
return value.strip().lower()
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)
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."
return None
@router.post("/login")
@@ -76,23 +78,14 @@ 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")
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(username, password)
response = await client.authenticate_by_name(form_data.username, form_data.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(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username)
create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin")
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")
try:
@@ -106,10 +99,9 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
except Exception:
pass
set_jellyfin_auth_cache(username, password)
token = create_access_token(username, "user")
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
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"}}
@router.post("/jellyseerr/login")
@@ -139,32 +131,6 @@ 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)
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":
@@ -176,12 +142,216 @@ 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")
if len(new_password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
error = _validate_password(new_password.strip())
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
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,10 +11,6 @@ router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
def _ensure_branding_dir() -> None:
@@ -45,18 +41,6 @@ def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_ensure_branding_dir()
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
try:
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
try:
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)
@@ -81,32 +65,24 @@ def _ensure_default_branding() -> None:
favicon.save(_FAVICON_PATH, format="ICO")
def _resolve_branding_paths() -> tuple[str, str]:
if _BRANDING_SOURCE == "data":
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
@router.get("/logo.png")
async def branding_logo() -> FileResponse:
logo_path, _ = _resolve_branding_paths()
if not os.path.exists(logo_path):
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
if not os.path.exists(_LOGO_PATH):
raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "no-store"}
return FileResponse(logo_path, media_type="image/png", headers=headers)
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
@router.get("/favicon.ico")
async def branding_favicon() -> FileResponse:
_, favicon_path = _resolve_branding_paths()
if not os.path.exists(favicon_path):
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
if not os.path.exists(_FAVICON_PATH):
raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "no-store"}
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:

View File

@@ -30,7 +30,6 @@ from ..db import (
get_request_cache_last_updated,
get_request_cache_count,
get_request_cache_payloads,
repair_request_cache_titles,
prune_duplicate_requests_cache,
upsert_request_cache,
get_setting,
@@ -69,7 +68,6 @@ _artwork_prefetch_state: Dict[str, Any] = {
"finished_at": None,
}
_artwork_prefetch_task: Optional[asyncio.Task] = None
_media_endpoint_supported: Optional[bool] = None
STATUS_LABELS = {
1: "Waiting for approval",
@@ -271,17 +269,10 @@ 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 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.")
except httpx.HTTPStatusError:
return None
_media_endpoint_supported = True
return details if isinstance(details, dict) else None
@@ -402,23 +393,14 @@ 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")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
if not payload.get("title") and payload.get("media_id"):
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")
@@ -446,7 +428,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") and payload.get("media_type"):
if not payload.get("title") and payload.get("tmdb_id"):
hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
client, payload.get("media_type"), payload.get("tmdb_id")
)
@@ -454,8 +436,6 @@ 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)
@@ -536,7 +516,6 @@ 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"):
@@ -544,11 +523,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("media_id")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
if not payload.get("title") and payload.get("media_id"):
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")
@@ -576,7 +551,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") and payload.get("media_type"):
if not payload.get("title") and payload.get("tmdb_id"):
hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
client, payload.get("media_type"), payload.get("tmdb_id")
)
@@ -584,8 +559,6 @@ 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)
@@ -815,14 +788,13 @@ 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 client.configured():
if not client.configured():
return
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)
return
_refresh_recent_cache_from_db()
@@ -970,10 +942,7 @@ 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":
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")
if mode != "always_js" and cached is not None:
logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode)
if _request_matches_user(cached, user.get("username", "")):
return
@@ -1030,148 +999,6 @@ 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 []
@@ -1254,11 +1081,9 @@ async def recent_requests(
) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
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:
@@ -1273,8 +1098,10 @@ async def recent_requests(
_refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, take, skip, since_iso)
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
allow_title_hydrate = False
allow_artwork_hydrate = allow_remote
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
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
@@ -1780,42 +1607,78 @@ 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")
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),
)
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
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,
)
return {"status": "ok", "response": {"qbittorrent": "queued"}}
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}
raise HTTPException(status_code=400, detail="Unknown request type")

View File

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

View File

@@ -2,17 +2,43 @@ 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",
"site_banner_enabled",
"jellyseerr_sync_users",
}

View File

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

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

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

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

View File

@@ -3,12 +3,10 @@ services:
build:
context: .
dockerfile: backend/Dockerfile
args:
BUILD_NUMBER: ${BUILD_NUMBER}
env_file:
- ./.env
ports:
- "8000:8000"
- "8001:8000"
volumes:
- ./data:/app/data
@@ -20,6 +18,6 @@ services:
- NEXT_PUBLIC_API_BASE=/api
- BACKEND_INTERNAL_URL=http://backend:8000
ports:
- "3000:3000"
- "3001:3000"
depends_on:
- backend

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
@@ -29,12 +29,9 @@ const SECTION_LABELS: Record<string, string> = {
qbittorrent: 'qBittorrent',
log: 'Activity log',
requests: 'Request syncing',
site: 'Site',
}
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr'])
const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.',
@@ -47,7 +44,6 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.',
log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -62,7 +58,6 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
cache: null,
logs: 'log',
maintenance: null,
site: 'site',
}
const labelFromKey = (key: string) =>
@@ -83,11 +78,6 @@ const labelFromKey = (key: string) =>
.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')
type SettingsPageProps = {
section: string
@@ -117,7 +107,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const loadSettings = useCallback(async () => {
const loadSettings = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`)
if (!response.ok) {
@@ -149,9 +139,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
setFormValues(initialValues)
setStatus(null)
}, [router])
}
const loadArtworkPrefetchStatus = useCallback(async () => {
const loadArtworkPrefetchStatus = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
@@ -163,9 +153,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} catch (err) {
console.error(err)
}
}, [])
}
const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
const loadOptions = async (service: 'sonarr' | 'radarr') => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/${service}/options`)
@@ -194,7 +185,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRadarrError('Could not load Radarr options.')
}
}
}, [])
}
useEffect(() => {
const load = async () => {
@@ -222,7 +213,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') {
void loadOptions('radarr')
}
}, [loadArtworkPrefetchStatus, loadOptions, loadSettings, router, section])
}, [router, section])
const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {}
@@ -280,12 +271,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.',
prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.',
@@ -297,15 +286,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_full_sync_time: 'Daily time to refresh the full request list.',
requests_cleanup_time: 'Daily time to trim old history.',
requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source:
'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.',
requests_data_source: 'Pick where Magent should read requests from.',
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 buildSelectOptions = (
@@ -489,7 +472,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [artworkPrefetch])
}, [artworkPrefetch?.status])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -499,7 +482,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(null)
}, 5000)
return () => clearTimeout(timer)
}, [artworkPrefetch])
}, [artworkPrefetch?.status])
useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') {
@@ -527,7 +510,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [requestsSync])
}, [requestsSync?.status])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
@@ -537,9 +520,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRequestsSync(null)
}, 5000)
return () => clearTimeout(timer)
}, [requestsSync])
}, [requestsSync?.status])
const loadLogs = useCallback(async () => {
const loadLogs = async () => {
setLogsStatus(null)
try {
const baseUrl = getApiBase()
@@ -564,7 +547,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
: 'Could not load logs.'
setLogsStatus(message)
}
}, [logsCount])
}
useEffect(() => {
if (!showLogs) {
@@ -575,7 +558,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [loadLogs, showLogs])
}, [logsCount, showLogs])
const loadCache = async () => {
setCacheStatus(null)
@@ -715,7 +698,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
.map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section">
<div className="section-header">
<h2>{sectionGroup.key === 'requests' ? 'Sync controls' : sectionGroup.title}</h2>
<h2>{sectionGroup.title}</h2>
{sectionGroup.key === 'sonarr' && (
<button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options
@@ -738,22 +721,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</button>
) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="sync-actions-block">
<div className="sync-actions">
<button type="button" onClick={syncRequests}>
Full refresh (all requests)
Full refresh
</button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Quick refresh (delta changes)
Quick refresh (new changes)
</button>
</div>
<div className="meta sync-note">
Full refresh reloads the entire list. Quick refresh only checks recent changes.
</div>
</div>
)}
</div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
{SECTION_DESCRIPTIONS[sectionGroup.key] && (
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
)}
{sectionGroup.key === 'sonarr' && sonarrError && (
@@ -1029,34 +1007,6 @@ 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'
@@ -1130,42 +1080,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
>
<option value="always_js">Always use Jellyseerr (slower)</option>
<option value="prefer_cache">
Use saved requests only (fastest)
</option>
<option value="prefer_cache">Use saved requests first (faster)</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">

View File

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

View File

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

View File

@@ -175,35 +175,30 @@ 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;
width: min(260px, 90vw);
background: rgba(14, 20, 32, 0.96);
min-width: 180px;
background: rgba(14, 20, 32, 0.95);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px;
@@ -211,50 +206,17 @@ body {
z-index: 20;
}
.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 {
.signed-in-dropdown a {
display: block;
padding: 8px 12px;
border-radius: 10px;
color: var(--ink);
text-decoration: none;
text-align: left;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.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;
.signed-in-dropdown a:hover {
background: rgba(255, 255, 255, 0.08);
}
.theme-toggle {
@@ -559,73 +521,6 @@ 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;
@@ -752,28 +647,12 @@ button span {
}
.user-card {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
display: flex;
justify-content: space-between;
align-items: center;
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;
@@ -1047,17 +926,6 @@ 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);
@@ -1100,49 +968,6 @@ 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;
@@ -1326,17 +1151,6 @@ 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;
}
@@ -1520,10 +1334,13 @@ button span {
@keyframes progress-indeterminate {
0% {
background-position: 200% 0;
transform: translateX(-50%);
}
50% {
transform: translateX(120%);
}
100% {
background-position: -200% 0;
transform: translateX(-50%);
}
}
@@ -1539,83 +1356,19 @@ 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 {
@@ -1653,21 +1406,6 @@ 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 */
@@ -1758,91 +1496,6 @@ 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;
@@ -1851,21 +1504,3 @@ 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,64 +75,8 @@ 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 &quot;waiting&quot;</h2>
<h2>Why Magent sometimes says waiting</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,7 +5,6 @@ import HeaderIdentity from './ui/HeaderIdentity'
import ThemeToggle from './ui/ThemeToggle'
import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo'
import SiteStatus from './ui/SiteStatus'
export const metadata = {
title: 'Magent',
@@ -29,14 +28,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</a>
</div>
<div className="header-right">
<ThemeToggle />
<HeaderIdentity />
<ThemeToggle />
</div>
<div className="header-nav">
<HeaderActions />
</div>
</header>
<SiteStatus />
{children}
</div>
</body>

View File

@@ -86,6 +86,9 @@ 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

@@ -10,68 +10,25 @@ type ProfileInfo = {
auth_provider: string
}
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'
type ContactInfo = {
email?: string | null
discord?: string | null
telegram?: string | null
matrix?: string | null
}
export default function ProfilePage() {
const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [contact, setContact] = useState<ContactInfo>({})
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(() => {
@@ -82,21 +39,35 @@ export default function ProfilePage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile`)
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
const data = await response.json()
const user = data?.user ?? {}
setProfile({
username: user?.username ?? 'Unknown',
role: user?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local',
username: data?.username ?? 'Unknown',
role: data?.role ?? 'user',
auth_provider: data?.auth_provider ?? 'local',
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
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)
}
}
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
@@ -137,6 +108,55 @@ 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>
}
@@ -150,78 +170,6 @@ 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.
@@ -252,6 +200,82 @@ 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

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

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,9 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(null)
const [jellyfinSyncBusy, setJellyfinSyncBusy] = useState(false)
const [selected, setSelected] = useState<string[]>([])
const [bulkAction, setBulkAction] = useState('block')
const [bulkRole, setBulkRole] = useState('user')
const loadUsers = async () => {
try {
@@ -105,29 +106,44 @@ export default function UsersPage() {
}
}
const syncJellyfinUsers = async () => {
setJellyfinSyncStatus(null)
setJellyfinSyncBusy(true)
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
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, {
const response = await authFetch(`${baseUrl}/admin/users/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: bulkAction,
role: bulkRole,
usernames: selected,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
throw new Error('Bulk update failed')
}
const data = await response.json()
setJellyfinSyncStatus(`Synced ${data?.imported ?? 0} Jellyfin users.`)
setSelected([])
await loadUsers()
} catch (err) {
console.error(err)
setJellyfinSyncStatus('Could not sync Jellyfin users.')
} finally {
setJellyfinSyncBusy(false)
setError('Could not run the bulk action.')
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
@@ -145,19 +161,42 @@ 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={syncJellyfinUsers} disabled={jellyfinSyncBusy}>
{jellyfinSyncBusy ? 'Syncing Jellyfin users...' : 'Sync Jellyfin users'}
</button>
</>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{jellyfinSyncStatus && <div className="status-banner">{jellyfinSyncStatus}</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>
)}
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
@@ -165,13 +204,19 @@ export default function UsersPage() {
{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>
<div className="user-meta">
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
</div>
</div>
<div className="user-actions">
<label className="toggle">
<input

View File

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