Compare commits

...

13 Commits

Author SHA1 Message Date
43be4830b0 Build 2502262257 2026-02-25 22:57:17 +13:00
1fe4a44eb5 feat: add Apprise sidecar user/admin notifications 2026-02-25 22:54:18 +13:00
d045dd0b07 Build 0202261541: allow FQDN service URLs 2026-02-02 15:43:08 +13:00
138069590b Build 3001262148: single container 2026-01-30 21:54:25 +13:00
8125b766c7 Build 2901262244: format changelog 2026-01-29 22:46:02 +13:00
d53e2917aa Build 2901262240: cache users 2026-01-29 22:42:00 +13:00
d7847652db Tidy full changelog 2026-01-29 22:13:04 +13:00
24ac54d606 Update full changelog 2026-01-29 22:08:17 +13:00
62f392ad37 Bake build number and changelog 2026-01-29 22:03:12 +13:00
e42ae8585d Hardcode build number in backend 2026-01-29 21:49:01 +13:00
06e0797722 release: 2901262102 2026-01-29 21:03:32 +13:00
914f478178 release: 2901262044 2026-01-29 20:45:20 +13:00
fb65d646f2 release: 2901262036 2026-01-29 20:38:37 +13:00
29 changed files with 2336 additions and 171 deletions

View File

@@ -1 +1 @@
271261539
2502262257

53
Dockerfile Normal file
View File

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

View File

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

View File

