35 Commits

Author SHA1 Message Date
3493bf715e Hydrate missing artwork from Jellyseerr (build 271261539) 2026-01-27 15:40:36 +13:00
b98239ab3e Fallback to TMDB when artwork cache fails (build 271261524) 2026-01-27 15:26:10 +13:00
40dc46c0c5 Add service test buttons (build 271261335) 2026-01-27 13:36:35 +13:00
d23d84ea42 Bump build number (process 2) 271261322 2026-01-27 13:24:35 +13:00
7d6cdcbe02 Add cache load spinner (build 271261238) 2026-01-27 12:39:51 +13:00
0e95f94025 Fix snapshot title fallback (build 271261228) 2026-01-27 12:30:04 +13:00
8b1a09fbd4 Fix request titles in snapshots (build 271261219) 2026-01-27 12:20:12 +13:00
fe0c108363 Bump build number to 271261202 2026-01-27 12:04:42 +13:00
9e8d22ba85 Clarify request sync settings (build 271261159) 2026-01-27 12:00:32 +13:00
7863658a19 Fix backend cache stats import (build 271261149) 2026-01-27 11:51:01 +13:00
7c97934bb9 Improve cache stats performance (build 271261145) 2026-01-27 11:46:50 +13:00
3f51e24181 Add cache control artwork stats 2026-01-27 11:27:26 +13:00
ab27ebfadf Fix sync progress bar animation 2026-01-26 14:21:18 +13:00
b93b41713a Fix cache title hydration 2026-01-26 14:01:06 +13:00
ceb8c1c9eb Build 2501262041 2026-01-25 20:43:14 +13:00
86ca3bdeb2 Harden request cache titles and cache-only reads 2026-01-25 19:38:31 +13:00
22f90b7e07 Serve bundled branding assets by default 2026-01-25 18:20:30 +13:00
57a4883931 Seed branding logo from bundled assets 2026-01-25 18:01:54 +13:00
6ba41b854b Tidy request sync controls 2026-01-25 17:52:33 +13:00
580b335268 Add Jellyfin login cache and admin-only stats 2026-01-25 17:47:03 +13:00
23549f1e45 Add user stats and activity tracking 2026-01-25 17:04:24 +13:00
2c45dd0065 Move account actions into avatar menu 2026-01-25 16:48:38 +13:00
92959d80ab Improve mobile header layout 2026-01-25 16:36:29 +13:00
615c4c1c29 Automate build number tagging and sync 2026-01-25 14:52:38 +13:00
38eee2407b Add site banner, build number, and changelog 2026-01-25 14:28:16 +13:00
cf4277d10c Improve request handling and qBittorrent categories 2026-01-24 21:48:55 +13:00
030480410b Map Prowlarr releases to Arr indexers for manual grab 2026-01-24 19:21:40 +13:00
3d414b4aeb Clarify how-it-works steps and fixes 2026-01-24 19:15:43 +13:00
18bbcbf660 Document fix buttons in how-it-works 2026-01-24 19:09:05 +13:00
5fa3aa6665 Route grabs through Sonarr/Radarr only 2026-01-24 19:06:40 +13:00
52e3d680f7 Use backend branding assets for logo and favicon 2026-01-23 20:27:12 +13:00
00bccfa8b6 Copy public assets into frontend image 2026-01-23 20:22:50 +13:00
aa3532dd83 Fix backend Dockerfile paths for root context 2026-01-23 20:17:42 +13:00
4ec2351241 Add Docker Hub compose override 2026-01-23 20:16:59 +13:00
6480478167 Remove password fields from users page 2026-01-23 20:16:19 +13:00
46 changed files with 2849 additions and 434 deletions

1
.build_number Normal file
View File

