2 Commits

Author SHA1 Message Date
619708cae0 Align referral uses field 2026-01-23 23:07:13 +13:00
af67c888c6 Fix invite referral uses hint layout 2026-01-23 23:05:09 +13:00
31 changed files with 1051 additions and 1515 deletions

View File

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

View File

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

View File

@@ -1,28 +1,15 @@
from typing import Dict, Any from typing import Dict, Any
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from .db import get_user_by_username, upsert_user_activity from .db import get_user_by_username
from .security import safe_decode_token, TokenError 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:
@@ -38,11 +25,6 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
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,13 +58,3 @@ 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,6 +1,5 @@
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
@@ -9,7 +8,6 @@ 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)
@@ -74,14 +72,6 @@ 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,9 +21,6 @@ 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]})
@@ -46,12 +43,3 @@ 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,9 +18,6 @@ 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})
@@ -53,12 +50,3 @@ 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,21 +38,6 @@ 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")
@@ -85,10 +70,6 @@ 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")
@@ -102,10 +83,6 @@ 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

@@ -103,32 +103,6 @@ def init_db() -> None:
ON requests_cache (requested_by_norm) ON requests_cache (requested_by_norm)
""" """
) )
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:
@@ -403,164 +377,6 @@ 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],
@@ -793,22 +609,6 @@ def get_request_cache_count() -> int:
return int(row[0] or 0) return int(row[0] or 0)
def update_request_cache_title(
request_id: int, title: str, year: Optional[int] = None
) -> None:
if not title:
return
with _connect() as conn:
conn.execute(
"""
UPDATE requests_cache
SET title = ?, year = COALESCE(?, year)
WHERE request_id = ?
""",
(title, year, request_id),
)
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(

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, set_setting from .db import init_db
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,7 +18,6 @@ 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
@@ -41,8 +40,6 @@ 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())
@@ -59,4 +56,3 @@ 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

@@ -20,7 +20,6 @@ from ..db import (
clear_requests_cache, clear_requests_cache,
clear_history, clear_history,
cleanup_history, cleanup_history,
update_request_cache_title,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -57,12 +56,10 @@ 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",
@@ -77,11 +74,6 @@ 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]]:
@@ -282,28 +274,7 @@ 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]:
rows = get_request_cache_overview(limit) return {"rows": get_request_cache_overview(limit)}
missing_titles = [row for row in rows if not row.get("title")]
if missing_titles:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
for row in missing_titles:
request_id = row.get("request_id")
if not isinstance(request_id, int):
continue
details = await requests_router._get_request_details(client, request_id)
if not isinstance(details, dict):
continue
payload = requests_router._parse_request_payload(details)
title = payload.get("title")
if not title:
continue
row["title"] = title
if payload.get("year"):
row["year"] = payload.get("year")
update_request_cache_title(request_id, title, payload.get("year"))
return {"rows": rows}
@router.post("/branding/logo") @router.post("/branding/logo")

View File

@@ -7,11 +7,6 @@ from ..db import (
set_last_login, set_last_login,
get_user_by_username, get_user_by_username,
set_user_password, set_user_password,
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
@@ -22,10 +17,6 @@ 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()
@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)
@@ -101,31 +92,6 @@ 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()
leader = get_global_request_leader()
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 []
return {
"user": current_user,
"stats": {
**stats,
"share": share,
"global_total": global_total,
"most_active_user": leader,
},
"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

@@ -68,7 +68,6 @@ _artwork_prefetch_state: Dict[str, Any] = {
"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",
@@ -270,17 +269,10 @@ 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 as exc: except httpx.HTTPStatusError:
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
@@ -401,23 +393,14 @@ 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 ( if not payload.get("title") and payload.get("media_id"):
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")
@@ -445,7 +428,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") and payload.get("media_type"): if not payload.get("title") and payload.get("tmdb_id"):
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")
) )
@@ -453,8 +436,6 @@ 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)
@@ -535,7 +516,6 @@ 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"):
@@ -543,11 +523,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 ( if not payload.get("title") and payload.get("media_id"):
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")
@@ -575,7 +551,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") and payload.get("media_type"): if not payload.get("title") and payload.get("tmdb_id"):
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")
) )
@@ -583,8 +559,6 @@ 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)
@@ -1025,148 +999,6 @@ 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 []
@@ -1775,43 +1607,78 @@ 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")
logger.info(
"Grab requested: request_id=%s guid=%s indexer_id=%s indexer_name=%s has_download_url=%s has_title=%s",
request_id,
guid,
indexer_id,
indexer_name,
bool(download_url),
bool(release_title),
)
runtime = get_runtime_settings() runtime = get_runtime_settings()
if not download_url:
raise HTTPException(status_code=400, detail="Missing downloadUrl")
if snapshot.request_type.value == "tv": if snapshot.request_type.value == "tv":
category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr") client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if snapshot.request_type.value == "movie": if not client.configured():
category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr") raise HTTPException(status_code=400, detail="Sonarr not configured")
if snapshot.request_type.value not in {"tv", "movie"}: try:
raise HTTPException(status_code=400, detail="Unknown request type") response = await client.grab_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc:
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category) status_code = exc.response.status_code if exc.response is not None else 502
if not qbittorrent_added: if status_code == 404 and download_url:
raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent") qbit = QBittorrentClient(
action_message = f"Grab sent to qBittorrent (category {category})." runtime.qbittorrent_base_url,
await asyncio.to_thread( runtime.qbittorrent_username,
save_action, request_id, "grab", "Grab release", "ok", action_message runtime.qbittorrent_password,
) )
return {"status": "ok", "response": {"qbittorrent": "queued"}} if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr."
)
return {"status": "ok", "response": response}
if snapshot.request_type.value == "movie":
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
response = await client.grab_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else 502
if status_code == 404 and download_url:
qbit = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbit.configured():
raise HTTPException(status_code=400, detail="qBittorrent not configured")
try:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc
await asyncio.to_thread(
save_action,
request_id,
"grab",
"Grab release",
"ok",
"Sent to qBittorrent via Prowlarr.",
)
return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"}
raise HTTPException(status_code=502, detail=str(exc)) from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr."
)
return {"status": "ok", "response": response}
raise HTTPException(status_code=400, detail="Unknown request type")