@@ -47,6 +47,7 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
"username": user["username"],
"role": user["role"],
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ from typing import Optional
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from .build_info import BUILD_NUMBER, CHANGELOG
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
@@ -38,9 +39,7 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
)
site_build_number: Optional[str] = Field(default=BUILD_NUMBER)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
@@ -50,8 +49,12 @@ class Settings(BaseSettings):
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_changelog: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
site_changelog: Optional[str] = Field(default=CHANGELOG)
apprise_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("APPRISE_URL", "APPRISE_BASE_URL")
)
apprise_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("APPRISE_API_KEY")
)
jellyseerr_base_url: Optional[str] = Field(

View File

@@ -145,11 +145,15 @@ def init_db() -> None:
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
auth_provider TEXT NOT NULL DEFAULT 'local',
jellyseerr_user_id INTEGER,
created_at TEXT NOT NULL,
last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0,
jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT
last_jellyfin_auth_at TEXT,
notify_enabled INTEGER NOT NULL DEFAULT 0,
notify_urls TEXT,
notify_updated_at TEXT
)
"""
)
@@ -173,6 +177,7 @@ def init_db() -> None:
year INTEGER,
requested_by TEXT,
requested_by_norm TEXT,
requested_by_id INTEGER,
created_at TEXT,
updated_at TEXT,
payload_json TEXT NOT NULL
@@ -258,6 +263,37 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"ALTER TABLE users ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 0"
)
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN notify_urls TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN notify_updated_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
ON requests_cache (requested_by_id)
"""
)
except sqlite3.OperationalError:
pass
_backfill_auth_providers()
ensure_admin_user()
@@ -361,31 +397,41 @@ def ensure_admin_user() -> None:
create_user(settings.admin_username, settings.admin_password, role="admin")
def create_user(username: str, password: str, role: str = "user", auth_provider: str = "local") -> None:
def create_user(
username: str,
password: str,
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
with _connect() as conn:
conn.execute(
"""
INSERT INTO users (username, password_hash, role, auth_provider, created_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(username, password_hash, role, auth_provider, created_at),
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
)
def create_user_if_missing(
username: str, password: str, role: str = "user", auth_provider: str = "local"
username: str,
password: str,
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
) -> bool:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
with _connect() as conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, created_at)
VALUES (?, ?, ?, ?, ?)
INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(username, password_hash, role, auth_provider, created_at),
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
)
return cursor.rowcount > 0
@@ -394,10 +440,10 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at,
is_blocked, jellyfin_password_hash, last_jellyfin_auth_at
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ?
WHERE username = ? COLLATE NOCASE
""",
(username,),
).fetchone()
@@ -409,19 +455,116 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"created_at": row[5],
"last_login_at": row[6],
"is_blocked": bool(row[7]),
"jellyfin_password_hash": row[8],
"last_jellyfin_auth_at": row[9],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"jellyfin_password_hash": row[9],
"last_jellyfin_auth_at": row[10],
}
def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"jellyfin_password_hash": row[9],
"last_jellyfin_auth_at": row[10],
}
def get_user_notification_settings(username: str) -> Dict[str, Any]:
if not username:
return {"enabled": False, "urls": []}
with _connect() as conn:
row = conn.execute(
"""
SELECT notify_enabled, notify_urls
FROM users
WHERE username = ? COLLATE NOCASE
""",
(username,),
).fetchone()
if not row:
return {"enabled": False, "urls": []}
enabled = bool(row[0])
urls_raw = row[1]
urls: list[str] = []
if isinstance(urls_raw, str) and urls_raw.strip():
try:
parsed = json.loads(urls_raw)
if isinstance(parsed, list):
urls = [str(item).strip() for item in parsed if str(item).strip()]
except json.JSONDecodeError:
urls = [urls_raw.strip()]
return {"enabled": enabled, "urls": urls}
def set_user_notification_settings(username: str, enabled: bool, urls: list[str]) -> None:
if not username:
return
urls_payload = json.dumps(urls, ensure_ascii=True)
updated_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE users
SET notify_enabled = ?, notify_urls = ?, notify_updated_at = ?
WHERE username = ? COLLATE NOCASE
""",
(1 if enabled else 0, urls_payload, updated_at, username),
)
def get_admin_notification_targets() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT username, notify_urls
FROM users
WHERE role = 'admin' AND notify_enabled = 1
ORDER BY username COLLATE NOCASE
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
username, urls_raw = row
urls: list[str] = []
if isinstance(urls_raw, str) and urls_raw.strip():
try:
parsed = json.loads(urls_raw)
if isinstance(parsed, list):
urls = [str(item).strip() for item in parsed if str(item).strip()]
except json.JSONDecodeError:
urls = [urls_raw.strip()]
if urls:
results.append({"username": username, "urls": urls})
return results
def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, role, auth_provider, created_at, last_login_at, is_blocked
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked
FROM users
ORDER BY username COLLATE NOCASE
"""
@@ -434,13 +577,33 @@ def get_all_users() -> list[Dict[str, Any]]:
"username": row[1],
"role": row[2],
"auth_provider": row[3],
"created_at": row[4],
"last_login_at": row[5],
"is_blocked": bool(row[6]),
"jellyseerr_user_id": row[4],
"created_at": row[5],
"last_login_at": row[6],
"is_blocked": bool(row[7]),
}
)
return results
def delete_non_admin_users() -> int:
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM users WHERE role != 'admin'
"""
)
return cursor.rowcount
def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET jellyseerr_user_id = ? WHERE username = ?
""",
(jellyseerr_user_id, username),
)
def set_last_login(username: str) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
@@ -503,7 +666,7 @@ def set_jellyfin_auth_cache(username: str, password: str) -> None:
"""
UPDATE users
SET jellyfin_password_hash = ?, last_jellyfin_auth_at = ?
WHERE username = ?
WHERE username = ? COLLATE NOCASE
""",
(password_hash, timestamp, username),
)
@@ -614,8 +777,8 @@ def get_user_activity_summary(username: str) -> Dict[str, Any]:
}
def get_user_request_stats(username_norm: str) -> Dict[str, Any]:
if not username_norm:
def get_user_request_stats(username_norm: str, requested_by_id: Optional[int] = None) -> Dict[str, Any]:
if requested_by_id is None:
return {
"total": 0,
"ready": 0,
@@ -632,26 +795,26 @@ def get_user_request_stats(username_norm: str) -> Dict[str, Any]:
"""
SELECT COUNT(*)
FROM requests_cache
WHERE requested_by_norm = ?
WHERE requested_by_id = ?
""",
(username_norm,),
(requested_by_id,),
).fetchone()
status_rows = conn.execute(
"""
SELECT status, COUNT(*)
FROM requests_cache
WHERE requested_by_norm = ?
WHERE requested_by_id = ?
GROUP BY status
""",
(username_norm,),
(requested_by_id,),
).fetchall()
last_row = conn.execute(
"""
SELECT MAX(created_at)
FROM requests_cache
WHERE requested_by_norm = ?
WHERE requested_by_id = ?
""",
(username_norm,),
(requested_by_id,),
).fetchone()
counts = {int(row[0]): int(row[1]) for row in status_rows if row[0] is not None}
pending = counts.get(1, 0)
@@ -706,6 +869,7 @@ def upsert_request_cache(
year: Optional[int],
requested_by: Optional[str],
requested_by_norm: Optional[str],
requested_by_id: Optional[int],
created_at: Optional[str],
updated_at: Optional[str],
payload_json: str,
@@ -749,11 +913,12 @@ def upsert_request_cache(
year,
requested_by,
requested_by_norm,
requested_by_id,
created_at,
updated_at,
payload_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO UPDATE SET
media_id = excluded.media_id,
media_type = excluded.media_type,
@@ -762,6 +927,7 @@ def upsert_request_cache(
year = excluded.year,
requested_by = excluded.requested_by,
requested_by_norm = excluded.requested_by_norm,
requested_by_id = excluded.requested_by_id,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
payload_json = excluded.payload_json
@@ -775,6 +941,7 @@ def upsert_request_cache(
normalized_year,
requested_by,
requested_by_norm,
requested_by_id,
created_at,
updated_at,
payload_json,
@@ -844,15 +1011,20 @@ def get_cached_requests(
limit: int,
offset: int,
requested_by_norm: Optional[str] = None,
requested_by_id: Optional[int] = None,
since_iso: Optional[str] = None,
) -> list[Dict[str, Any]]:
query = """
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, payload_json
SELECT request_id, media_id, media_type, status, title, year, requested_by,
requested_by_norm, requested_by_id, created_at, payload_json
FROM requests_cache
"""
params: list[Any] = []
conditions = []
if requested_by_norm:
if requested_by_id is not None:
conditions.append("requested_by_id = ?")
params.append(requested_by_id)
elif requested_by_norm:
conditions.append("requested_by_norm = ?")
params.append(requested_by_norm)
if since_iso:
@@ -865,17 +1037,19 @@ def get_cached_requests(
with _connect() as conn:
rows = conn.execute(query, tuple(params)).fetchall()
logger.debug(
"requests_cache list: count=%s requested_by_norm=%s since_iso=%s",
"requests_cache list: count=%s requested_by_norm=%s requested_by_id=%s since_iso=%s",
len(rows),
requested_by_norm,
requested_by_id,
since_iso,
)
results: list[Dict[str, Any]] = []
for row in rows:
title = row[4]
year = row[5]
if (not title or not year) and row[8]:
derived_title, derived_year = _extract_title_year_from_payload(row[8])
payload_json = row[10]
if (not title or not year) and payload_json:
derived_title, derived_year = _extract_title_year_from_payload(payload_json)
if not title:
title = derived_title
if not year:
@@ -889,18 +1063,47 @@ def get_cached_requests(
"title": title,
"year": year,
"requested_by": row[6],
"created_at": row[7],
"requested_by_norm": row[7],
"requested_by_id": row[8],
"created_at": row[9],
}
)
return results
def get_cached_requests_count(
requested_by_norm: Optional[str] = None,
requested_by_id: Optional[int] = None,
since_iso: Optional[str] = None,
) -> int:
query = "SELECT COUNT(*) FROM requests_cache"
params: list[Any] = []
conditions = []
if requested_by_id is not None:
conditions.append("requested_by_id = ?")
params.append(requested_by_id)
elif requested_by_norm:
conditions.append("requested_by_norm = ?")
params.append(requested_by_norm)
if since_iso:
conditions.append("created_at >= ?")
params.append(since_iso)
if conditions:
query += " WHERE " + " AND ".join(conditions)
with _connect() as conn:
row = conn.execute(query, tuple(params)).fetchone()
if not row:
return 0
return int(row[0])
def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 200))
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, updated_at, payload_json
SELECT request_id, media_id, media_type, status, title, year, requested_by,
requested_by_norm, requested_by_id, created_at, updated_at, payload_json
FROM requests_cache
ORDER BY updated_at DESC, request_id DESC
LIMIT ?
@@ -910,8 +1113,8 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
results: list[Dict[str, Any]] = []
for row in rows:
title = row[4]
if not title and row[9]:
derived_title, _ = _extract_title_year_from_payload(row[9])
if not title and row[11]:
derived_title, _ = _extract_title_year_from_payload(row[11])
title = derived_title or row[4]
results.append(
{
@@ -922,8 +1125,10 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
"title": title,
"year": row[5],
"requested_by": row[6],
"created_at": row[7],
"updated_at": row[8],
"requested_by_norm": row[7],
"requested_by_id": row[8],
"created_at": row[9],
"updated_at": row[10],
}
)
return results
@@ -1202,7 +1407,8 @@ def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, media_id, media_type, status, title, year, requested_by, requested_by_norm, created_at
SELECT request_id, media_id, media_type, status, title, year, requested_by,
requested_by_norm, requested_by_id, created_at
FROM requests_cache
WHERE created_at >= ?
ORDER BY created_at DESC, request_id DESC
@@ -1221,14 +1427,17 @@ def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
"year": row[5],
"requested_by": row[6],
"requested_by_norm": row[7],
"created_at": row[8],
"requested_by_id": row[8],
"created_at": row[9],
}
)
return results
def get_cached_request_by_media_id(
media_id: int, requested_by_norm: Optional[str] = None
media_id: int,
requested_by_norm: Optional[str] = None,
requested_by_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
query = """
SELECT request_id, status
@@ -1236,7 +1445,10 @@ def get_cached_request_by_media_id(
WHERE media_id = ?
"""
params: list[Any] = [media_id]
if requested_by_norm:
if requested_by_id is not None:
query += " AND requested_by_id = ?"
params.append(requested_by_id)
elif requested_by_norm:
query += " AND requested_by_norm = ?"
params.append(requested_by_norm)
query += " ORDER BY created_at DESC, request_id DESC LIMIT 1"

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,
@@ -41,8 +41,6 @@ async def health() -> dict:
@app.on_event("startup")
async def startup() -> None:
init_db()
if settings.site_build_number and settings.site_build_number.strip():
set_setting("site_build_number", settings.site_build_number.strip())
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync())