@@ -0,0 +1 @@
271261539

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.env
*.log
data/*
!data/branding/
!data/branding/**
frontend/node_modules/
frontend/.next/
backend/__pycache__/
**/__pycache__/
**/*.pyc

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
.env .env
.venv/ .venv/
data/ data/
!data/branding/
!data/branding/**
backend/__pycache__/ backend/__pycache__/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,21 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field( artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE") default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
) )
site_build_number: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
site_banner_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
)
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_changelog: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
)
jellyseerr_base_url: Optional[str] = Field( jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
@@ -70,6 +85,10 @@ class Settings(BaseSettings):
sonarr_root_folder: Optional[str] = Field( sonarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER") default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER")
) )
sonarr_qbittorrent_category: Optional[str] = Field(
default="sonarr",
validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"),
)
radarr_base_url: Optional[str] = Field( radarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL") default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL")
@@ -83,6 +102,10 @@ class Settings(BaseSettings):
radarr_root_folder: Optional[str] = Field( radarr_root_folder: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER") default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER")
) )
radarr_qbittorrent_category: Optional[str] = Field(
default="radarr",
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
prowlarr_base_url: Optional[str] = Field( prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL") default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")

View File

@@ -24,6 +24,92 @@ def _connect() -> sqlite3.Connection:
return sqlite3.connect(_db_path()) return sqlite3.connect(_db_path())
def _normalize_title_value(title: Optional[str]) -> Optional[str]:
if not isinstance(title, str):
return None
trimmed = title.strip()
return trimmed if trimmed else None
def _normalize_year_value(year: Optional[Any]) -> Optional[int]:
if isinstance(year, int):
return year
if isinstance(year, str):
trimmed = year.strip()
if trimmed.isdigit():
return int(trimmed)
return None
def _is_placeholder_title(title: Optional[str], request_id: Optional[int]) -> bool:
if not isinstance(title, str):
return True
normalized = title.strip().lower()
if not normalized:
return True
if normalized == "untitled":
return True
if request_id and normalized == f"request {request_id}":
return True
return False
def _extract_title_year_from_payload(payload_json: Optional[str]) -> tuple[Optional[str], Optional[int]]:
if not payload_json:
return None, None
try:
payload = json.loads(payload_json)
except json.JSONDecodeError:
return None, None
if not isinstance(payload, dict):
return None, None
media = payload.get("media") or {}
title = None
year = None
if isinstance(media, dict):
title = media.get("title") or media.get("name")
year = media.get("year")
if not title:
title = payload.get("title") or payload.get("name")
if year is None:
year = payload.get("year")
return _normalize_title_value(title), _normalize_year_value(year)
def _extract_tmdb_from_payload(payload_json: Optional[str]) -> tuple[Optional[int], Optional[str]]:
if not payload_json:
return None, None
try:
payload = json.loads(payload_json)
except (TypeError, json.JSONDecodeError):
return None, None
if not isinstance(payload, dict):
return None, None
media = payload.get("media") or {}
if not isinstance(media, dict):
media = {}
tmdb_id = (
media.get("tmdbId")
or payload.get("tmdbId")
or payload.get("tmdb_id")
or media.get("externalServiceId")
or payload.get("externalServiceId")
)
media_type = (
media.get("mediaType")
or payload.get("mediaType")
or payload.get("media_type")
or payload.get("type")
)
try:
tmdb_id = int(tmdb_id) if tmdb_id is not None else None
except (TypeError, ValueError):
tmdb_id = None
if isinstance(media_type, str):
media_type = media_type.strip().lower() or None
return tmdb_id, media_type
def init_db() -> None: def init_db() -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
@@ -61,7 +147,9 @@ def init_db() -> None:
auth_provider TEXT NOT NULL DEFAULT 'local', auth_provider TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT, last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0 is_blocked INTEGER NOT NULL DEFAULT 0,
jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT
) )
""" """
) )
@@ -91,6 +179,21 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS artwork_cache_status (
request_id INTEGER PRIMARY KEY,
tmdb_id INTEGER,
media_type TEXT,
poster_path TEXT,
backdrop_path TEXT,
has_tmdb INTEGER NOT NULL DEFAULT 0,
poster_cached INTEGER NOT NULL DEFAULT 0,
backdrop_cached INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
"""
)
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -103,6 +206,38 @@ def init_db() -> None:
ON requests_cache (requested_by_norm) ON requests_cache (requested_by_norm)
""" """
) )
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_artwork_cache_status_updated_at
ON artwork_cache_status (updated_at)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip TEXT NOT NULL,
user_agent TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
hit_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(username, ip, user_agent)
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_user_activity_username
ON user_activity (username)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_user_activity_last_seen
ON user_activity (last_seen_at)
"""
)
try: try:
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT") conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -115,6 +250,14 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'") conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError:
pass
_backfill_auth_providers() _backfill_auth_providers()
ensure_admin_user() ensure_admin_user()
@@ -251,7 +394,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at, is_blocked SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at,
is_blocked, jellyfin_password_hash, last_jellyfin_auth_at
FROM users FROM users
WHERE username = ? WHERE username = ?
""", """,
@@ -268,6 +412,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"created_at": row[5], "created_at": row[5],
"last_login_at": row[6], "last_login_at": row[6],
"is_blocked": bool(row[7]), "is_blocked": bool(row[7]),
"jellyfin_password_hash": row[8],
"last_jellyfin_auth_at": row[9],
} }
@@ -347,6 +493,22 @@ def set_user_password(username: str, password: str) -> None:
) )
def set_jellyfin_auth_cache(username: str, password: str) -> None:
if not username or not password:
return
password_hash = hash_password(password)
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE users
SET jellyfin_password_hash = ?, last_jellyfin_auth_at = ?
WHERE username = ?
""",
(password_hash, timestamp, username),
)
def _backfill_auth_providers() -> None: def _backfill_auth_providers() -> None:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
@@ -377,6 +539,164 @@ def _backfill_auth_providers() -> None:
) )
def upsert_user_activity(username: str, ip: str, user_agent: str) -> None:
if not username:
return
ip_value = ip.strip() if isinstance(ip, str) and ip.strip() else "unknown"
agent_value = (
user_agent.strip() if isinstance(user_agent, str) and user_agent.strip() else "unknown"
)
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO user_activity (username, ip, user_agent, first_seen_at, last_seen_at, hit_count)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(username, ip, user_agent)
DO UPDATE SET last_seen_at = excluded.last_seen_at, hit_count = hit_count + 1
""",
(username, ip_value, agent_value, timestamp, timestamp),
)
def get_user_activity(username: str, limit: int = 5) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 20))
with _connect() as conn:
rows = conn.execute(
"""
SELECT ip, user_agent, first_seen_at, last_seen_at, hit_count
FROM user_activity
WHERE username = ?
ORDER BY last_seen_at DESC
LIMIT ?
""",
(username, limit),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"ip": row[0],
"user_agent": row[1],
"first_seen_at": row[2],
"last_seen_at": row[3],
"hit_count": row[4],
}
)
return results
def get_user_activity_summary(username: str) -> Dict[str, Any]:
with _connect() as conn:
last_row = conn.execute(
"""
SELECT ip, user_agent, last_seen_at
FROM user_activity
WHERE username = ?
ORDER BY last_seen_at DESC
LIMIT 1
""",
(username,),
).fetchone()
count_row = conn.execute(
"""
SELECT COUNT(*)
FROM user_activity
WHERE username = ?
""",
(username,),
).fetchone()
return {
"last_ip": last_row[0] if last_row else None,
"last_user_agent": last_row[1] if last_row else None,
"last_seen_at": last_row[2] if last_row else None,
"device_count": int(count_row[0] or 0) if count_row else 0,
}
def get_user_request_stats(username_norm: str) -> Dict[str, Any]:
if not username_norm:
return {
"total": 0,
"ready": 0,
"pending": 0,
"approved": 0,
"working": 0,
"partial": 0,
"declined": 0,
"in_progress": 0,
"last_request_at": None,
}
with _connect() as conn:
total_row = conn.execute(
"""
SELECT COUNT(*)
FROM requests_cache
WHERE requested_by_norm = ?
""",
(username_norm,),
).fetchone()
status_rows = conn.execute(
"""
SELECT status, COUNT(*)
FROM requests_cache
WHERE requested_by_norm = ?
GROUP BY status
""",
(username_norm,),
).fetchall()
last_row = conn.execute(
"""
SELECT MAX(created_at)
FROM requests_cache
WHERE requested_by_norm = ?
""",
(username_norm,),
).fetchone()
counts = {int(row[0]): int(row[1]) for row in status_rows if row[0] is not None}
pending = counts.get(1, 0)
approved = counts.get(2, 0)
declined = counts.get(3, 0)
ready = counts.get(4, 0)
working = counts.get(5, 0)
partial = counts.get(6, 0)
in_progress = approved + working + partial
return {
"total": int(total_row[0] or 0) if total_row else 0,
"ready": ready,
"pending": pending,
"approved": approved,
"working": working,
"partial": partial,
"declined": declined,
"in_progress": in_progress,
"last_request_at": last_row[0] if last_row else None,
}
def get_global_request_leader() -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT requested_by_norm, MAX(requested_by) as display_name, COUNT(*) as total
FROM requests_cache
WHERE requested_by_norm IS NOT NULL AND requested_by_norm != ''
GROUP BY requested_by_norm
ORDER BY total DESC
LIMIT 1
"""
).fetchone()
if not row:
return None
return {"username": row[1] or row[0], "total": int(row[2] or 0)}
def get_global_request_total() -> int:
with _connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone()
return int(row[0] or 0)
def upsert_request_cache( def upsert_request_cache(
request_id: int, request_id: int,
media_id: Optional[int], media_id: Optional[int],
@@ -390,7 +710,34 @@ def upsert_request_cache(
updated_at: Optional[str], updated_at: Optional[str],
payload_json: str, payload_json: str,
) -> None: ) -> None:
normalized_title = _normalize_title_value(title)
normalized_year = _normalize_year_value(year)
derived_title = None
derived_year = None
if not normalized_title or normalized_year is None:
derived_title, derived_year = _extract_title_year_from_payload(payload_json)
if _is_placeholder_title(normalized_title, request_id):
normalized_title = None
if derived_title and not normalized_title:
normalized_title = derived_title
if normalized_year is None and derived_year is not None:
normalized_year = derived_year
with _connect() as conn: with _connect() as conn:
existing_title = None
existing_year = None
if normalized_title is None or normalized_year is None:
row = conn.execute(
"SELECT title, year FROM requests_cache WHERE request_id = ?",
(request_id,),
).fetchone()
if row:
existing_title, existing_year = row[0], row[1]
if _is_placeholder_title(existing_title, request_id):
existing_title = None
if normalized_title is None and existing_title:
normalized_title = existing_title
if normalized_year is None and existing_year is not None:
normalized_year = existing_year
conn.execute( conn.execute(
""" """
INSERT INTO requests_cache ( INSERT INTO requests_cache (
@@ -424,8 +771,8 @@ def upsert_request_cache(
media_id, media_id,
media_type, media_type,
status, status,
title, normalized_title,
year, normalized_year,
requested_by, requested_by,
requested_by_norm, requested_by_norm,
created_at, created_at,
@@ -528,22 +875,11 @@ def get_cached_requests(
title = row[4] title = row[4]
year = row[5] year = row[5]
if (not title or not year) and row[8]: if (not title or not year) and row[8]:
try: derived_title, derived_year = _extract_title_year_from_payload(row[8])
payload = json.loads(row[8]) if not title:
if isinstance(payload, dict): title = derived_title
media = payload.get("media") or {} if not year:
if not title: year = derived_year
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
if not year:
year = media.get("year") if isinstance(media, dict) else None
year = year or payload.get("year")
except json.JSONDecodeError:
pass
results.append( results.append(
{ {
"request_id": row[0], "request_id": row[0],
@@ -575,18 +911,8 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
for row in rows: for row in rows:
title = row[4] title = row[4]
if not title and row[9]: if not title and row[9]:
try: derived_title, _ = _extract_title_year_from_payload(row[9])
payload = json.loads(row[9]) title = derived_title or row[4]
if isinstance(payload, dict):
media = payload.get("media") or {}
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
except json.JSONDecodeError:
title = row[4]
results.append( results.append(
{ {
"request_id": row[0], "request_id": row[0],
@@ -603,12 +929,200 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
return results return results
def get_request_cache_missing_titles(limit: int = 200) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 500))
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, payload_json
FROM requests_cache
WHERE title IS NULL OR TRIM(title) = '' OR LOWER(title) = 'untitled'
ORDER BY updated_at DESC, request_id DESC
LIMIT ?
""",
(limit,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
payload_json = row[1]
tmdb_id, media_type = _extract_tmdb_from_payload(payload_json)
results.append(
{
"request_id": row[0],
"payload_json": payload_json,
"tmdb_id": tmdb_id,
"media_type": media_type,
}
)
return results
def get_request_cache_count() -> int: def get_request_cache_count() -> int:
with _connect() as conn: with _connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone() row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone()
return int(row[0] or 0) return int(row[0] or 0)
def upsert_artwork_cache_status(
request_id: int,
tmdb_id: Optional[int],
media_type: Optional[str],
poster_path: Optional[str],
backdrop_path: Optional[str],
has_tmdb: bool,
poster_cached: bool,
backdrop_cached: bool,
) -> None:
updated_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO artwork_cache_status (
request_id,
tmdb_id,
media_type,
poster_path,
backdrop_path,
has_tmdb,
poster_cached,
backdrop_cached,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO UPDATE SET
tmdb_id = excluded.tmdb_id,
media_type = excluded.media_type,
poster_path = excluded.poster_path,
backdrop_path = excluded.backdrop_path,
has_tmdb = excluded.has_tmdb,
poster_cached = excluded.poster_cached,
backdrop_cached = excluded.backdrop_cached,
updated_at = excluded.updated_at
""",
(
request_id,
tmdb_id,
media_type,
poster_path,
backdrop_path,
1 if has_tmdb else 0,
1 if poster_cached else 0,
1 if backdrop_cached else 0,
updated_at,
),
)
def get_artwork_cache_status_count() -> int:
with _connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM artwork_cache_status").fetchone()
return int(row[0] or 0)
def get_artwork_cache_missing_count() -> int:
with _connect() as conn:
row = conn.execute(
"""
SELECT COUNT(*)
FROM artwork_cache_status
WHERE (
(poster_path IS NULL AND has_tmdb = 1)
OR (poster_path IS NOT NULL AND poster_cached = 0)
OR (backdrop_path IS NULL AND has_tmdb = 1)
OR (backdrop_path IS NOT NULL AND backdrop_cached = 0)
)
"""
).fetchone()
return int(row[0] or 0)
def update_artwork_cache_stats(
cache_bytes: Optional[int] = None,
cache_files: Optional[int] = None,
missing_count: Optional[int] = None,
total_requests: Optional[int] = None,
) -> None:
updated_at = datetime.now(timezone.utc).isoformat()
if cache_bytes is not None:
set_setting("artwork_cache_bytes", str(int(cache_bytes)))
if cache_files is not None:
set_setting("artwork_cache_files", str(int(cache_files)))
if missing_count is not None:
set_setting("artwork_cache_missing", str(int(missing_count)))
if total_requests is not None:
set_setting("artwork_cache_total_requests", str(int(total_requests)))
set_setting("artwork_cache_updated_at", updated_at)
def get_artwork_cache_stats() -> Dict[str, Any]:
def _get_int(key: str) -> int:
value = get_setting(key)
if value is None:
return 0
try:
return int(value)
except (TypeError, ValueError):
return 0
return {
"cache_bytes": _get_int("artwork_cache_bytes"),
"cache_files": _get_int("artwork_cache_files"),
"missing_artwork": _get_int("artwork_cache_missing"),
"total_requests": _get_int("artwork_cache_total_requests"),
"updated_at": get_setting("artwork_cache_updated_at"),
}
def get_request_cache_stats() -> Dict[str, Any]:
return get_artwork_cache_stats()
def update_request_cache_title(
request_id: int, title: str, year: Optional[int] = None
) -> None:
normalized_title = _normalize_title_value(title)
normalized_year = _normalize_year_value(year)
if not normalized_title:
return
with _connect() as conn:
conn.execute(
"""
UPDATE requests_cache
SET title = ?, year = COALESCE(?, year)
WHERE request_id = ?
""",
(normalized_title, normalized_year, request_id),
)
def repair_request_cache_titles() -> int:
updated = 0
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, title, year, payload_json
FROM requests_cache
"""
).fetchall()
for row in rows:
request_id, title, year, payload_json = row
if not _is_placeholder_title(title, request_id):
continue
derived_title, derived_year = _extract_title_year_from_payload(payload_json)
if not derived_title:
continue
conn.execute(
"""
UPDATE requests_cache
SET title = ?, year = COALESCE(?, year)
WHERE request_id = ?
""",
(derived_title, derived_year, request_id),
)
updated += 1
return updated
def prune_duplicate_requests_cache() -> int: def prune_duplicate_requests_cache() -> int:
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( cursor = conn.execute(
@@ -651,6 +1165,39 @@ def get_request_cache_payloads(limit: int = 200, offset: int = 0) -> list[Dict[s
return results return results
def get_request_cache_payloads_missing(limit: int = 200, offset: int = 0) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 1000))
offset = max(0, offset)
with _connect() as conn:
rows = conn.execute(
"""
SELECT rc.request_id, rc.payload_json
FROM requests_cache rc
JOIN artwork_cache_status acs
ON rc.request_id = acs.request_id
WHERE (
(acs.poster_path IS NULL AND acs.has_tmdb = 1)
OR (acs.poster_path IS NOT NULL AND acs.poster_cached = 0)
OR (acs.backdrop_path IS NULL AND acs.has_tmdb = 1)
OR (acs.backdrop_path IS NOT NULL AND acs.backdrop_cached = 0)
)
ORDER BY rc.request_id ASC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
payload = None
if row[1]:
try:
payload = json.loads(row[1])
except json.JSONDecodeError:
payload = None
results.append({"request_id": row[0], "payload": payload})
return results
def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]: def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import settings from .config import settings
from .db import init_db from .db import init_db, set_setting
from .routers.requests import ( from .routers.requests import (
router as requests_router, router as requests_router,
startup_warmup_requests_cache, startup_warmup_requests_cache,
@@ -18,6 +18,7 @@ from .routers.images import router as images_router
from .routers.branding import router as branding_router from .routers.branding import router as branding_router
from .routers.status import router as status_router from .routers.status import router as status_router
from .routers.feedback import router as feedback_router from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
@@ -40,6 +41,8 @@ async def health() -> dict:
@app.on_event("startup") @app.on_event("startup")
async def startup() -> None: async def startup() -> None:
init_db() 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() runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file) configure_logging(runtime.log_level, runtime.log_file)
asyncio.create_task(run_daily_jellyfin_sync()) asyncio.create_task(run_daily_jellyfin_sync())
@@ -56,3 +59,4 @@ app.include_router(images_router)
app.include_router(branding_router) app.include_router(branding_router)
app.include_router(status_router) app.include_router(status_router)
app.include_router(feedback_router) app.include_router(feedback_router)
app.include_router(site_router)

View File

@@ -9,6 +9,8 @@ from ..db import (
delete_setting, delete_setting,
get_all_users, get_all_users,
get_request_cache_overview, get_request_cache_overview,
get_request_cache_missing_titles,
get_request_cache_stats,
get_settings_overrides, get_settings_overrides,
get_user_by_username, get_user_by_username,
set_setting, set_setting,
@@ -20,6 +22,8 @@ from ..db import (
clear_requests_cache, clear_requests_cache,
clear_history, clear_history,
cleanup_history, cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -56,10 +60,12 @@ SETTING_KEYS: List[str] = [
"sonarr_api_key", "sonarr_api_key",
"sonarr_quality_profile_id", "sonarr_quality_profile_id",
"sonarr_root_folder", "sonarr_root_folder",
"sonarr_qbittorrent_category",
"radarr_base_url", "radarr_base_url",
"radarr_api_key", "radarr_api_key",
"radarr_quality_profile_id", "radarr_quality_profile_id",
"radarr_root_folder", "radarr_root_folder",
"radarr_qbittorrent_category",
"prowlarr_base_url", "prowlarr_base_url",
"prowlarr_api_key", "prowlarr_api_key",
"qbittorrent_base_url", "qbittorrent_base_url",
@@ -74,6 +80,11 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time", "requests_cleanup_time",
"requests_cleanup_days", "requests_cleanup_days",
"requests_data_source", "requests_data_source",
"site_build_number",
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
"site_changelog",
] ]
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]: def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
@@ -91,6 +102,38 @@ def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
return results return results
async def _hydrate_cache_titles_from_jellyseerr(limit: int) -> int:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return 0
missing = get_request_cache_missing_titles(limit)
if not missing:
return 0
hydrated = 0
for row in missing:
tmdb_id = row.get("tmdb_id")
media_type = row.get("media_type")
request_id = row.get("request_id")
if not tmdb_id or not media_type or not request_id:
continue
try:
title, year = await requests_router._hydrate_title_from_tmdb(
client, media_type, tmdb_id
)
except Exception:
logger.warning(
"Requests cache title hydrate failed: request_id=%s tmdb_id=%s",
request_id,
tmdb_id,
)
continue
if title:
update_request_cache_title(request_id, title, year)
hydrated += 1
return hydrated
def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]: def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
if not isinstance(profiles, list): if not isinstance(profiles, list):
return [] return []
@@ -235,10 +278,12 @@ async def requests_sync_delta() -> Dict[str, Any]:
@router.post("/requests/artwork/prefetch") @router.post("/requests/artwork/prefetch")
async def requests_artwork_prefetch() -> Dict[str, Any]: async def requests_artwork_prefetch(only_missing: bool = False) -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
state = await requests_router.start_artwork_prefetch( state = await requests_router.start_artwork_prefetch(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key runtime.jellyseerr_base_url,
runtime.jellyseerr_api_key,
only_missing=only_missing,
) )
logger.info("Admin triggered artwork prefetch: status=%s", state.get("status")) logger.info("Admin triggered artwork prefetch: status=%s", state.get("status"))
return {"status": "ok", "prefetch": state} return {"status": "ok", "prefetch": state}
@@ -248,6 +293,25 @@ async def requests_artwork_prefetch() -> Dict[str, Any]:
async def requests_artwork_status() -> Dict[str, Any]: async def requests_artwork_status() -> Dict[str, Any]:
return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()} return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()}
@router.get("/requests/artwork/summary")
async def requests_artwork_summary() -> Dict[str, Any]:
runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
stats = get_request_cache_stats()
if cache_mode != "cache":
stats["cache_bytes"] = 0
stats["cache_files"] = 0
stats["missing_artwork"] = 0
summary = {
"cache_mode": cache_mode,
"cache_bytes": stats.get("cache_bytes", 0),
"cache_files": stats.get("cache_files", 0),
"missing_artwork": stats.get("missing_artwork", 0),
"total_requests": stats.get("total_requests", 0),
"updated_at": stats.get("updated_at"),
}
return {"status": "ok", "summary": summary}
@router.get("/requests/sync/status") @router.get("/requests/sync/status")
async def requests_sync_status() -> Dict[str, Any]: async def requests_sync_status() -> Dict[str, Any]:
@@ -274,7 +338,14 @@ async def read_logs(lines: int = 200) -> Dict[str, Any]:
@router.get("/requests/cache") @router.get("/requests/cache")
async def requests_cache(limit: int = 50) -> Dict[str, Any]: async def requests_cache(limit: int = 50) -> Dict[str, Any]:
return {"rows": get_request_cache_overview(limit)} repaired = repair_request_cache_titles()
if repaired:
logger.info("Requests cache titles repaired via settings view: %s", repaired)
hydrated = await _hydrate_cache_titles_from_jellyseerr(limit)
if hydrated:
logger.info("Requests cache titles hydrated via Jellyseerr: %s", hydrated)
rows = get_request_cache_overview(limit)
return {"rows": rows}
@router.post("/branding/logo") @router.post("/branding/logo")

View File

@@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
@@ -7,16 +9,51 @@ from ..db import (
set_last_login, set_last_login,
get_user_by_username, get_user_by_username,
set_user_password, set_user_password,
set_jellyfin_auth_cache,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
get_global_request_leader,
get_global_request_total,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token from ..security import create_access_token, verify_password
from ..auth import get_current_user from ..auth import get_current_user
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
def _normalize_username(value: str) -> str:
return value.strip().lower()
def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
if not last_auth_at:
return False
try:
parsed = datetime.fromisoformat(last_auth_at)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(days=7)
def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
if not user or not password:
return False
cached_hash = user.get("jellyfin_password_hash")
last_auth_at = user.get("last_jellyfin_auth_at")
if not cached_hash or not last_auth_at:
return False
if not verify_password(password, cached_hash):
return False
return _is_recent_jellyfin_auth(last_auth_at)
@router.post("/login") @router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password) user = verify_user_password(form_data.username, form_data.password)
@@ -39,14 +76,23 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
username = form_data.username
password = form_data.password
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user")
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
try: try:
response = await client.authenticate_by_name(form_data.username, form_data.password) response = await client.authenticate_by_name(username, password)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"): if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(form_data.username) user = get_user_by_username(username)
if user and user.get("is_blocked"): if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
try: try:
@@ -60,9 +106,10 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
except Exception: except Exception:
pass pass
token = create_access_token(form_data.username, "user") set_jellyfin_auth_cache(username, password)
set_last_login(form_data.username) token = create_access_token(username, "user")
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@router.post("/jellyseerr/login") @router.post("/jellyseerr/login")
@@ -92,6 +139,32 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user return current_user
@router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm)
global_total = get_global_request_total()
share = (stats.get("total", 0) / global_total) if global_total else 0
activity_summary = get_user_activity_summary(username) if username else {}
activity_recent = get_user_activity(username, limit=5) if username else []
stats_payload = {
**stats,
"share": share,
"global_total": global_total,
}
if current_user.get("role") == "admin":
stats_payload["most_active_user"] = get_global_request_leader()
return {
"user": current_user,
"stats": stats_payload,
"activity": {
**activity_summary,
"recent": activity_recent,
},
}
@router.post("/password") @router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
if current_user.get("auth_provider") != "local": if current_user.get("auth_provider") != "local":

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import asyncio
import httpx import httpx
import json import json
import logging import logging
import os
import time import time
from urllib.parse import quote from urllib.parse import quote
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -17,7 +18,7 @@ from ..clients.prowlarr import ProwlarrClient
from ..ai.triage import triage_snapshot from ..ai.triage import triage_snapshot
from ..auth import get_current_user from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .images import cache_tmdb_image from .images import cache_tmdb_image, is_tmdb_cached
from ..db import ( from ..db import (
save_action, save_action,
get_recent_actions, get_recent_actions,
@@ -30,10 +31,16 @@ from ..db import (
get_request_cache_last_updated, get_request_cache_last_updated,
get_request_cache_count, get_request_cache_count,
get_request_cache_payloads, get_request_cache_payloads,
get_request_cache_payloads_missing,
repair_request_cache_titles,
prune_duplicate_requests_cache, prune_duplicate_requests_cache,
upsert_request_cache, upsert_request_cache,
upsert_artwork_cache_status,
get_artwork_cache_missing_count,
get_artwork_cache_status_count,
get_setting, get_setting,
set_setting, set_setting,
update_artwork_cache_stats,
cleanup_history, cleanup_history,
) )
from ..models import Snapshot, TriageResult, RequestType from ..models import Snapshot, TriageResult, RequestType
@@ -64,10 +71,12 @@ _artwork_prefetch_state: Dict[str, Any] = {
"processed": 0, "processed": 0,
"total": 0, "total": 0,
"message": "", "message": "",
"only_missing": False,
"started_at": None, "started_at": None,
"finished_at": None, "finished_at": None,
} }
_artwork_prefetch_task: Optional[asyncio.Task] = None _artwork_prefetch_task: Optional[asyncio.Task] = None
_media_endpoint_supported: Optional[bool] = None
STATUS_LABELS = { STATUS_LABELS = {
1: "Waiting for approval", 1: "Waiting for approval",
@@ -225,6 +234,108 @@ def _extract_artwork_paths(item: Dict[str, Any]) -> tuple[Optional[str], Optiona
backdrop_path = item.get("backdropPath") or item.get("backdrop_path") backdrop_path = item.get("backdropPath") or item.get("backdrop_path")
return poster_path, backdrop_path return poster_path, backdrop_path
def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Optional[str]]:
media = payload.get("media") or {}
if not isinstance(media, dict):
media = {}
tmdb_id = media.get("tmdbId") or payload.get("tmdbId")
media_type = (
media.get("mediaType")
or payload.get("mediaType")
or payload.get("type")
)
try:
tmdb_id = int(tmdb_id) if tmdb_id is not None else None
except (TypeError, ValueError):
tmdb_id = None
if isinstance(media_type, str):
media_type = media_type.strip().lower() or None
else:
media_type = None
return tmdb_id, media_type
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
poster_path, backdrop_path = _extract_artwork_paths(payload)
tmdb_id, media_type = _extract_tmdb_lookup(payload)
can_hydrate = bool(tmdb_id and media_type)
if poster_path:
if not is_tmdb_cached(poster_path, "w185") or not is_tmdb_cached(poster_path, "w342"):
return True
elif can_hydrate:
return True
if backdrop_path:
if not is_tmdb_cached(backdrop_path, "w780"):
return True
elif can_hydrate:
return True
return False
def _compute_cached_flags(
poster_path: Optional[str],
backdrop_path: Optional[str],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> tuple[bool, bool]:
if cache_mode != "cache":
return True, True
poster = poster_cached
backdrop = backdrop_cached
if poster is None:
poster = bool(poster_path) and is_tmdb_cached(poster_path, "w185") and is_tmdb_cached(
poster_path, "w342"
)
if backdrop is None:
backdrop = bool(backdrop_path) and is_tmdb_cached(backdrop_path, "w780")
return bool(poster), bool(backdrop)
def _upsert_artwork_status(
payload: Dict[str, Any],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> None:
parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id")
if not isinstance(request_id, int):
return
tmdb_id, media_type = _extract_tmdb_lookup(payload)
poster_path, backdrop_path = _extract_artwork_paths(payload)
has_tmdb = bool(tmdb_id and media_type)
poster_cached_flag, backdrop_cached_flag = _compute_cached_flags(
poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached
)
upsert_artwork_cache_status(
request_id=request_id,
tmdb_id=tmdb_id,
media_type=media_type,
poster_path=poster_path,
backdrop_path=backdrop_path,
has_tmdb=has_tmdb,
poster_cached=poster_cached_flag,
backdrop_cached=backdrop_cached_flag,
)
def _collect_artwork_cache_disk_stats() -> tuple[int, int]:
cache_root = os.path.join(os.getcwd(), "data", "artwork")
total_bytes = 0
total_files = 0
if not os.path.isdir(cache_root):
return 0, 0
for root, _, files in os.walk(cache_root):
for name in files:
path = os.path.join(root, name)
try:
total_bytes += os.path.getsize(path)
total_files += 1
except OSError:
continue
return total_bytes, total_files
async def _get_request_details(client: JellyseerrClient, request_id: int) -> Optional[Dict[str, Any]]: async def _get_request_details(client: JellyseerrClient, request_id: int) -> Optional[Dict[str, Any]]:
cache_key = f"request:{request_id}" cache_key = f"request:{request_id}"
@@ -269,10 +380,17 @@ async def _hydrate_title_from_tmdb(
async def _hydrate_media_details(client: JellyseerrClient, media_id: Optional[int]) -> Optional[Dict[str, Any]]: async def _hydrate_media_details(client: JellyseerrClient, media_id: Optional[int]) -> Optional[Dict[str, Any]]:
if not media_id: if not media_id:
return None return None
global _media_endpoint_supported
if _media_endpoint_supported is False:
return None
try: try:
details = await client.get_media(int(media_id)) details = await client.get_media(int(media_id))
except httpx.HTTPStatusError: except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 405:
_media_endpoint_supported = False
logger.info("Jellyseerr media endpoint rejected GET requests; skipping media lookups.")
return None return None
_media_endpoint_supported = True
return details if isinstance(details, dict) else None return details if isinstance(details, dict) else None
@@ -393,14 +511,23 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
continue continue
payload = _parse_request_payload(item) payload = _parse_request_payload(item)
request_id = payload.get("request_id") request_id = payload.get("request_id")
cached_title = None
if isinstance(request_id, int): if isinstance(request_id, int):
if not payload.get("title"):
cached = get_request_cache_by_id(request_id)
if cached and cached.get("title"):
cached_title = cached.get("title")
if not payload.get("title") or not payload.get("media_id"): if not payload.get("title") or not payload.get("media_id"):
logger.debug("Jellyseerr sync hydrate request_id=%s", request_id) logger.debug("Jellyseerr sync hydrate request_id=%s", request_id)
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
if isinstance(details, dict): if isinstance(details, dict):
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
item = details item = details
if not payload.get("title") and payload.get("media_id"): if (
not payload.get("title")
and payload.get("media_id")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
media_details = await _hydrate_media_details(client, payload.get("media_id")) media_details = await _hydrate_media_details(client, payload.get("media_id"))
if isinstance(media_details, dict): if isinstance(media_details, dict):
media_title = media_details.get("title") or media_details.get("name") media_title = media_details.get("title") or media_details.get("name")
@@ -428,7 +555,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
if isinstance(details, dict): if isinstance(details, dict):
item = details item = details
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
if not payload.get("title") and payload.get("tmdb_id"): if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"):
hydrated_title, hydrated_year = await _hydrate_title_from_tmdb( hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
client, payload.get("media_type"), payload.get("tmdb_id") client, payload.get("media_type"), payload.get("tmdb_id")
) )
@@ -436,6 +563,8 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
payload["title"] = hydrated_title payload["title"] = hydrated_title
if hydrated_year: if hydrated_year:
payload["year"] = hydrated_year payload["year"] = hydrated_year
if not payload.get("title") and cached_title:
payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int): if not isinstance(payload.get("request_id"), int):
continue continue
payload_json = json.dumps(item, ensure_ascii=True) payload_json = json.dumps(item, ensure_ascii=True)
@@ -452,6 +581,8 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=payload_json, payload_json=payload_json,
) )
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
stored += 1 stored += 1
_sync_state["stored"] = stored _sync_state["stored"] = stored
if len(items) < take: if len(items) < take:
@@ -471,6 +602,11 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
) )
set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat()) set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
if cache_mode == "cache":
update_artwork_cache_stats(
missing_count=get_artwork_cache_missing_count(),
total_requests=get_request_cache_count(),
)
return stored return stored
@@ -516,6 +652,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if isinstance(request_id, int): if isinstance(request_id, int):
cached = get_request_cache_by_id(request_id) cached = get_request_cache_by_id(request_id)
incoming_updated = payload.get("updated_at") incoming_updated = payload.get("updated_at")
cached_title = cached.get("title") if cached else None
if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"): if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"):
continue continue
if not payload.get("title") or not payload.get("media_id"): if not payload.get("title") or not payload.get("media_id"):
@@ -523,7 +660,11 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if isinstance(details, dict): if isinstance(details, dict):
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
item = details item = details
if not payload.get("title") and payload.get("media_id"): if (
not payload.get("title")
and payload.get("media_id")
and (not payload.get("tmdb_id") or not payload.get("media_type"))
):
media_details = await _hydrate_media_details(client, payload.get("media_id")) media_details = await _hydrate_media_details(client, payload.get("media_id"))
if isinstance(media_details, dict): if isinstance(media_details, dict):
media_title = media_details.get("title") or media_details.get("name") media_title = media_details.get("title") or media_details.get("name")
@@ -551,7 +692,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if isinstance(details, dict): if isinstance(details, dict):
payload = _parse_request_payload(details) payload = _parse_request_payload(details)
item = details item = details
if not payload.get("title") and payload.get("tmdb_id"): if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"):
hydrated_title, hydrated_year = await _hydrate_title_from_tmdb( hydrated_title, hydrated_year = await _hydrate_title_from_tmdb(
client, payload.get("media_type"), payload.get("tmdb_id") client, payload.get("media_type"), payload.get("tmdb_id")
) )
@@ -559,6 +700,8 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
payload["title"] = hydrated_title payload["title"] = hydrated_title
if hydrated_year: if hydrated_year:
payload["year"] = hydrated_year payload["year"] = hydrated_year
if not payload.get("title") and cached_title:
payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int): if not isinstance(payload.get("request_id"), int):
continue continue
payload_json = json.dumps(item, ensure_ascii=True) payload_json = json.dumps(item, ensure_ascii=True)
@@ -575,6 +718,8 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=payload_json, payload_json=payload_json,
) )
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
stored += 1 stored += 1
page_changed = True page_changed = True
_sync_state["stored"] = stored _sync_state["stored"] = stored
@@ -602,10 +747,20 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
) )
set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat()) set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
if cache_mode == "cache":
update_artwork_cache_stats(
missing_count=get_artwork_cache_missing_count(),
total_requests=get_request_cache_count(),
)
return stored return stored
async def _prefetch_artwork_cache(client: JellyseerrClient) -> None: async def _prefetch_artwork_cache(
client: JellyseerrClient,
only_missing: bool = False,
total: Optional[int] = None,
use_missing_query: bool = False,
) -> None:
runtime = get_runtime_settings() runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower() cache_mode = (runtime.artwork_cache_mode or "remote").lower()
if cache_mode != "cache": if cache_mode != "cache":
@@ -618,74 +773,100 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
) )
return return
total = get_request_cache_count() total = total if total is not None else get_request_cache_count()
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
{ {
"status": "running", "status": "running",
"processed": 0, "processed": 0,
"total": total, "total": total,
"message": "Starting artwork prefetch", "message": "Starting missing artwork prefetch"
if only_missing
else "Starting artwork prefetch",
"only_missing": only_missing,
"started_at": datetime.now(timezone.utc).isoformat(), "started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None, "finished_at": None,
} }
) )
if only_missing and total == 0:
_artwork_prefetch_state.update(
{
"status": "completed",
"processed": 0,
"message": "No missing artwork to cache.",
"finished_at": datetime.now(timezone.utc).isoformat(),
}
)
return
offset = 0 offset = 0
limit = 200 limit = 200
processed = 0 processed = 0
while True: while True:
batch = get_request_cache_payloads(limit=limit, offset=offset) if use_missing_query:
batch = get_request_cache_payloads_missing(limit=limit, offset=offset)
else:
batch = get_request_cache_payloads(limit=limit, offset=offset)
if not batch: if not batch:
break break
for row in batch: for row in batch:
payload = row.get("payload") payload = row.get("payload")
if not isinstance(payload, dict): if not isinstance(payload, dict):
processed += 1 if not only_missing:
processed += 1
continue
if only_missing and not use_missing_query and not _artwork_missing_for_payload(payload):
continue continue
poster_path, backdrop_path = _extract_artwork_paths(payload) poster_path, backdrop_path = _extract_artwork_paths(payload)
if not (poster_path or backdrop_path) and client.configured(): tmdb_id, media_type = _extract_tmdb_lookup(payload)
if (not poster_path or not backdrop_path) and client.configured() and tmdb_id and media_type:
media = payload.get("media") or {} media = payload.get("media") or {}
tmdb_id = media.get("tmdbId") or payload.get("tmdbId") hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb(
media_type = media.get("mediaType") or payload.get("type") client, media_type, tmdb_id
if tmdb_id and media_type: )
hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb( poster_path = poster_path or hydrated_poster
client, media_type, tmdb_id backdrop_path = backdrop_path or hydrated_backdrop
) if hydrated_poster or hydrated_backdrop:
poster_path = poster_path or hydrated_poster media = dict(media) if isinstance(media, dict) else {}
backdrop_path = backdrop_path or hydrated_backdrop if hydrated_poster:
if hydrated_poster or hydrated_backdrop: media["posterPath"] = hydrated_poster
media = dict(media) if isinstance(media, dict) else {} if hydrated_backdrop:
if hydrated_poster: media["backdropPath"] = hydrated_backdrop
media["posterPath"] = hydrated_poster payload["media"] = media
if hydrated_backdrop: parsed = _parse_request_payload(payload)
media["backdropPath"] = hydrated_backdrop request_id = parsed.get("request_id")
payload["media"] = media if isinstance(request_id, int):
parsed = _parse_request_payload(payload) upsert_request_cache(
request_id = parsed.get("request_id") request_id=request_id,
if isinstance(request_id, int): media_id=parsed.get("media_id"),
upsert_request_cache( media_type=parsed.get("media_type"),
request_id=request_id, status=parsed.get("status"),
media_id=parsed.get("media_id"), title=parsed.get("title"),
media_type=parsed.get("media_type"), year=parsed.get("year"),
status=parsed.get("status"), requested_by=parsed.get("requested_by"),
title=parsed.get("title"), requested_by_norm=parsed.get("requested_by_norm"),
year=parsed.get("year"), created_at=parsed.get("created_at"),
requested_by=parsed.get("requested_by"), updated_at=parsed.get("updated_at"),
requested_by_norm=parsed.get("requested_by_norm"), payload_json=json.dumps(payload, ensure_ascii=True),
created_at=parsed.get("created_at"), )
updated_at=parsed.get("updated_at"), poster_cached_flag = False
payload_json=json.dumps(payload, ensure_ascii=True), backdrop_cached_flag = False
)
if poster_path: if poster_path:
try: try:
await cache_tmdb_image(poster_path, "w185") poster_cached_flag = bool(
await cache_tmdb_image(poster_path, "w342") await cache_tmdb_image(poster_path, "w185")
) and bool(await cache_tmdb_image(poster_path, "w342"))
except httpx.HTTPError: except httpx.HTTPError:
pass poster_cached_flag = False
if backdrop_path: if backdrop_path:
try: try:
await cache_tmdb_image(backdrop_path, "w780") backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780"))
except httpx.HTTPError: except httpx.HTTPError:
pass backdrop_cached_flag = False
_upsert_artwork_status(
payload,
cache_mode,
poster_cached=poster_cached_flag if poster_path else None,
backdrop_cached=backdrop_cached_flag if backdrop_path else None,
)
processed += 1 processed += 1
if processed % 25 == 0: if processed % 25 == 0:
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
@@ -693,6 +874,15 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
) )
offset += limit offset += limit
total_requests = get_request_cache_count()
missing_count = get_artwork_cache_missing_count()
cache_bytes, cache_files = _collect_artwork_cache_disk_stats()
update_artwork_cache_stats(
cache_bytes=cache_bytes,
cache_files=cache_files,
missing_count=missing_count,
total_requests=total_requests,
)
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
{ {
"status": "completed", "status": "completed",
@@ -703,25 +893,52 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
) )
async def start_artwork_prefetch(base_url: Optional[str], api_key: Optional[str]) -> Dict[str, Any]: async def start_artwork_prefetch(
base_url: Optional[str], api_key: Optional[str], only_missing: bool = False
) -> Dict[str, Any]:
global _artwork_prefetch_task global _artwork_prefetch_task
if _artwork_prefetch_task and not _artwork_prefetch_task.done(): if _artwork_prefetch_task and not _artwork_prefetch_task.done():
return dict(_artwork_prefetch_state) return dict(_artwork_prefetch_state)
client = JellyseerrClient(base_url, api_key) client = JellyseerrClient(base_url, api_key)
status_count = get_artwork_cache_status_count()
total_requests = get_request_cache_count()
use_missing_query = only_missing and status_count >= total_requests and total_requests > 0
if only_missing and use_missing_query:
total = get_artwork_cache_missing_count()
else:
total = total_requests
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
{ {
"status": "running", "status": "running",
"processed": 0, "processed": 0,
"total": get_request_cache_count(), "total": total,
"message": "Starting artwork prefetch", "message": "Seeding artwork cache status"
if only_missing and not use_missing_query
else ("Starting missing artwork prefetch" if only_missing else "Starting artwork prefetch"),
"only_missing": only_missing,
"started_at": datetime.now(timezone.utc).isoformat(), "started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None, "finished_at": None,
} }
) )
if only_missing and total == 0:
_artwork_prefetch_state.update(
{
"status": "completed",
"processed": 0,
"message": "No missing artwork to cache.",
"finished_at": datetime.now(timezone.utc).isoformat(),
}
)
return dict(_artwork_prefetch_state)
async def _runner() -> None: async def _runner() -> None:
try: try:
await _prefetch_artwork_cache(client) await _prefetch_artwork_cache(
client,
only_missing=only_missing,
total=total,
use_missing_query=use_missing_query,
)
except Exception: except Exception:
logger.exception("Artwork prefetch failed") logger.exception("Artwork prefetch failed")
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
@@ -788,13 +1005,14 @@ def _get_recent_from_cache(
async def startup_warmup_requests_cache() -> None: async def startup_warmup_requests_cache() -> None:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): if client.configured():
return try:
try: await _ensure_requests_cache(client)
await _ensure_requests_cache(client) except httpx.HTTPError as exc:
except httpx.HTTPError as exc: logger.warning("Requests warmup skipped: %s", exc)
logger.warning("Requests warmup skipped: %s", exc) repaired = repair_request_cache_titles()
return if repaired:
logger.info("Requests cache titles repaired: %s", repaired)
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
@@ -942,7 +1160,10 @@ async def _ensure_request_access(
runtime = get_runtime_settings() runtime = get_runtime_settings()
mode = (runtime.requests_data_source or "prefer_cache").lower() mode = (runtime.requests_data_source or "prefer_cache").lower()
cached = get_request_cache_payload(request_id) cached = get_request_cache_payload(request_id)
if mode != "always_js" and cached is not None: if mode != "always_js":
if cached is None:
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
raise HTTPException(status_code=404, detail="Request not found in cache")
logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode) logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode)
if _request_matches_user(cached, user.get("username", "")): if _request_matches_user(cached, user.get("username", "")):
return return
@@ -999,6 +1220,148 @@ def _normalize_categories(categories: Any) -> List[str]:
return names return names
def _normalize_indexer_name(value: Optional[str]) -> str:
if not isinstance(value, str):
return ""
return "".join(ch for ch in value.lower().strip() if ch.isalnum())
def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None:
if exc.response is None:
logger.warning("%s %s failed: %s", service_label, action, exc)
return
status = exc.response.status_code
body = exc.response.text
if isinstance(body, str):
body = body.strip()
if len(body) > 800:
body = f"{body[:800]}...(truncated)"
logger.warning("%s %s failed: status=%s body=%s", service_label, action, status, body)
def _format_rejections(rejections: Any) -> Optional[str]:
if isinstance(rejections, str):
return rejections.strip() or None
if isinstance(rejections, list):
reasons = []
for item in rejections:
reason = None
if isinstance(item, dict):
reason = (
item.get("reason")
or item.get("message")
or item.get("errorMessage")
)
if not reason and item is not None:
reason = str(item)
if isinstance(reason, str) and reason.strip():
reasons.append(reason.strip())
if reasons:
return "; ".join(reasons)
return None
def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
if not isinstance(response, dict):
return True, None
rejections = response.get("rejections") or response.get("rejectionReasons")
reason = _format_rejections(rejections)
if reason:
return False, reason
if response.get("rejected") is True:
return False, "rejected"
if response.get("downloadAllowed") is False:
return False, "download not allowed"
if response.get("approved") is False:
return False, "not approved"
return True, None
def _resolve_arr_indexer_id(
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
) -> Optional[int]:
if not isinstance(indexers, list):
return None
if not indexer_name:
if indexer_id is None:
return None
by_id = next(
(item for item in indexers if isinstance(item, dict) and item.get("id") == indexer_id),
None,
)
if by_id and by_id.get("id") is not None:
logger.debug("%s indexer id match: %s", service_label, by_id.get("id"))
return int(by_id["id"])
return None
target = indexer_name.lower().strip()
target_compact = _normalize_indexer_name(indexer_name)
exact = next(
(
item
for item in indexers
if isinstance(item, dict)
and str(item.get("name", "")).lower().strip() == target
),
None,
)
if exact and exact.get("id") is not None:
logger.debug("%s indexer match: '%s' -> %s", service_label, indexer_name, exact.get("id"))
return int(exact["id"])
compact = next(
(
item
for item in indexers
if isinstance(item, dict)
and _normalize_indexer_name(str(item.get("name", ""))) == target_compact
),
None,
)
if compact and compact.get("id") is not None:
logger.debug("%s indexer compact match: '%s' -> %s", service_label, indexer_name, compact.get("id"))
return int(compact["id"])
contains = next(
(
item
for item in indexers
if isinstance(item, dict)
and target in str(item.get("name", "")).lower()
),
None,
)
if contains and contains.get("id") is not None:
logger.debug("%s indexer contains match: '%s' -> %s", service_label, indexer_name, contains.get("id"))
return int(contains["id"])
logger.warning(
"%s indexer not found for name '%s'. Check indexer names in the Arr app.",
service_label,
indexer_name,
)
return None
async def _fallback_qbittorrent_download(download_url: Optional[str], category: str) -> bool:
if not download_url:
return False
runtime = get_runtime_settings()
client = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not client.configured():
return False
await client.add_torrent_url(download_url, category=category)
return True
def _resolve_qbittorrent_category(value: Optional[str], default: str) -> str:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return default
def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Dict[str, Any]]: def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Dict[str, Any]]:
if not isinstance(results, list): if not isinstance(results, list):
return [] return []
@@ -1081,13 +1444,15 @@ async def recent_requests(
) -> dict: ) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): mode = (runtime.requests_data_source or "prefer_cache").lower()
raise HTTPException(status_code=400, detail="Jellyseerr not configured") allow_remote = mode == "always_js"
if allow_remote:
try: if not client.configured():
await _ensure_requests_cache(client) raise HTTPException(status_code=400, detail="Jellyseerr not configured")
except httpx.HTTPStatusError as exc: try:
raise HTTPException(status_code=502, detail=str(exc)) from exc await _ensure_requests_cache(client)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
username_norm = _normalize_username(user.get("username", "")) username_norm = _normalize_username(user.get("username", ""))
requested_by = None if user.get("role") == "admin" else username_norm requested_by = None if user.get("role") == "admin" else username_norm
@@ -1098,10 +1463,8 @@ async def recent_requests(
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, take, skip, since_iso) rows = _get_recent_from_cache(requested_by, take, skip, since_iso)
cache_mode = (runtime.artwork_cache_mode or "remote").lower() cache_mode = (runtime.artwork_cache_mode or "remote").lower()
mode = (runtime.requests_data_source or "prefer_cache").lower() allow_title_hydrate = False
allow_remote = mode == "always_js" allow_artwork_hydrate = client.configured()
allow_title_hydrate = mode == "prefer_cache"
allow_artwork_hydrate = allow_remote or allow_title_hydrate
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {} jellyfin_cache: Dict[str, bool] = {}
@@ -1607,78 +1970,42 @@ async def action_grab(
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
guid = payload.get("guid") guid = payload.get("guid")
indexer_id = payload.get("indexerId") indexer_id = payload.get("indexerId")
indexer_name = payload.get("indexerName") or payload.get("indexer")
download_url = payload.get("downloadUrl") download_url = payload.get("downloadUrl")
release_title = payload.get("title")
release_size = payload.get("size")
release_protocol = payload.get("protocol") or "torrent"
release_publish = payload.get("publishDate")
release_seeders = payload.get("seeders")
release_leechers = payload.get("leechers")
if not guid or not indexer_id: if not guid or not indexer_id:
raise HTTPException(status_code=400, detail="Missing guid or indexerId") raise HTTPException(status_code=400, detail="Missing guid or indexerId")
runtime = get_runtime_settings() logger.info(
if snapshot.request_type.value == "tv": "Grab requested: request_id=%s guid=%s indexer_id=%s indexer_name=%s has_download_url=%s has_title=%s",
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) request_id,
if not client.configured(): guid,
raise HTTPException(status_code=400, detail="Sonarr not configured") indexer_id,
try: indexer_name,
response = await client.grab_release(str(guid), int(indexer_id)) bool(download_url),
except httpx.HTTPStatusError as exc: bool(release_title),
status_code = exc.response.status_code if exc.response is not None else 502 )
if status_code == 404 and download_url:
qbit = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr."
)
return {"status": "ok", "response": response}
if snapshot.request_type.value == "movie":
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
response = await client.grab_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else 502
if status_code == 404 and download_url:
qbit = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr."
)
return {"status": "ok", "response": response}
raise HTTPException(status_code=400, detail="Unknown request type") runtime = get_runtime_settings()
if not download_url:
raise HTTPException(status_code=400, detail="Missing downloadUrl")
if snapshot.request_type.value == "tv":
category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
if snapshot.request_type.value == "movie":
category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
if snapshot.request_type.value not in {"tv", "movie"}:
raise HTTPException(status_code=400, detail="Unknown request type")
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
if not qbittorrent_added:
raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent")
action_message = f"Grab sent to qBittorrent (category {category})."
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", action_message
)
return {"status": "ok", "response": {"qbittorrent": "queued"}}

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ _INT_FIELDS = {
} }
_BOOL_FIELDS = { _BOOL_FIELDS = {
"jellyfin_sync_to_arr", "jellyfin_sync_to_arr",
"site_banner_enabled",
} }

View File

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

BIN
data/branding/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
data/branding/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

19
docker-compose.hub.yml Normal file
View File

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

View File

@@ -8,6 +8,7 @@ COPY package.json ./
RUN npm install RUN npm install
COPY app ./app COPY app ./app
COPY public ./public
COPY next-env.d.ts ./next-env.d.ts COPY next-env.d.ts ./next-env.d.ts
COPY next.config.js ./next.config.js COPY next.config.js ./next.config.js
COPY tsconfig.json ./tsconfig.json COPY tsconfig.json ./tsconfig.json
@@ -22,6 +23,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js ./next.config.js COPY --from=builder /app/next.config.js ./next.config.js

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
@@ -21,35 +21,39 @@ type ServiceOptions = {
const SECTION_LABELS: Record<string, string> = { const SECTION_LABELS: Record<string, string> = {
jellyseerr: 'Jellyseerr', jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
artwork: 'Artwork', artwork: 'Artwork cache',
cache: 'Cache', cache: 'Cache Control',
sonarr: 'Sonarr', sonarr: 'Sonarr',
radarr: 'Radarr', radarr: 'Radarr',
prowlarr: 'Prowlarr', prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent', qbittorrent: 'qBittorrent',
log: 'Activity log', log: 'Activity log',
requests: 'Request syncing', requests: 'Request sync',
site: 'Site',
} }
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr']) const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.', jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.', jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Configure how posters and artwork are loaded.', artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved request data and offline artwork.', cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'TV automation settings.', sonarr: 'TV automation settings.',
radarr: 'Movie automation settings.', radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.', prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.', qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.', requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.', log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
} }
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
jellyseerr: 'jellyseerr', jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin', jellyfin: 'jellyfin',
artwork: 'artwork', artwork: null,
sonarr: 'sonarr', sonarr: 'sonarr',
radarr: 'radarr', radarr: 'radarr',
prowlarr: 'prowlarr', prowlarr: 'prowlarr',
@@ -58,6 +62,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
cache: null, cache: null,
logs: 'log', logs: 'log',
maintenance: null, maintenance: null,
site: 'site',
} }
const labelFromKey = (key: string) => const labelFromKey = (key: string) =>
@@ -68,16 +73,34 @@ const labelFromKey = (key: string) =>
.replace('quality profile id', 'Quality profile ID') .replace('quality profile id', 'Quality profile ID')
.replace('root folder', 'Root folder') .replace('root folder', 'Root folder')
.replace('qbittorrent', 'qBittorrent') .replace('qbittorrent', 'qBittorrent')
.replace('requests sync ttl minutes', 'Refresh saved requests if older than (minutes)') .replace('requests sync ttl minutes', 'Saved request refresh TTL (minutes)')
.replace('requests poll interval seconds', 'Background refresh check (seconds)') .replace('requests poll interval seconds', 'Full refresh check interval (seconds)')
.replace('requests delta sync interval minutes', 'Check for new or updated requests every (minutes)') .replace('requests delta sync interval minutes', 'Delta sync interval (minutes)')
.replace('requests full sync time', 'Full refresh time (24h)') .replace('requests full sync time', 'Daily full refresh time (24h)')
.replace('requests cleanup time', 'Clean up old history time (24h)') .replace('requests cleanup time', 'Daily history cleanup time (24h)')
.replace('requests cleanup days', 'Remove history older than (days)') .replace('requests cleanup days', 'History retention window (days)')
.replace('requests data source', 'Where requests are loaded from') .replace('requests data source', 'Request source (cache vs Jellyseerr)')
.replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode') .replace('artwork cache mode', 'Artwork cache mode')
.replace('site build number', 'Build number')
.replace('site banner enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message')
.replace('site banner tone', 'Sitewide banner tone')
.replace('site changelog', 'Changelog text')
const formatBytes = (value?: number | null) => {
if (!value || value <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
const decimals = unitIndex === 0 || size >= 10 ? 0 : 1
return `${size.toFixed(decimals)} ${units[unitIndex]}`
}
type SettingsPageProps = { type SettingsPageProps = {
section: string section: string
@@ -102,12 +125,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [cacheRows, setCacheRows] = useState<any[]>([]) const [cacheRows, setCacheRows] = useState<any[]>([])
const [cacheCount, setCacheCount] = useState(50) const [cacheCount, setCacheCount] = useState(50)
const [cacheStatus, setCacheStatus] = useState<string | null>(null) const [cacheStatus, setCacheStatus] = useState<string | null>(null)
const [cacheLoading, setCacheLoading] = useState(false)
const [requestsSync, setRequestsSync] = useState<any | null>(null) const [requestsSync, setRequestsSync] = useState<any | null>(null)
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null) const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [artworkSummary, setArtworkSummary] = useState<any | null>(null)
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null) const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false) const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const loadSettings = async () => { const loadSettings = useCallback(async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`) const response = await authFetch(`${baseUrl}/admin/settings`)
if (!response.ok) { if (!response.ok) {
@@ -139,9 +165,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
setFormValues(initialValues) setFormValues(initialValues)
setStatus(null) setStatus(null)
} }, [router])
const loadArtworkPrefetchStatus = async () => { const loadArtworkPrefetchStatus = useCallback(async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`) const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
@@ -153,10 +179,30 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
} }, [])
const loadArtworkSummary = useCallback(async () => {
setArtworkSummaryStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/summary`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Artwork summary fetch failed')
}
const data = await response.json()
setArtworkSummary(data?.summary ?? null)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load artwork stats.'
setArtworkSummaryStatus(message)
}
}, [])
const loadOptions = async (service: 'sonarr' | 'radarr') => { const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/${service}/options`) const response = await authFetch(`${baseUrl}/admin/${service}/options`)
@@ -185,7 +231,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRadarrError('Could not load Radarr options.') setRadarrError('Could not load Radarr options.')
} }
} }
} }, [])
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@@ -195,8 +241,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
try { try {
await loadSettings() await loadSettings()
if (section === 'artwork') { if (section === 'cache' || section === 'artwork') {
await loadArtworkPrefetchStatus() await loadArtworkPrefetchStatus()
await loadArtworkSummary()
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -213,7 +260,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') { if (section === 'radarr') {
void loadOptions('radarr') void loadOptions('radarr')
} }
}, [router, section]) }, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
const groupedSettings = useMemo(() => { const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {} const groups: Record<string, AdminSetting[]> = {}
@@ -228,28 +275,51 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const visibleSections = settingsSection ? [settingsSection] : [] const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache' const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set([ const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
'requests_sync_ttl_minutes', const artworkSettingKeys = new Set(['artwork_cache_mode'])
'requests_data_source', const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys])
'artwork_cache_mode', const requestSettingOrder = [
]) 'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_full_sync_time',
'requests_cleanup_time',
'requests_cleanup_days',
]
const sortByOrder = (items: AdminSetting[], order: string[]) => {
const position = new Map(order.map((key, index) => [key, index]))
return [...items].sort((a, b) => {
const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
if (aIndex !== bIndex) return aIndex - bIndex
return a.key.localeCompare(b.key)
})
}
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key)) const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const settingsSections = isCacheSection const settingsSections = isCacheSection
? [{ key: 'cache', title: 'Cache settings', items: cacheSettings }] ? [
{ key: 'cache', title: 'Cache control', items: cacheSettings },
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
]
: visibleSections.map((sectionKey) => ({ : visibleSections.map((sectionKey) => ({
key: sectionKey, key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey, title: SECTION_LABELS[sectionKey] ?? sectionKey,
items: items: (() => {
sectionKey === 'requests' || sectionKey === 'artwork' const sectionItems = groupedSettings[sectionKey] ?? []
? (groupedSettings[sectionKey] ?? []).filter( const filtered =
(setting) => !cacheSettingKeys.has(setting.key) sectionKey === 'requests' || sectionKey === 'artwork'
) ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: groupedSettings[sectionKey] ?? [], : sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
}
return filtered
})(),
})) }))
const showLogs = section === 'logs' const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance' const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests' const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork' const showArtworkExtras = section === 'cache'
const showCacheExtras = section === 'cache' const showCacheExtras = section === 'cache'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => { const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true if (sectionGroup.items && sectionGroup.items.length > 0) return true
@@ -271,24 +341,34 @@ export default function SettingsPage({ section }: SettingsPageProps) {
sonarr_api_key: 'API key for Sonarr.', sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores 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.',
radarr_api_key: 'API key for Radarr.', radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.', radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores 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.',
prowlarr_api_key: 'API key for Prowlarr.', 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.',
qbittorrent_username: 'qBittorrent login username.', qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.', qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.', requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
requests_poll_interval_seconds: 'How often the background checker runs.', requests_poll_interval_seconds:
requests_delta_sync_interval_minutes: 'How often we check for new or updated requests.', 'How often Magent checks if a full refresh should run.',
requests_full_sync_time: 'Daily time to refresh the full request list.', requests_delta_sync_interval_minutes:
requests_cleanup_time: 'Daily time to trim old history.', 'How often we poll for new or updated requests.',
requests_full_sync_time: 'Daily time to rebuild the full request cache.',
requests_cleanup_time: 'Daily time to trim old request history.',
requests_cleanup_days: 'History older than this is removed during cleanup.', requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source: 'Pick where Magent should read requests from.', requests_data_source:
'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.',
log_level: 'How much detail is written to the activity log.', log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.', log_file: 'Where the activity log is stored.',
site_build_number: 'Build number shown in the account menu (auto-set from releases).',
site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.',
site_changelog: 'One update per line for the public changelog.',
} }
const buildSelectOptions = ( const buildSelectOptions = (
@@ -446,6 +526,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
} }
const prefetchArtworkMissing = async () => {
setArtworkPrefetchStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/requests/artwork/prefetch?only_missing=1`,
{ method: 'POST' }
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Missing artwork prefetch failed')
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
setArtworkPrefetchStatus('Missing artwork caching started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not cache missing artwork.'
setArtworkPrefetchStatus(message)
}
}
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') { if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
return return
@@ -463,6 +568,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(data?.prefetch ?? null) setArtworkPrefetch(data?.prefetch ?? null)
if (data?.prefetch?.status && data.prefetch.status !== 'running') { if (data?.prefetch?.status && data.prefetch.status !== 'running') {
setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.') setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.')
void loadArtworkSummary()
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -472,7 +578,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [artworkPrefetch?.status]) }, [artworkPrefetch, loadArtworkSummary])
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') { if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -482,7 +588,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(null) setArtworkPrefetch(null)
}, 5000) }, 5000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [artworkPrefetch?.status]) }, [artworkPrefetch])
useEffect(() => { useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') { if (!requestsSync || requestsSync.status !== 'running') {
@@ -510,7 +616,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [requestsSync?.status]) }, [requestsSync])
useEffect(() => { useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') { if (!requestsSync || requestsSync.status === 'running') {
@@ -520,9 +626,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setRequestsSync(null) setRequestsSync(null)
}, 5000) }, 5000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [requestsSync?.status]) }, [requestsSync])
const loadLogs = async () => { const loadLogs = useCallback(async () => {
setLogsStatus(null) setLogsStatus(null)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
@@ -547,7 +653,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
: 'Could not load logs.' : 'Could not load logs.'
setLogsStatus(message) setLogsStatus(message)
} }
} }, [logsCount])
useEffect(() => { useEffect(() => {
if (!showLogs) { if (!showLogs) {
@@ -558,10 +664,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
void loadLogs() void loadLogs()
}, 5000) }, 5000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [logsCount, showLogs]) }, [loadLogs, showLogs])
const loadCache = async () => { const loadCache = async () => {
setCacheStatus(null) setCacheStatus(null)
setCacheLoading(true)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
@@ -584,6 +691,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '') ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load cache.' : 'Could not load cache.'
setCacheStatus(message) setCacheStatus(message)
} finally {
setCacheLoading(false)
} }
} }
@@ -698,7 +807,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
.map((sectionGroup) => ( .map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section"> <section key={sectionGroup.key} className="admin-section">
<div className="section-header"> <div className="section-header">
<h2>{sectionGroup.title}</h2> <h2>
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
</h2>
{sectionGroup.key === 'sonarr' && ( {sectionGroup.key === 'sonarr' && (
<button type="button" onClick={() => loadOptions('sonarr')}> <button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options Refresh Sonarr options
@@ -714,24 +825,38 @@ export default function SettingsPage({ section }: SettingsPageProps) {
Import Jellyfin users Import Jellyfin users
</button> </button>
)} )}
{(showArtworkExtras && sectionGroup.key === 'artwork') || {showArtworkExtras && sectionGroup.key === 'artwork' ? (
(showCacheExtras && sectionGroup.key === 'cache') ? ( <div className="sync-actions">
<button type="button" onClick={prefetchArtwork}> <button type="button" onClick={prefetchArtwork}>
Cache all artwork now Cache all artwork now
</button> </button>
<button
type="button"
className="ghost-button"
onClick={prefetchArtworkMissing}
>
Sync only missing artwork
</button>
</div>
) : null} ) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && ( {showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="sync-actions"> <div className="sync-actions-block">
<button type="button" onClick={syncRequests}> <div className="sync-actions">
Full refresh <button type="button" onClick={syncRequests}>
</button> Run full refresh (rebuild cache)
<button type="button" className="ghost-button" onClick={syncRequestsDelta}> </button>
Quick refresh (new changes) <button type="button" className="ghost-button" onClick={syncRequestsDelta}>
</button> Run delta sync (recent changes)
</button>
</div>
<div className="meta sync-note">
Full refresh rebuilds the entire cache. Delta sync only checks new or updated
requests.
</div>
</div> </div>
)} )}
</div> </div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && ( {SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p> <p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
)} )}
{sectionGroup.key === 'sonarr' && sonarrError && ( {sectionGroup.key === 'sonarr' && sonarrError && (
@@ -743,17 +868,48 @@ export default function SettingsPage({ section }: SettingsPageProps) {
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && ( {sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
<div className="status-banner">{jellyfinSyncStatus}</div> <div className="status-banner">{jellyfinSyncStatus}</div>
)} )}
{((showArtworkExtras && sectionGroup.key === 'artwork') || {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
(showCacheExtras && sectionGroup.key === 'cache')) &&
artworkPrefetchStatus && (
<div className="status-banner">{artworkPrefetchStatus}</div> <div className="status-banner">{artworkPrefetchStatus}</div>
)} )}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkSummaryStatus && (
<div className="status-banner">{artworkSummaryStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && (
<div className="summary">
<div className="summary-card">
<strong>Missing artwork</strong>
<p>{artworkSummary?.missing_artwork ?? '--'}</p>
<div className="meta">Requests missing poster/backdrop or cache files.</div>
</div>
<div className="summary-card">
<strong>Artwork cache size</strong>
<p>{formatBytes(artworkSummary?.cache_bytes)}</p>
<div className="meta">
{artworkSummary?.cache_files ?? '--'} cached files
</div>
</div>
<div className="summary-card">
<strong>Total requests</strong>
<p>{artworkSummary?.total_requests ?? '--'}</p>
<div className="meta">Requests currently tracked in cache.</div>
</div>
<div className="summary-card">
<strong>Cache mode</strong>
<p>{artworkSummary?.cache_mode ?? '--'}</p>
<div className="meta">Artwork setting applied to posters/backdrops.</div>
</div>
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && ( {showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
<div className="status-banner">{requestsSyncStatus}</div> <div className="status-banner">{requestsSyncStatus}</div>
)} )}
{((showArtworkExtras && sectionGroup.key === 'artwork') || {showRequestsExtras && sectionGroup.key === 'requests' && (
(showCacheExtras && sectionGroup.key === 'cache')) && <div className="status-banner">
artworkPrefetch && ( Full refresh checks only decide when to run a full refresh. The delta sync interval
polls for new or updated requests.
</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetch && (
<div className="sync-progress"> <div className="sync-progress">
<div className="sync-meta"> <div className="sync-meta">
<span>Status: {artworkPrefetch.status}</span> <span>Status: {artworkPrefetch.status}</span>
@@ -1007,6 +1163,34 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (setting.key === 'site_banner_tone') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'info'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
{BANNER_TONES.map((tone) => (
<option key={tone} value={tone}>
{tone.charAt(0).toUpperCase() + tone.slice(1)}
</option>
))}
</select>
</label>
)
}
if ( if (
setting.key === 'requests_full_sync_time' || setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time' setting.key === 'requests_cleanup_time'
@@ -1080,11 +1264,42 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
> >
<option value="always_js">Always use Jellyseerr (slower)</option> <option value="always_js">Always use Jellyseerr (slower)</option>
<option value="prefer_cache">Use saved requests first (faster)</option> <option value="prefer_cache">
Use saved requests only (fastest)
</option>
</select> </select>
</label> </label>
) )
} }
if (TEXTAREA_SETTINGS.has(setting.key)) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? '  stored' : ''}
</span>
</span>
<textarea
name={setting.key}
rows={setting.key === 'site_changelog' ? 6 : 3}
placeholder={
setting.key === 'site_changelog'
? 'One update per line.'
: ''
}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row"> <span className="label-row">
@@ -1121,7 +1336,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</form> </form>
) : ( ) : (
<div className="status-banner"> <div className="status-banner">
No settings to show here yet. Try the Cache page for artwork and saved-request controls. No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.
</div> </div>
)} )}
{showLogs && ( {showLogs && (
@@ -1167,8 +1382,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<option value={200}>200</option> <option value={200}>200</option>
</select> </select>
</label> </label>
<button type="button" onClick={loadCache}> <button type="button" onClick={loadCache} disabled={cacheLoading}>
Load saved requests {cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button> </button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,8 @@ export default function HomePage() {
>(null) >(null)
const [servicesLoading, setServicesLoading] = useState(false) const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null) const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -42,6 +44,61 @@ export default function HomePage() {
void runSearch(trimmed) void runSearch(trimmed)
} }
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
const updateServiceStatus = (name: string, status: string, message?: string) => {
setServicesStatus((prev) => {
if (!prev) return prev
return {
...prev,
services: prev.services.map((service) =>
service.name === name ? { ...service, status, message } : service
),
}
})
}
const testService = async (name: string) => {
const slug = toServiceSlug(name)
setServiceTesting((prev) => ({ ...prev, [name]: true }))
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const data = await response.json()
const status = data?.status ?? 'unknown'
const message =
data?.message ||
(status === 'up'
? 'API OK'
: status === 'down'
? 'API unreachable'
: status === 'degraded'
? 'Health warnings'
: status === 'not_configured'
? 'Not configured'
: 'Unknown')
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
updateServiceStatus(name, status, data?.message)
} catch (error) {
console.error(error)
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
} finally {
setServiceTesting((prev) => ({ ...prev, [name]: false }))
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -214,21 +271,37 @@ export default function HomePage() {
return order.map((name) => { return order.map((name) => {
const item = items.find((entry) => entry.name === name) const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown' const status = item?.status ?? 'unknown'
const testing = serviceTesting[name] ?? false
return ( return (
<div key={name} className={`system-item system-${status}`}> <div key={name} className={`system-item system-${status}`}>
<span className="system-dot" /> <span className="system-dot" />
<span className="system-name">{name}</span> <div className="system-meta">
<span className="system-state"> <span className="system-name">{name}</span>
{status === 'up' {serviceTestResults[name] && (
? 'Up' <span className="system-test-message">{serviceTestResults[name]}</span>
: status === 'down' )}
? 'Down' </div>
: status === 'degraded' <div className="system-actions">
? 'Needs attention' <span className="system-state">
: status === 'not_configured' {status === 'up'
? 'Not configured' ? 'Up'
: 'Unknown'} : status === 'down'
</span> ? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div> </div>
) )
}) })

View File

@@ -10,9 +10,65 @@ type ProfileInfo = {
auth_provider: string auth_provider: string
} }
type ProfileStats = {
total: number
ready: number
pending: number
in_progress: number
declined: number
working: number
partial: number
approved: number
last_request_at?: string | null
share: number
global_total: number
most_active_user?: { username: string; total: number } | null
}
type ActivityEntry = {
ip: string
user_agent: string
first_seen_at: string
last_seen_at: string
hit_count: number
}
type ProfileActivity = {
last_ip?: string | null
last_user_agent?: string | null
last_seen_at?: string | null
device_count: number
recent: ActivityEntry[]
}
type ProfileResponse = {
user: ProfileInfo
stats: ProfileStats
activity: ProfileActivity
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const parseBrowser = (agent?: string | null) => {
if (!agent) return 'Unknown'
const value = agent.toLowerCase()
if (value.includes('edg/')) return 'Edge'
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
if (value.includes('firefox/')) return 'Firefox'
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
return 'Unknown'
}
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter() const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null) const [profile, setProfile] = useState<ProfileInfo | null>(null)
const [stats, setStats] = useState<ProfileStats | null>(null)
const [activity, setActivity] = useState<ProfileActivity | null>(null)
const [currentPassword, setCurrentPassword] = useState('') const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
@@ -26,18 +82,21 @@ export default function ProfilePage() {
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`) const response = await authFetch(`${baseUrl}/auth/profile`)
if (!response.ok) { if (!response.ok) {
clearToken() clearToken()
router.push('/login') router.push('/login')
return return
} }
const data = await response.json() const data = await response.json()
const user = data?.user ?? {}
setProfile({ setProfile({
username: data?.username ?? 'Unknown', username: user?.username ?? 'Unknown',
role: data?.role ?? 'user', role: user?.role ?? 'user',
auth_provider: data?.auth_provider ?? 'local', auth_provider: user?.auth_provider ?? 'local',
}) })
setStats(data?.stats ?? null)
setActivity(data?.activity ?? null)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('Could not load your profile.') setStatus('Could not load your profile.')
@@ -91,6 +150,78 @@ export default function ProfilePage() {
{profile.auth_provider}. {profile.auth_provider}.
</div> </div>
)} )}
<div className="profile-grid">
<section className="profile-section">
<h2>Account stats</h2>
<div className="stat-grid">
<div className="stat-card">
<div className="stat-label">Requests submitted</div>
<div className="stat-value">{stats?.total ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Ready to watch</div>
<div className="stat-value">{stats?.ready ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">In progress</div>
<div className="stat-value">{stats?.in_progress ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Pending approval</div>
<div className="stat-value">{stats?.pending ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Declined</div>
<div className="stat-value">{stats?.declined ?? 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Last request</div>
<div className="stat-value stat-value--small">
{formatDate(stats?.last_request_at)}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Share of all requests</div>
<div className="stat-value">
{stats?.global_total
? `${Math.round((stats.share || 0) * 1000) / 10}%`
: '0%'}
</div>
</div>
{profile?.role === 'admin' ? (
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
</div>
</div>
) : null}
</div>
</section>
<section className="profile-section">
<h2>Connection history</h2>
<div className="status-banner">
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
</div>
<div className="connection-list">
{(activity?.recent ?? []).map((entry, index) => (
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
<div>
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
<div className="meta">IP: {entry.ip}</div>
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
</div>
<div className="connection-count">{entry.hit_count} visits</div>
</div>
))}
{activity && activity.recent.length === 0 ? (
<div className="status-banner">No connection history yet.</div>
) : null}
</div>
</section>
</div>
{profile?.auth_provider !== 'local' ? ( {profile?.auth_provider !== 'local' ? (
<div className="status-banner"> <div className="status-banner">
Password changes are only available for local Magent accounts. Password changes are only available for local Magent accounts.

View File

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

View File

@@ -17,14 +17,14 @@ const NAV_GROUPS = [
{ {
title: 'Requests', title: 'Requests',
items: [ items: [
{ href: '/admin/requests', label: 'Request syncing' }, { href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/artwork', label: 'Artwork' }, { href: '/admin/cache', label: 'Cache Control' },
{ href: '/admin/cache', label: 'Cache' },
], ],
}, },
{ {
title: 'Admin', title: 'Admin',
items: [ items: [
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' }, { href: '/users', label: 'Users' },
{ href: '/admin/logs', label: 'Activity log' }, { href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' }, { href: '/admin/maintenance', label: 'Maintenance' },

View File

@@ -4,12 +4,11 @@ import { useEffect } from 'react'
export default function BrandingFavicon() { export default function BrandingFavicon() {
useEffect(() => { useEffect(() => {
const href = '/branding-icon.svg' const href = '/api/branding/favicon.ico'
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
if (!link) { if (!link) {
link = document.createElement('link') link = document.createElement('link')
link.rel = 'icon' link.rel = 'icon'
link.type = 'image/svg+xml'
document.head.appendChild(link) document.head.appendChild(link)
} }
link.href = href link.href = href

View File

@@ -7,7 +7,7 @@ export default function BrandingLogo({ className, alt = 'Magent logo' }: Brandin
return ( return (
<img <img
className={className} className={className}
src="/branding-logo.svg" src="/api/branding/logo.png"
alt={alt} alt={alt}
/> />
) )

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,8 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [passwordInputs, setPasswordInputs] = useState<Record<string, string>>({}) const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(null)
const [passwordStatus, setPasswordStatus] = useState<Record<string, string>>({}) const [jellyfinSyncBusy, setJellyfinSyncBusy] = useState(false)
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -105,40 +105,26 @@ export default function UsersPage() {
} }
} }
const updateUserPassword = async (username: string) => { const syncJellyfinUsers = async () => {
const newPassword = passwordInputs[username] || '' setJellyfinSyncStatus(null)
if (!newPassword || newPassword.length < 8) { setJellyfinSyncBusy(true)
setPasswordStatus((current) => ({
...current,
[username]: 'Password must be at least 8 characters.',
}))
return
}
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, {
`${baseUrl}/admin/users/${encodeURIComponent(username)}/password`, method: 'POST',
{ })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword }),
}
)
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text()
throw new Error(text || 'Update failed') throw new Error(text || 'Sync failed')
} }
setPasswordInputs((current) => ({ ...current, [username]: '' })) const data = await response.json()
setPasswordStatus((current) => ({ setJellyfinSyncStatus(`Synced ${data?.imported ?? 0} Jellyfin users.`)
...current, await loadUsers()
[username]: 'Password updated.',
}))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setPasswordStatus((current) => ({ setJellyfinSyncStatus('Could not sync Jellyfin users.')
...current, } finally {
[username]: 'Could not update password.', setJellyfinSyncBusy(false)
}))
} }
} }
@@ -159,13 +145,19 @@ export default function UsersPage() {
title="Users" title="Users"
subtitle="Manage who can use Magent." subtitle="Manage who can use Magent."
actions={ actions={
<button type="button" onClick={loadUsers}> <>
Reload list <button type="button" onClick={loadUsers}>
</button> Reload list
</button>
<button type="button" onClick={syncJellyfinUsers} disabled={jellyfinSyncBusy}>
{jellyfinSyncBusy ? 'Syncing Jellyfin users...' : 'Sync Jellyfin users'}
</button>
</>
} }
> >
<section className="admin-section"> <section className="admin-section">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{jellyfinSyncStatus && <div className="status-banner">{jellyfinSyncStatus}</div>}
{users.length === 0 ? ( {users.length === 0 ? (
<div className="status-banner">No users found yet.</div> <div className="status-banner">No users found yet.</div>
) : ( ) : (
@@ -174,9 +166,11 @@ export default function UsersPage() {
<div key={user.username} className="summary-card user-card"> <div key={user.username} className="summary-card user-card">
<div> <div>
<strong>{user.username}</strong> <strong>{user.username}</strong>
<span className="meta">Role: {user.role}</span> <div className="user-meta">
<span className="meta">Login type: {user.authProvider || 'local'}</span> <span className="meta">Role: {user.role}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span> <span className="meta">Login type: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
</div>
</div> </div>
<div className="user-actions"> <div className="user-actions">
<label className="toggle"> <label className="toggle">
@@ -197,27 +191,6 @@ export default function UsersPage() {
{user.isBlocked ? 'Allow access' : 'Block access'} {user.isBlocked ? 'Allow access' : 'Block access'}
</button> </button>
</div> </div>
{user.authProvider === 'local' && (
<div className="user-actions">
<input
type="password"
placeholder="New password (min 8 chars)"
value={passwordInputs[user.username] || ''}
onChange={(event) =>
setPasswordInputs((current) => ({
...current,
[user.username]: event.target.value,
}))
}
/>
<button type="button" onClick={() => updateUserPassword(user.username)}>
Set password
</button>
</div>
)}
{passwordStatus[user.username] && (
<div className="meta">{passwordStatus[user.username]}</div>
)}
</div> </div>
))} ))}
</div> </div>

26
scripts/build_release.ps1 Normal file
View File

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