View File

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

View File

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

View File

@@ -11,14 +11,9 @@ 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, get_recent_snapshots, get_setting, set_setting from ..db import save_snapshot, get_request_cache_payload
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",
@@ -46,35 +41,6 @@ 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")
@@ -415,6 +381,10 @@ 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))
@@ -554,7 +524,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 yet added to Sonarr/Radarr." snapshot.state_reason = "Approved, but not added to the library yet."
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."
@@ -578,7 +548,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
actions.append( actions.append(
ActionOption( ActionOption(
id="readd_to_arr", id="readd_to_arr",
label="Push to Sonarr/Radarr", label="Add to the library queue (Sonarr/Radarr)",
risk="medium", risk="medium",
) )
) )
@@ -634,6 +604,5 @@ 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

View File

@@ -3,8 +3,6 @@ services:
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
args:
BUILD_NUMBER: ${BUILD_NUMBER}
env_file: env_file:
- ./.env - ./.env
ports: ports:

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -175,35 +175,30 @@ body {
margin-right: auto; 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;
width: min(260px, 90vw); min-width: 180px;
background: rgba(14, 20, 32, 0.96); background: rgba(14, 20, 32, 0.95);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 8px; padding: 8px;
@@ -211,50 +206,17 @@ body {
z-index: 20; z-index: 20;
} }
.signed-in-header { .signed-in-dropdown a {
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: left; text-align: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
} }
.signed-in-signout { .signed-in-dropdown a:hover {
cursor: pointer; background: rgba(255, 255, 255, 0.08);
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 {
@@ -559,73 +521,6 @@ 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;
@@ -752,34 +647,33 @@ button span {
} }
.user-card { .user-card {
display: grid; display: flex;
grid-template-columns: 1fr auto; justify-content: space-between;
align-items: start; align-items: center;
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;
justify-items: end; justify-items: end;
} }
.user-bulk-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.user-bulk-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.toggle { .toggle {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
@@ -911,6 +805,11 @@ button span {
gap: 10px; gap: 10px;
} }
.captcha-wrap {
display: flex;
justify-content: center;
}
.ghost-button { .ghost-button {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: var(--ink); color: var(--ink);
@@ -1058,6 +957,103 @@ button span {
gap: 16px; gap: 16px;
} }
.settings-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.duration-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(160px, 1fr);
gap: 10px;
align-items: center;
}
.duration-row input,
.duration-row select {
width: 100%;
}
.invite-stack {
display: grid;
gap: 16px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-items: stretch;
}
.invite-card h3 {
margin: 0;
font-size: 18px;
}
.invite-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
align-items: start;
}
.invite-card {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.invite-card .admin-actions {
margin-top: auto;
}
.invite-grid label {
display: grid;
gap: 8px;
font-size: 14px;
color: var(--ink-muted);
text-align: left;
}
.invite-grid input,
.invite-grid select {
width: 100%;
}
.toggle-row {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px 14px;
}
.toggle-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-help {
font-size: 12px;
color: var(--ink-muted);
}
.field-inline {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(180px, 260px);
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--ink-muted);
}
.field-stack {
display: grid;
gap: 6px;
}
.field-stack .field-help {
text-align: left;
}
.admin-grid label { .admin-grid label {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1089,49 +1085,6 @@ 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;
@@ -1222,7 +1175,7 @@ button span {
.cache-row { .cache-row {
display: grid; display: grid;
grid-template-columns: 90px minmax(0, 1.6fr) 120px 90px 180px; grid-template-columns: minmax(140px, 1.4fr) 100px 140px 100px 180px;
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
@@ -1230,6 +1183,7 @@ button span {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
font-size: 13px; font-size: 13px;
color: var(--ink); color: var(--ink);
align-items: center;
} }
.cache-row span { .cache-row span {
@@ -1246,6 +1200,13 @@ button span {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.cache-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.maintenance-grid { .maintenance-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1520,83 +1481,19 @@ 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 {
@@ -1627,26 +1524,27 @@ button span {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.card { .duration-row {
padding: 24px; grid-template-columns: 1fr;
}
.field-inline {
grid-template-columns: 1fr;
} }
.cache-row { .cache-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.user-card { .toggle-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.connection-item { .card {
flex-direction: column; padding: 24px;
align-items: flex-start;
}
} }
@media (max-width: 480px) { .cache-row {
.header-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -1739,91 +1637,6 @@ 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;
@@ -1832,21 +1645,3 @@ 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,64 +75,8 @@ 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 &quot;waiting&quot;</h2> <h2>Why Magent sometimes says waiting</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,7 +5,6 @@ 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',
@@ -29,14 +28,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</a> </a>
</div> </div>
<div className="header-right"> <div className="header-right">
<ThemeToggle />
<HeaderIdentity /> <HeaderIdentity />
<ThemeToggle />
</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

@@ -10,65 +10,9 @@ 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)
@@ -82,21 +26,18 @@ export default function ProfilePage() {
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/profile`) const response = await authFetch(`${baseUrl}/auth/me`)
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: user?.username ?? 'Unknown', username: data?.username ?? 'Unknown',
role: user?.role ?? 'user', role: data?.role ?? 'user',
auth_provider: user?.auth_provider ?? 'local', auth_provider: data?.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.')
@@ -150,76 +91,6 @@ 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>
<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>
</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,6 +1,5 @@
'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'
@@ -34,7 +33,6 @@ type ReleaseOption = {
seeders?: number seeders?: number
leechers?: number leechers?: number
protocol?: string protocol?: string
publishDate?: string
infoUrl?: string infoUrl?: string
downloadUrl?: string downloadUrl?: string
} }
@@ -125,7 +123,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: 'Push to Sonarr/Radarr', NEEDS_ADD: 'Needs adding to the library',
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',
@@ -157,7 +155,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: 'Push to Sonarr/Radarr', missing: 'Not added yet',
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',
@@ -252,7 +250,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
} }
load() load()
}, [params.id, router]) }, [params.id])
if (loading) { if (loading) {
return ( return (
@@ -276,11 +274,9 @@ 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: arrStageLabel }, { key: 'Sonarr/Radarr', label: 'Library queue' },
{ key: 'Prowlarr', label: 'Search' }, { key: 'Prowlarr', label: 'Search' },
{ key: 'qBittorrent', label: 'Download' }, { key: 'qBittorrent', label: 'Download' },
{ key: 'Jellyfin', label: 'Jellyfin' }, { key: 'Jellyfin', label: 'Jellyfin' },
@@ -312,14 +308,11 @@ 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 && (
<Image <img
className="request-poster" className="request-poster"
src={resolvedPoster} src={resolvedPoster}
alt={`${snapshot.title} poster`} alt={`${snapshot.title} poster`}
width={90} loading="lazy"
height={135}
sizes="90px"
unoptimized
/> />
)} )}
<div> <div>
@@ -597,14 +590,7 @@ 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,
}), }),
} }
) )
@@ -617,8 +603,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 qBittorrent.') setActionMessage('Download sent to Sonarr/Radarr.')
setModalMessage('Download sent to qBittorrent.') setModalMessage('Download sent to Sonarr/Radarr.')
} 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

@@ -25,7 +25,6 @@ const NAV_GROUPS = [
{ {
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

@@ -32,6 +32,14 @@ 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
} }
@@ -41,7 +49,11 @@ 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,15 +4,13 @@ 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<{ username: string; role?: string } | null>(null) const [identity, setIdentity] = useState<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 () => {
@@ -26,14 +24,7 @@ export default function HeaderIdentity() {
} }
const data = await response.json() const data = await response.json()
if (data?.username) { if (data?.username) {
setIdentity({ username: data.username, role: data.role }) setIdentity(`${data.username}${data.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)
@@ -47,42 +38,14 @@ 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 <button type="button" className="signed-in" onClick={() => setOpen((prev) => !prev)}>
type="button" Signed in as {identity}
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">
<div className="signed-in-header">Signed in as {label}</div> <a href="/profile">My profile</a>
<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

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

View File

@@ -136,12 +136,10 @@ 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>
<div className="user-meta">
<span className="meta">Role: {user.role}</span> <span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.authProvider || 'local'}</span> <span className="meta">Login type: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</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">
<input <input

View File

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