View File

@@ -1,18 +1,27 @@
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import ipaddress
import os
from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
from ..auth import require_admin
from ..auth import require_admin, get_current_user
from ..config import settings as env_settings
from ..db import (
delete_setting,
get_all_users,
get_cached_requests,
get_cached_requests_count,
get_request_cache_overview,
get_request_cache_missing_titles,
get_request_cache_stats,
get_settings_overrides,
get_user_by_id,
get_user_by_username,
get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
set_setting,
set_user_blocked,
set_user_password,
@@ -24,6 +33,8 @@ from ..db import (
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
get_user_notification_settings,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -31,6 +42,15 @@ 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.user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyfin_users,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
save_jellyseerr_users_cache,
)
from ..services.notifications import send_apprise_notification
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
@@ -46,6 +66,18 @@ SENSITIVE_KEYS = {
"radarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
"apprise_api_key",
}
URL_SETTING_KEYS = {
"jellyseerr_base_url",
"jellyfin_base_url",
"jellyfin_public_url",
"sonarr_base_url",
"radarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
"apprise_base_url",
}
SETTING_KEYS: List[str] = [
@@ -73,6 +105,8 @@ SETTING_KEYS: List[str] = [
"qbittorrent_password",
"log_level",
"log_file",
"apprise_base_url",
"apprise_api_key",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
@@ -80,13 +114,60 @@ 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",
]
def _normalize_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
def _is_ip_host(host: str) -> bool:
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
def _normalize_service_url(value: str) -> str:
raw = value.strip()
if not raw:
raise ValueError("URL cannot be empty.")
candidate = raw
if "://" not in candidate:
authority = candidate.split("/", 1)[0].strip()
if authority.startswith("["):
closing = authority.find("]")
host = authority[1:closing] if closing > 0 else authority.strip("[]")
else:
host = authority.split(":", 1)[0]
host = host.strip().lower()
default_scheme = "http" if host in {"localhost"} or _is_ip_host(host) or "." not in host else "https"
candidate = f"{default_scheme}://{candidate}"
parsed = urlparse(candidate)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if not parsed.netloc:
raise ValueError("URL must include a host.")
if parsed.query or parsed.fragment:
raise ValueError("URL must not include query params or fragments.")
if not parsed.hostname:
raise ValueError("URL must include a valid host.")
normalized_path = parsed.path.rstrip("/")
normalized = parsed._replace(path=normalized_path, params="", query="", fragment="")
result = urlunparse(normalized).rstrip("/")
if not result:
raise ValueError("URL is invalid.")
return result
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
if not isinstance(folders, list):
return []
@@ -183,7 +264,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
delete_setting(key)
updates += 1
continue
set_setting(key, str(value))
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1
if key in {"log_level", "log_file"}:
touched_logging = True
@@ -223,6 +311,9 @@ async def radarr_options() -> Dict[str, Any]:
@router.get("/jellyfin/users")
async def jellyfin_users() -> Dict[str, Any]:
cached = get_cached_jellyfin_users()
if cached is not None:
return {"users": cached}
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
@@ -230,18 +321,7 @@ async def jellyfin_users() -> Dict[str, Any]:
users = await client.get_users()
if not isinstance(users, list):
return {"users": []}
results = []
for user in users:
if not isinstance(user, dict):
continue
results.append(
{
"id": user.get("Id"),
"name": user.get("Name"),
"hasPassword": user.get("HasPassword"),
"lastLoginDate": user.get("LastLoginDate"),
}
)
results = save_jellyfin_users_cache(users)
return {"users": results}
@@ -250,6 +330,106 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
imported = await sync_jellyfin_users()
return {"status": "ok", "imported": imported}
async def _fetch_all_jellyseerr_users(
client: JellyseerrClient, use_cache: bool = True
) -> List[Dict[str, Any]]:
if use_cache:
cached = get_cached_jellyseerr_users()
if cached is not None:
return cached
users: List[Dict[str, Any]] = []
take = 100
skip = 0
while True:
payload = await client.get_users(take=take, skip=skip)
if not payload:
break
if isinstance(payload, list):
batch = payload
elif isinstance(payload, dict):
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
else:
batch = None
if not isinstance(batch, list) or not batch:
break
users.extend([user for user in batch if isinstance(user, dict)])
if len(batch) < take:
break
skip += take
if users:
return save_jellyseerr_users_cache(users)
return users
@router.post("/jellyseerr/users/sync")
async def jellyseerr_users_sync() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
if not jellyseerr_users:
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
updated = 0
skipped = 0
users = get_all_users()
for user in users:
if user.get("jellyseerr_user_id") is not None:
skipped += 1
continue
username = user.get("username") or ""
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
updated += 1
else:
skipped += 1
return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
for key in ("email", "username", "displayName", "name"):
value = user.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
@router.post("/jellyseerr/users/resync")
async def jellyseerr_users_resync() -> Dict[str, Any]:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False)
if not jellyseerr_users:
return {"status": "ok", "imported": 0, "cleared": 0}
cleared = delete_non_admin_users()
imported = 0
for user in jellyseerr_users:
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
username = _pick_jellyseerr_username(user)
if not username:
continue
created = create_user_if_missing(
username,
"jellyseerr-user",
role="user",
auth_provider="jellyseerr",
jellyseerr_user_id=user_id,
)
if created:
imported += 1
else:
set_user_jellyseerr_id(username, user_id)
return {"status": "ok", "imported": imported, "cleared": cleared}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
@@ -348,6 +528,40 @@ async def requests_cache(limit: int = 50) -> Dict[str, Any]:
return {"rows": rows}
@router.get("/requests/all")
async def requests_all(
take: int = 50,
skip: int = 0,
days: Optional[int] = None,
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
take = max(1, min(int(take or 50), 200))
skip = max(0, int(skip or 0))
since_iso = None
if days is not None and int(days) > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso)
total = get_cached_requests_count(since_iso=since_iso)
results = []
for row in rows:
status = row.get("status")
results.append(
{
"id": row.get("request_id"),
"title": row.get("title"),
"year": row.get("year"),
"type": row.get("media_type"),
"status": status,
"statusLabel": requests_router._status_label(status),
"requestedBy": row.get("requested_by"),
"createdAt": row.get("created_at"),
}
)
return {"results": results, "total": total, "take": take, "skip": skip}
@router.post("/branding/logo")
async def upload_branding_logo(file: UploadFile = File(...)) -> Dict[str, Any]:
return await save_branding_image(file)
@@ -397,9 +611,105 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users")
async def list_users() -> Dict[str, Any]:
users = get_all_users()
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
return {"users": users}
@router.get("/notifications/users")
async def list_notification_users() -> Dict[str, Any]:
users = get_all_users()
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
settings = get_user_notification_settings(username)
results.append(
{
"username": username,
"role": user.get("role"),
"authProvider": user.get("auth_provider"),
"jellyseerrUserId": user.get("jellyseerr_user_id"),
"isBlocked": bool(user.get("is_blocked")),
"notifyEnabled": bool(settings.get("enabled")),
"notifyCount": len(settings.get("urls") or []),
}
)
return {"users": results}
@router.post("/notifications/send")
async def send_notifications(payload: Dict[str, Any]) -> Dict[str, Any]:
usernames = payload.get("usernames")
message = payload.get("message")
title = payload.get("title") or "Magent admin message"
if not isinstance(usernames, list) or not usernames:
raise HTTPException(status_code=400, detail="Select at least one user.")
if not isinstance(message, str) or not message.strip():
raise HTTPException(status_code=400, detail="Message cannot be empty.")
results: list[Dict[str, Any]] = []
counts = {"sent": 0, "skipped": 0, "failed": 0}
for raw_username in usernames:
if not isinstance(raw_username, str) or not raw_username.strip():
results.append({"username": str(raw_username), "status": "invalid"})
counts["failed"] += 1
continue
username = raw_username.strip()
user = get_user_by_username(username)
if not user:
results.append({"username": username, "status": "not_found"})
counts["failed"] += 1
continue
if user.get("is_blocked"):
results.append({"username": username, "status": "blocked"})
counts["skipped"] += 1
continue
settings = get_user_notification_settings(username)
if not settings.get("enabled"):
results.append({"username": username, "status": "disabled"})
counts["skipped"] += 1
continue
urls = settings.get("urls") or []
if not urls:
results.append({"username": username, "status": "no_targets"})
counts["skipped"] += 1
continue
ok = send_apprise_notification(urls, str(title).strip() or "Magent admin message", message.strip())
if ok:
results.append({"username": username, "status": "sent"})
counts["sent"] += 1
else:
results.append({"username": username, "status": "failed"})
counts["failed"] += 1
return {"results": results, **counts}
@router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
results.append({**user, "stats": stats})
return {"users": results}
@router.get("/users/{username}")
async def get_user_summary(username: str) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
@router.get("/users/id/{user_id}")
async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
user = get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
@router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]:

View File

@@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone
import asyncio
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
@@ -10,23 +11,40 @@ from ..db import (
get_user_by_username,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
get_global_request_leader,
get_global_request_total,
get_user_notification_settings,
set_user_notification_settings,
)
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 ..auth import get_current_user
from ..services.user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
from ..services.notifications import (
notify_admins_new_signup,
send_apprise_notification,
validate_apprise_urls,
)
router = APIRouter(prefix="/auth", tags=["auth"])
def _normalize_username(value: str) -> str:
return value.strip().lower()
normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
@@ -53,6 +71,22 @@ def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
return False
return _is_recent_jellyfin_auth(last_auth_at)
def _extract_jellyseerr_user_id(response: dict) -> int | None:
if not isinstance(response, dict):
return None
candidate = response
if isinstance(response.get("user"), dict):
candidate = response.get("user")
for key in ("id", "userId", "Id"):
value = candidate.get(key) if isinstance(candidate, dict) else None
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
@@ -76,6 +110,8 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
username = form_data.username
password = form_data.password
user = get_user_by_username(username)
@@ -91,22 +127,31 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
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")
created = create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if created:
asyncio.create_task(
asyncio.to_thread(notify_admins_new_signup, username, "jellyfin")
)
try:
users = await client.get_users()
if isinstance(users, list):
for user in users:
if not isinstance(user, dict):
save_jellyfin_users_cache(users)
for jellyfin_user in users:
if not isinstance(jellyfin_user, dict):
continue
name = user.get("Name")
name = jellyfin_user.get("Name")
if isinstance(name, str) and name:
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
except Exception:
pass
set_jellyfin_auth_cache(username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map:
matched_id = match_jellyseerr_user_id(username, candidate_map)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
token = create_access_token(username, "user")
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@@ -125,10 +170,23 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
create_user_if_missing(form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
created = create_user_if_missing(
form_data.username,
"jellyseerr-user",
role="user",
auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id,
)
user = get_user_by_username(form_data.username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
if created:
asyncio.create_task(
asyncio.to_thread(notify_admins_new_signup, form_data.username, "jellyseerr")
)
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"}}
@@ -143,7 +201,7 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm)
stats = get_user_request_stats(username_norm, current_user.get("jellyseerr_user_id"))
global_total = get_global_request_total()
share = (stats.get("total", 0) / global_total) if global_total else 0
activity_summary = get_user_activity_summary(username) if username else {}
@@ -165,6 +223,48 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
}
@router.get("/notifications")
async def get_notifications(current_user: dict = Depends(get_current_user)) -> dict:
settings = get_user_notification_settings(current_user.get("username") or "")
return settings
@router.put("/notifications")
async def update_notifications(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")
enabled = bool(payload.get("enabled"))
urls_raw = payload.get("urls") or []
if isinstance(urls_raw, str):
urls = [line.strip() for line in urls_raw.splitlines() if line.strip()]
elif isinstance(urls_raw, list):
urls = [str(item).strip() for item in urls_raw if str(item).strip()]
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid URLs")
try:
validated = validate_apprise_urls(urls)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
set_user_notification_settings(current_user.get("username") or "", enabled, validated)
return {"status": "ok", "enabled": enabled, "urls": validated}
@router.post("/notifications/test")
async def test_notifications(current_user: dict = Depends(get_current_user)) -> dict:
settings = get_user_notification_settings(current_user.get("username") or "")
if not settings.get("enabled"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Notifications are disabled")
urls = settings.get("urls") or []
if not urls:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No Apprise URLs configured")
title = "Magent notification test"
body = f"Hello {current_user.get('username')}, your Apprise notifications are working."
sent = await asyncio.to_thread(send_apprise_notification, urls, title, body)
if not sent:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Notification failed")
return {"status": "ok"}
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if current_user.get("auth_provider") != "local":

View File

@@ -113,6 +113,10 @@ def _normalize_username(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip().lower()
if not normalized:
return None
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized if normalized else None
@@ -164,6 +168,21 @@ def _normalize_requested_by(request_data: Any) -> Optional[str]:
normalized = normalized.split("@", 1)[0]
return normalized
def _extract_requested_by_id(request_data: Any) -> Optional[int]:
if not isinstance(request_data, dict):
return None
requested_by = request_data.get("requestedBy") or request_data.get("requestedByUser")
if isinstance(requested_by, dict):
for key in ("id", "userId", "Id"):
value = requested_by.get(key)
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str:
response = exc.response
@@ -206,6 +225,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
updated_at = item.get("updatedAt") or created_at
requested_by = _request_display_name(item)
requested_by_norm = _normalize_requested_by(item)
requested_by_id = _extract_requested_by_id(item)
return {
"request_id": item.get("id"),
"media_id": media_id,
@@ -216,6 +236,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
"year": year,
"requested_by": requested_by,
"requested_by_norm": requested_by_norm,
"requested_by_id": requested_by_id,
"created_at": created_at,
"updated_at": updated_at,
}
@@ -577,6 +598,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
@@ -714,6 +736,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
@@ -843,6 +866,7 @@ async def _prefetch_artwork_cache(
year=parsed.get("year"),
requested_by=parsed.get("requested_by"),
requested_by_norm=parsed.get("requested_by_norm"),
requested_by_id=parsed.get("requested_by_id"),
created_at=parsed.get("created_at"),
updated_at=parsed.get("updated_at"),
payload_json=json.dumps(payload, ensure_ascii=True),
@@ -985,19 +1009,39 @@ def _recent_cache_stale() -> bool:
return (datetime.now(timezone.utc) - parsed).total_seconds() > RECENT_CACHE_TTL_SECONDS
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _get_recent_from_cache(
requested_by_norm: Optional[str],
requested_by_id: Optional[int],
limit: int,
offset: int,
since_iso: Optional[str],
) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or []
results = []
since_dt = _parse_iso_datetime(since_iso)
for item in items:
if requested_by_norm and item.get("requested_by_norm") != requested_by_norm:
continue
if since_iso and item.get("created_at") and item["created_at"] < since_iso:
if requested_by_id is not None:
if item.get("requested_by_id") != requested_by_id:
continue
elif requested_by_norm and item.get("requested_by_norm") != requested_by_norm:
continue
if since_dt:
candidate = item.get("created_at") or item.get("updated_at")
item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt:
continue
results.append(item)
return results[offset : offset + limit]
@@ -1455,13 +1499,15 @@ async def recent_requests(
raise HTTPException(status_code=502, detail=str(exc)) from exc
username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
requested_by_id = None if user.get("role") == "admin" else requested_by_id
since_iso = None
if days > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
if _recent_cache_stale():
_refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, take, skip, since_iso)
rows = _get_recent_from_cache(requested_by, requested_by_id, take, skip, since_iso)
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
allow_title_hydrate = False
allow_artwork_hydrate = client.configured()
@@ -1537,6 +1583,7 @@ async def recent_requests(
year=year or payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True),
@@ -1584,6 +1631,7 @@ async def recent_requests(
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True),
@@ -1602,6 +1650,7 @@ async def recent_requests(
"status": status,
"statusLabel": status_label,
"mediaId": row.get("media_id"),
"createdAt": row.get("created_at") or row.get("updated_at"),
"artwork": {
"poster_url": _artwork_url(poster_path, "w185", cache_mode),
"backdrop_url": _artwork_url(backdrop_path, "w780", cache_mode),
@@ -1656,8 +1705,14 @@ async def search_requests(
status_label = _status_label(status)
elif isinstance(media_info_id, int):
username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
cached = get_cached_request_by_media_id(media_info_id, requested_by_norm=requested_by)
requested_by_id = None if user.get("role") == "admin" else requested_by_id
cached = get_cached_request_by_media_id(
media_info_id,
requested_by_norm=requested_by,
requested_by_id=requested_by_id,
)
if cached:
request_id = cached.get("request_id")
status = cached.get("status")

View File

@@ -14,6 +14,7 @@ _BOOL_FIELDS = {
"jellyfin_sync_to_arr",
"site_banner_enabled",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
def get_runtime_settings():
@@ -22,6 +23,8 @@ def get_runtime_settings():
for key, value in overrides.items():
if value is None:
continue
if key in _SKIP_OVERRIDE_FIELDS:
continue
if key in _INT_FIELDS:
try:
update[key] = int(value)

View File

@@ -3,8 +3,14 @@ import logging
from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient
from ..db import create_user_if_missing
from ..db import create_user_if_missing, set_user_jellyseerr_id
from ..runtime import get_runtime_settings
from .user_cache import (
build_jellyseerr_candidate_map,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
logger = logging.getLogger(__name__)
@@ -17,6 +23,9 @@ async def sync_jellyfin_users() -> int:
users = await client.get_users()
if not isinstance(users, list):
return 0
save_jellyfin_users_cache(users)
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
imported = 0
for user in users:
if not isinstance(user, dict):
@@ -24,8 +33,18 @@ async def sync_jellyfin_users() -> int:
name = user.get("Name")
if not name:
continue
if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"):
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
created = create_user_if_missing(
name,
"jellyfin-user",
role="user",
auth_provider="jellyfin",
jellyseerr_user_id=matched_id,
)
if created:
imported += 1
elif matched_id is not None:
set_user_jellyseerr_id(name, matched_id)
return imported

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Iterable, List
import httpx
from ..db import get_admin_notification_targets
from ..runtime import get_runtime_settings
logger = logging.getLogger(__name__)
def _normalize_urls(urls: Iterable[str]) -> List[str]:
normalized: list[str] = []
seen: set[str] = set()
for entry in urls:
if not isinstance(entry, str):
continue
value = entry.strip()
if value and value not in seen:
normalized.append(value)
seen.add(value)
return normalized
def validate_apprise_urls(urls: Iterable[str]) -> List[str]:
normalized = _normalize_urls(urls)
if not normalized:
return []
invalid: list[str] = []
for url in normalized:
if "://" not in url:
invalid.append(url)
if invalid:
raise ValueError(
"Invalid Apprise URL(s): "
+ ", ".join(invalid)
+ " (each URL must include a scheme like discord:// or mailto://)"
)
return normalized
def _get_apprise_notify_url() -> str | None:
runtime = get_runtime_settings()
base_url = (runtime.apprise_base_url or "").strip()
if not base_url:
return None
if "://" not in base_url:
base_url = f"http://{base_url}"
base_url = base_url.rstrip("/")
if base_url.endswith("/notify"):
return base_url
return f"{base_url}/notify"
def _get_apprise_headers() -> dict[str, str]:
runtime = get_runtime_settings()
headers = {"Content-Type": "application/json"}
api_key = (runtime.apprise_api_key or "").strip()
if api_key:
headers["X-API-Key"] = api_key
headers["Authorization"] = f"Bearer {api_key}"
return headers
def send_apprise_notification(urls: Iterable[str], title: str, body: str) -> bool:
try:
normalized = validate_apprise_urls(urls)
except ValueError as exc:
logger.warning("Apprise notification skipped due to invalid URL(s): %s", exc)
return False
if not normalized:
return False
notify_url = _get_apprise_notify_url()
if not notify_url:
logger.warning("Apprise notification skipped: APPRISE_BASE_URL is not configured.")
return False
payload = {
"urls": normalized,
"title": str(title or "Magent notification").strip() or "Magent notification",
"body": str(body or "").strip(),
}
if not payload["body"]:
return False
try:
with httpx.Client(timeout=10.0) as client:
response = client.post(notify_url, headers=_get_apprise_headers(), json=payload)
response.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("Apprise sidecar notify failed: %s", exc)
return False
try:
data = response.json()
except ValueError:
return True
if isinstance(data, dict):
if data.get("status") in {"error", "failed"}:
return False
if "sent" in data:
return bool(data.get("sent"))
return True
def notify_admins_new_signup(username: str, provider: str) -> int:
targets = get_admin_notification_targets()
if not targets:
return 0
timestamp = datetime.now(timezone.utc).isoformat()
title = "New Magent user signup"
body = f"User {username} signed in via {provider} at {timestamp}."
sent = 0
for target in targets:
urls = target.get("urls") or []
if send_apprise_notification(urls, title, body):
sent += 1
if sent == 0:
logger.info("Apprise signup notification skipped (no valid admin targets).")
return sent

View File

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

View File

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

View File

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

28
docker/supervisord.conf Normal file
View File

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

View File

@@ -27,6 +27,7 @@ const SECTION_LABELS: Record<string, string> = {
radarr: 'Radarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
apprise: 'Apprise',
log: 'Activity log',
requests: 'Request sync',
site: 'Site',
@@ -34,6 +35,16 @@ const SECTION_LABELS: Record<string, string> = {
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const URL_SETTINGS = new Set([
'jellyseerr_base_url',
'jellyfin_base_url',
'jellyfin_public_url',
'sonarr_base_url',
'radarr_base_url',
'prowlarr_base_url',
'qbittorrent_base_url',
'apprise_base_url',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = {
@@ -45,6 +56,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.',
apprise: 'Configure the external Apprise sidecar used for notifications.',
requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
@@ -58,6 +70,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
radarr: 'radarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
apprise: 'apprise',
requests: 'requests',
cache: null,
logs: 'log',
@@ -330,28 +343,37 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: 'Base URL for your Jellyseerr server.',
jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.',
jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.',
jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking.',
sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies.',
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.',
prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.',
qbittorrent_base_url:
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.',
apprise_base_url:
'External Apprise API base URL for notifications (for example http://apprise:8000).',
apprise_api_key:
'Optional API key Magent uses when calling your external Apprise service.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
requests_poll_interval_seconds:
'How often Magent checks if a full refresh should run.',
@@ -371,6 +393,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_changelog: 'One update per line for the public changelog.',
}
const settingPlaceholders: Record<string, string> = {
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
apprise_base_url: 'http://apprise:8000 or https://notify.example.com',
}
const buildSelectOptions = (
currentValue: string,
options: { id: number; label: string; path?: string }[],
@@ -982,6 +1015,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
const isRadarrRoot = setting.key === 'radarr_root_folder'
const isBoolSetting = BOOL_SETTINGS.has(setting.key)
const isUrlSetting = URL_SETTINGS.has(setting.key)
const inputPlaceholder = setting.sensitive && setting.isSet
? 'Configured (enter to replace)'
: settingPlaceholders[setting.key] ?? ''
if (isBoolSetting) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
@@ -1312,9 +1349,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<input
name={setting.key}
type={setting.sensitive ? 'password' : 'text'}
placeholder={
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : ''
}
placeholder={inputPlaceholder}
autoComplete={isUrlSetting ? 'url' : undefined}
value={value}
onChange={(event) =>
setFormValues((current) => ({

View File

@@ -0,0 +1,281 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type NotificationUser = {
username: string
role?: string | null
authProvider?: string | null
jellyseerrUserId?: number | null
isBlocked?: boolean
notifyEnabled?: boolean
notifyCount?: number
}
type SendResult = {
username: string
status: string
}
const formatStatus = (user: NotificationUser) => {
if (user.isBlocked) return 'Blocked'
if (!user.notifyEnabled) return 'Disabled'
if (user.notifyCount && user.notifyCount > 0) return `Enabled (${user.notifyCount})`
return 'No targets'
}
export default function AdminNotificationsPage() {
const router = useRouter()
const [users, setUsers] = useState<NotificationUser[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [sending, setSending] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const [sendResults, setSendResults] = useState<SendResult[]>([])
const selectedCount = selected.size
const selectableUsers = useMemo(
() => users.filter((user) => user.username && !user.isBlocked),
[users]
)
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/notifications/users`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Load failed')
}
const data = await response.json()
const fetched = Array.isArray(data?.users) ? data.users : []
setUsers(fetched)
setSelected(new Set())
} catch (err) {
console.error(err)
setStatus('Unable to load notification targets.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const toggleUser = (username: string) => {
setSelected((current) => {
const next = new Set(current)
if (next.has(username)) {
next.delete(username)
} else {
next.add(username)
}
return next
})
}
const selectAll = () => {
const next = new Set<string>()
for (const user of selectableUsers) {
if (user.username) {
next.add(user.username)
}
}
setSelected(next)
}
const selectEnabled = () => {
const next = new Set<string>()
for (const user of selectableUsers) {
if (user.username && user.notifyEnabled && (user.notifyCount ?? 0) > 0) {
next.add(user.username)
}
}
setSelected(next)
}
const clearSelection = () => {
setSelected(new Set())
}
const send = async () => {
setStatus(null)
setSendResults([])
if (selectedCount === 0) {
setStatus('Select at least one user.')
return
}
if (!message.trim()) {
setStatus('Message cannot be empty.')
return
}
setSending(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/notifications/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
usernames: Array.from(selected),
title: title.trim() || 'Magent admin message',
message: message.trim(),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Send failed')
}
const data = await response.json()
const results = Array.isArray(data?.results) ? data.results : []
setSendResults(results)
setStatus(
`Sent ${data?.sent ?? 0}. Skipped ${data?.skipped ?? 0}. Failed ${data?.failed ?? 0}.`
)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Send failed.'
setStatus(message)
} finally {
setSending(false)
}
}
return (
<AdminShell
title="User notifications"
subtitle="Send admin messages to users via their Apprise targets."
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section">
<div className="admin-toolbar">
<div className="admin-toolbar-info">
<span>{users.length.toLocaleString()} users</span>
<span>{selectedCount.toLocaleString()} selected</span>
</div>
<div className="admin-toolbar-actions">
<button type="button" onClick={selectAll} disabled={loading}>
Select all
</button>
<button type="button" onClick={selectEnabled} disabled={loading}>
Select enabled
</button>
<button type="button" className="ghost-button" onClick={clearSelection}>
Clear
</button>
</div>
</div>
{loading ? (
<div className="status-banner">Loading notification targets</div>
) : users.length === 0 ? (
<div className="status-banner">No users found.</div>
) : (
<div className="admin-table">
<div className="admin-table-head">
<span>Select</span>
<span>User</span>
<span>Role</span>
<span>Status</span>
</div>
{users.map((user) => {
const username = user.username || 'Unknown'
const isChecked = selected.has(username)
return (
<div key={username} className="admin-table-row">
<span>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleUser(username)}
disabled={!username || user.isBlocked}
/>
</span>
<span>{username}</span>
<span>{user.role || 'user'}</span>
<span>{formatStatus(user)}</span>
</div>
)
})}
</div>
)}
</section>
<section className="admin-section">
<div className="section-header">
<h2>Message</h2>
</div>
<div className="admin-form">
<label>
<span className="label-row">
<span>Title</span>
<span className="meta">Optional</span>
</span>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Magent admin message"
/>
</label>
<label>
<span className="label-row">
<span>Message</span>
<span className="meta">Required</span>
</span>
<textarea
rows={4}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Write the message you want to send."
/>
</label>
</div>
{status && <div className="status-banner">{status}</div>}
<div className="admin-actions">
<button type="button" onClick={send} disabled={sending}>
{sending ? 'Sending…' : 'Send message'}
</button>
</div>
{sendResults.length > 0 && (
<div className="admin-table">
<div className="admin-table-head">
<span>User</span>
<span>Result</span>
</div>
{sendResults.map((result) => (
<div key={`${result.username}-${result.status}`} className="admin-table-row">
<span>{result.username}</span>
<span>{result.status}</span>
</div>
))}
</div>
)}
</section>
</AdminShell>
)
}

View File

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

View File

@@ -1027,6 +1027,85 @@ button span {
gap: 12px;
}
.admin-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.admin-toolbar-info {
color: var(--ink-muted);
font-size: 13px;
}
.admin-toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.admin-select {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--ink-muted);
font-size: 13px;
}
.admin-table {
display: grid;
gap: 8px;
}
.admin-table-head {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 12px;
font-size: 12px;
color: var(--ink-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0 12px;
}
.admin-table-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 12px;
align-items: center;
text-align: left;
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.admin-table-row:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(15, 20, 45, 0.18);
}
.admin-pagination {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
color: var(--ink-muted);
font-size: 13px;
}
.admin-pagination button {
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
}
.admin-pagination span {
padding: 0 6px;
}
.section-header {
display: flex;
justify-content: space-between;
@@ -1084,6 +1163,118 @@ button span {
line-height: 1.4;
}
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.user-grid-card {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--ink);
text-decoration: none;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.user-grid-card:hover {
border-color: rgba(59, 130, 246, 0.5);
transform: translateY(-2px);
}
.user-grid-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.user-grid-meta {
display: block;
font-size: 12px;
color: var(--ink-muted);
}
.user-grid-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid rgba(59, 130, 246, 0.4);
color: var(--ink);
background: rgba(59, 130, 246, 0.2);
}
.user-grid-pill.is-blocked {
border-color: rgba(255, 82, 82, 0.5);
background: rgba(255, 82, 82, 0.2);
}
.user-grid-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.user-grid-stats .label {
font-size: 12px;
color: var(--ink-muted);
display: block;
}
.user-grid-stats .value {
font-size: 16px;
font-weight: 600;
}
.user-grid-footer {
display: grid;
gap: 6px;
}
.user-detail-card {
display: grid;
gap: 16px;
padding: 18px;
border-radius: 18px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
}
.user-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.user-detail-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.user-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.user-detail-grid .label {
font-size: 12px;
color: var(--ink-muted);
display: block;
}
.user-detail-grid .value {
font-size: 18px;
font-weight: 600;
}
.label-row {
display: flex;
justify-content: space-between;

View File

@@ -14,6 +14,7 @@ export default function HomePage() {
year?: number
statusLabel?: string
artwork?: { poster_url?: string }
createdAt?: string | null
}[]
>([])
const [recentError, setRecentError] = useState<string | null>(null)
@@ -151,6 +152,7 @@ export default function HomePage() {
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
)
@@ -236,6 +238,13 @@ export default function HomePage() {
return url.startsWith('http') ? url : `${getApiBase()}${url}`
}
const formatRequestTime = (value?: string | null) => {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
return (
<main className="card">
<div className="layout-grid">
@@ -312,11 +321,12 @@ export default function HomePage() {
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<label className="recent-filter">
<span>Show last</span>
<span>Show</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
@@ -363,6 +373,7 @@ export default function HomePage() {
<span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
{item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
</button>

View File

@@ -72,6 +72,10 @@ export default function ProfilePage() {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [notifyEnabled, setNotifyEnabled] = useState(false)
const [notifyUrls, setNotifyUrls] = useState('')
const [notifyStatus, setNotifyStatus] = useState<string | null>(null)
const [notifySaving, setNotifySaving] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -97,6 +101,14 @@ export default function ProfilePage() {
})
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
const notifyResponse = await authFetch(`${baseUrl}/auth/notifications`)
if (notifyResponse.ok) {
const notifyData = await notifyResponse.json()
setNotifyEnabled(Boolean(notifyData?.enabled))
const urls = Array.isArray(notifyData?.urls) ? notifyData.urls : []
setNotifyUrls(urls.join('\n'))
}
} catch (err) {
console.error(err)
setStatus('Could not load your profile.')
@@ -137,6 +149,59 @@ export default function ProfilePage() {
}
}
const saveNotifications = async (event: React.FormEvent) => {
event.preventDefault()
setNotifyStatus(null)
setNotifySaving(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/notifications`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: notifyEnabled,
urls: notifyUrls,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setNotifyStatus('Notification settings saved.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not save notification settings.'
setNotifyStatus(message)
} finally {
setNotifySaving(false)
}
}
const sendTest = async () => {
setNotifyStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/notifications/test`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Test failed')
}
setNotifyStatus('Test notification sent.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not send test notification.'
setNotifyStatus(message)
}
}
if (loading) {
return <main className="card">Loading profile...</main>
}
@@ -222,6 +287,42 @@ export default function ProfilePage() {
</div>
</section>
</div>
<section className="profile-section">
<h2>Notifications</h2>
<div className="status-banner">
Add Apprise URLs to receive notifications (one URL per line).
</div>
<form onSubmit={saveNotifications} className="auth-form">
<label>
Enable notifications
<select
value={notifyEnabled ? 'true' : 'false'}
onChange={(event) => setNotifyEnabled(event.target.value === 'true')}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</label>
<label>
Apprise URLs
<textarea
rows={4}
placeholder="discord://token@webhook_id\nmailto://user:pass@server"
value={notifyUrls}
onChange={(event) => setNotifyUrls(event.target.value)}
/>
</label>
{notifyStatus && <div className="status-banner">{notifyStatus}</div>}
<div className="auth-actions">
<button type="submit" disabled={notifySaving}>
{notifySaving ? 'Saving...' : 'Save notifications'}
</button>
<button type="button" className="ghost-button" onClick={sendTest}>
Send test
</button>
</div>
</form>
</section>
{profile?.auth_provider !== 'local' ? (
<div className="status-banner">
Password changes are only available for local Magent accounts.

View File

@@ -18,9 +18,17 @@ const NAV_GROUPS = [
title: 'Requests',
items: [
{ href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' },
],
},
{
title: 'Notifications',
items: [
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/apprise', label: 'Apprise' },
],
},
{
title: 'Admin',
items: [

View File

@@ -0,0 +1,234 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
}
type AdminUser = {
id?: number
username: string
role: string
auth_provider?: string | null
last_login_at?: string | null
is_blocked?: boolean
jellyseerr_user_id?: number | null
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UserDetailPage() {
const params = useParams()
const router = useRouter()
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
const [user, setUser] = useState<AdminUser | null>(null)
const [stats, setStats] = useState<UserStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const loadUser = async () => {
if (!idParam) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
if (response.status === 404) {
setError('User not found.')
return
}
throw new Error('Could not load user.')
}
const data = await response.json()
setUser(data?.user ?? null)
setStats(normalizeStats(data?.stats))
setError(null)
} catch (err) {
console.error(err)
setError('Could not load user.')
} finally {
setLoading(false)
}
}
const toggleUserBlock = async (blocked: boolean) => {
if (!user) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
} catch (err) {
console.error(err)
setError('Could not update user access.')
}
}
const updateUserRole = async (role: string) => {
if (!user) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
} catch (err) {
console.error(err)
setError('Could not update user role.')
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
void loadUser()
}, [router, idParam])
if (loading) {
return <main className="card">Loading user...</main>
}
return (
<AdminShell
title={user?.username || 'User'}
subtitle="User overview and request stats."
actions={
<button type="button" onClick={() => router.push('/users')}>
Back to users
</button>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<>
<div className="user-detail-card">
<div className="user-detail-header">
<div>
<strong>{user.username}</strong>
<div className="user-detail-meta">
<span className="meta">Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</span>
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.auth_provider || 'local'}</span>
<span className="meta">Last login: {formatDateTime(user.last_login_at)}</span>
</div>
</div>
<div className="user-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
</div>
</div>
<div className="user-detail-grid">
<div>
<span className="label">Total</span>
<span className="value">{stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">Approved</span>
<span className="value">{stats?.approved ?? 0}</span>
</div>
<div>
<span className="label">Working</span>
<span className="value">{stats?.working ?? 0}</span>
</div>
<div>
<span className="label">Partial</span>
<span className="value">{stats?.partial ?? 0}</span>
</div>
<div>
<span className="label">Declined</span>
<span className="value">{stats?.declined ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{stats?.in_progress ?? 0}</span>
</div>
<div>
<span className="label">Last request</span>
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
</div>
</div>
</div>
</>
)}
</section>
</AdminShell>
)
}

View File

@@ -2,15 +2,30 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminUser = {
id: number
username: string
role: string
authProvider?: string | null
lastLoginAt?: string | null
isBlocked?: boolean
stats?: UserStats
}
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
}
const formatLastLogin = (value?: string | null) => {
@@ -20,18 +35,50 @@ const formatLastLogin = (value?: string | null) => {
return date.toLocaleString()
}
const formatLastRequest = (value?: string | null) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const emptyStats: UserStats = {
total: 0,
ready: 0,
pending: 0,
approved: 0,
working: 0,
partial: 0,
declined: 0,
in_progress: 0,
last_request_at: null,
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UsersPage() {
const router = useRouter()
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(null)
const [jellyfinSyncBusy, setJellyfinSyncBusy] = useState(false)
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const loadUsers = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users`)
const response = await authFetch(`${baseUrl}/admin/users/summary`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
@@ -53,6 +100,8 @@ export default function UsersPage() {
authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked),
id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats),
}))
)
} else {
@@ -105,12 +154,12 @@ export default function UsersPage() {
}
}
const syncJellyfinUsers = async () => {
setJellyfinSyncStatus(null)
setJellyfinSyncBusy(true)
const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, {
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
method: 'POST',
})
if (!response.ok) {
@@ -118,13 +167,44 @@ export default function UsersPage() {
throw new Error(text || 'Sync failed')
}
const data = await response.json()
setJellyfinSyncStatus(`Synced ${data?.imported ?? 0} Jellyfin users.`)
setJellyseerrSyncStatus(
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setJellyfinSyncStatus('Could not sync Jellyfin users.')
setJellyseerrSyncStatus('Could not sync Jellyseerr users.')
} finally {
setJellyfinSyncBusy(false)
setJellyseerrSyncBusy(false)
}
}
const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Jellyseerr. Continue?'
)
if (!confirmed) return
setJellyseerrSyncStatus(null)
setJellyseerrResyncBusy(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Resync failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not resync Jellyseerr users.')
} finally {
setJellyseerrResyncBusy(false)
}
}
@@ -149,49 +229,63 @@ export default function UsersPage() {
<button type="button" onClick={loadUsers}>
Reload list
</button>
<button type="button" onClick={syncJellyfinUsers} disabled={jellyfinSyncBusy}>
{jellyfinSyncBusy ? 'Syncing Jellyfin users...' : 'Sync Jellyfin users'}
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
</button>
<button type="button" onClick={resyncJellyseerrUsers} disabled={jellyseerrResyncBusy}>
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
</button>
</>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{jellyfinSyncStatus && <div className="status-banner">{jellyfinSyncStatus}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
<div className="admin-grid">
<div className="user-grid">
{users.map((user) => (
<div key={user.username} className="summary-card user-card">
<div>
<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>
<Link
key={user.username}
className="user-grid-card"
href={`/users/${user.id}`}
>
<div className="user-grid-header">
<div>
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
{user.isBlocked ? 'Blocked' : 'Active'}
</span>
</div>
<div className="user-grid-stats">
<div>
<span className="label">Total</span>
<span className="value">{user.stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{user.stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{user.stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{user.stats?.in_progress ?? 0}</span>
</div>
</div>
<div className="user-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) =>
updateUserRole(user.username, event.target.checked ? 'admin' : 'user')
}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(user.username, !user.isBlocked)}
>
{user.isBlocked ? 'Allow access' : 'Block access'}
</button>
<div className="user-grid-footer">
<span className="meta">Login: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
<span className="meta">
Last request: {formatLastRequest(user.stats?.last_request_at)}
</span>
</div>
</div>
</Link>
))}
</div>
)}

View File

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