7 Commits

36 changed files with 914 additions and 2341 deletions

View File

@@ -2,8 +2,11 @@ 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="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",
) )

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

@@ -18,16 +18,6 @@ class JellyseerrClient(ApiClient):
}, },
) )
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/user",
params={
"take": take,
"skip": skip,
"sort": "createdAt",
},
)
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]: async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v1/media/{media_id}") return await self.get(f"/api/v1/media/{media_id}")

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")
@@ -105,159 +128,6 @@ class Settings(BaseSettings):
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt", default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"), validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
) )
invites_enabled: bool = Field(default=True, validation_alias=AliasChoices("INVITES_ENABLED"))
invites_require_captcha: bool = Field(
default=False, validation_alias=AliasChoices("INVITES_REQUIRE_CAPTCHA")
)
invite_default_profile_id: Optional[int] = Field(
default=None, validation_alias=AliasChoices("INVITE_DEFAULT_PROFILE_ID")
)
signup_allow_referrals: bool = Field(
default=True, validation_alias=AliasChoices("SIGNUP_ALLOW_REFERRALS")
)
referral_default_uses: int = Field(
default=1, validation_alias=AliasChoices("REFERRAL_DEFAULT_USES")
)
password_min_length: int = Field(
default=8, validation_alias=AliasChoices("PASSWORD_MIN_LENGTH")
)
password_require_upper: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_UPPER")
)
password_require_lower: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_LOWER")
)
password_require_number: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_NUMBER")
)
password_require_symbol: bool = Field(
default=False, validation_alias=AliasChoices("PASSWORD_REQUIRE_SYMBOL")
)
password_reset_enabled: bool = Field(
default=True, validation_alias=AliasChoices("PASSWORD_RESET_ENABLED")
)
captcha_provider: str = Field(
default="none", validation_alias=AliasChoices("CAPTCHA_PROVIDER")
)
hcaptcha_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("HCAPTCHA_SITE_KEY")
)
hcaptcha_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("HCAPTCHA_SECRET_KEY")
)
recaptcha_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RECAPTCHA_SITE_KEY")
)
recaptcha_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("RECAPTCHA_SECRET_KEY")
)
turnstile_site_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TURNSTILE_SITE_KEY")
)
turnstile_secret_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TURNSTILE_SECRET_KEY")
)
smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_HOST")
)
smtp_port: Optional[int] = Field(
default=587, validation_alias=AliasChoices("SMTP_PORT")
)
smtp_user: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_USER")
)
smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_PASSWORD")
)
smtp_from: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SMTP_FROM")
)
smtp_tls: bool = Field(default=False, validation_alias=AliasChoices("SMTP_TLS"))
smtp_starttls: bool = Field(default=True, validation_alias=AliasChoices("SMTP_STARTTLS"))
notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_EMAIL_ENABLED")
)
notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_DISCORD_ENABLED")
)
notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_TELEGRAM_ENABLED")
)
notify_matrix_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_MATRIX_ENABLED")
)
notify_pushover_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_PUSHOVER_ENABLED")
)
notify_pushbullet_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_PUSHBULLET_ENABLED")
)
notify_gotify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_GOTIFY_ENABLED")
)
notify_ntfy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("NOTIFY_NTFY_ENABLED")
)
telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TELEGRAM_BOT_TOKEN")
)
telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("TELEGRAM_CHAT_ID")
)
matrix_homeserver: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_HOMESERVER")
)
matrix_user: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_USER")
)
matrix_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_PASSWORD")
)
matrix_access_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_ACCESS_TOKEN")
)
matrix_room_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MATRIX_ROOM_ID")
)
pushover_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHOVER_TOKEN")
)
pushover_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHOVER_USER_KEY")
)
pushbullet_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PUSHBULLET_TOKEN")
)
gotify_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("GOTIFY_URL")
)
gotify_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("GOTIFY_TOKEN")
)
ntfy_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("NTFY_URL")
)
ntfy_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("NTFY_TOPIC")
)
expiry_default_days: int = Field(
default=0, validation_alias=AliasChoices("EXPIRY_DEFAULT_DAYS")
)
expiry_default_action: str = Field(
default="disable", validation_alias=AliasChoices("EXPIRY_DEFAULT_ACTION")
)
expiry_warning_days: int = Field(
default=3, validation_alias=AliasChoices("EXPIRY_WARNING_DAYS")
)
expiry_check_interval_minutes: int = Field(
default=60, validation_alias=AliasChoices("EXPIRY_CHECK_INTERVAL_MINUTES")
)
jellyseerr_sync_users: bool = Field(
default=True, validation_alias=AliasChoices("JELLYSEERR_SYNC_USERS")
)
jellyseerr_sync_interval_minutes: int = Field(
default=1440, validation_alias=AliasChoices("JELLYSEERR_SYNC_INTERVAL_MINUTES")
)
settings = Settings() settings = Settings()

View File

@@ -65,111 +65,6 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_contacts (
user_id INTEGER PRIMARY KEY,
email TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
discord TEXT,
discord_verified INTEGER NOT NULL DEFAULT 0,
telegram TEXT,
telegram_verified INTEGER NOT NULL DEFAULT 0,
matrix TEXT,
matrix_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS invite_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
max_uses INTEGER,
expires_in_days INTEGER,
require_captcha INTEGER NOT NULL DEFAULT 0,
password_rules_json TEXT,
allow_referrals INTEGER NOT NULL DEFAULT 0,
referral_uses INTEGER,
user_expiry_days INTEGER,
user_expiry_action TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
profile_id INTEGER,
created_by TEXT,
created_at TEXT NOT NULL,
expires_at TEXT,
max_uses INTEGER,
uses_count INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
require_captcha INTEGER NOT NULL DEFAULT 0,
password_rules_json TEXT,
allow_referrals INTEGER NOT NULL DEFAULT 0,
referral_uses INTEGER,
user_expiry_days INTEGER,
user_expiry_action TEXT,
is_referral INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_expiry (
user_id INTEGER PRIMARY KEY,
expires_at TEXT NOT NULL,
action TEXT NOT NULL,
warning_sent_at TEXT,
disabled_at TEXT,
deleted_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_by TEXT,
subject TEXT NOT NULL,
body_md TEXT NOT NULL,
channels_csv TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
recipient TEXT,
status TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
@@ -376,32 +271,6 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
} }
def get_user_by_email(email: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT users.id, users.username, users.password_hash, users.role, users.auth_provider,
users.created_at, users.last_login_at, users.is_blocked
FROM users
JOIN user_contacts ON user_contacts.user_id = users.id
WHERE lower(user_contacts.email) = lower(?)
""",
(email,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"created_at": row[5],
"last_login_at": row[6],
"is_blocked": bool(row[7]),
}
def get_all_users() -> list[Dict[str, Any]]: def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
@@ -458,17 +327,6 @@ def set_user_role(username: str, role: str) -> None:
) )
def delete_user(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
with _connect() as conn:
conn.execute("DELETE FROM user_contacts WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM user_expiry WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM password_resets WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]: def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username) user = get_user_by_username(username)
if not user: if not user:
@@ -489,603 +347,6 @@ def set_user_password(username: str, password: str) -> None:
) )
def get_user_id(username: str) -> Optional[int]:
with _connect() as conn:
row = conn.execute(
"SELECT id FROM users WHERE username = ?",
(username,),
).fetchone()
if not row:
return None
return int(row[0])
def upsert_user_contact(
username: str,
email: Optional[str] = None,
discord: Optional[str] = None,
telegram: Optional[str] = None,
matrix: Optional[str] = None,
verified: Optional[Dict[str, bool]] = None,
) -> None:
user_id = get_user_id(username)
if user_id is None:
return
now = datetime.now(timezone.utc).isoformat()
verified = verified or {}
with _connect() as conn:
conn.execute(
"""
INSERT INTO user_contacts (
user_id,
email,
email_verified,
discord,
discord_verified,
telegram,
telegram_verified,
matrix,
matrix_verified,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
email = COALESCE(excluded.email, user_contacts.email),
email_verified = COALESCE(excluded.email_verified, user_contacts.email_verified),
discord = COALESCE(excluded.discord, user_contacts.discord),
discord_verified = COALESCE(excluded.discord_verified, user_contacts.discord_verified),
telegram = COALESCE(excluded.telegram, user_contacts.telegram),
telegram_verified = COALESCE(excluded.telegram_verified, user_contacts.telegram_verified),
matrix = COALESCE(excluded.matrix, user_contacts.matrix),
matrix_verified = COALESCE(excluded.matrix_verified, user_contacts.matrix_verified),
updated_at = excluded.updated_at
""",
(
user_id,
email,
1 if verified.get("email") else 0,
discord,
1 if verified.get("discord") else 0,
telegram,
1 if verified.get("telegram") else 0,
matrix,
1 if verified.get("matrix") else 0,
now,
now,
),
)
def get_user_contact(username: str) -> Optional[Dict[str, Any]]:
user_id = get_user_id(username)
if user_id is None:
return None
with _connect() as conn:
row = conn.execute(
"""
SELECT email, email_verified, discord, discord_verified, telegram, telegram_verified, matrix, matrix_verified
FROM user_contacts
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if not row:
return None
return {
"email": row[0],
"email_verified": bool(row[1]),
"discord": row[2],
"discord_verified": bool(row[3]),
"telegram": row[4],
"telegram_verified": bool(row[5]),
"matrix": row[6],
"matrix_verified": bool(row[7]),
}
def get_all_contacts() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, user_contacts.email, user_contacts.discord,
user_contacts.telegram, user_contacts.matrix
FROM user_contacts
JOIN users ON users.id = user_contacts.user_id
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"email": row[1],
"discord": row[2],
"telegram": row[3],
"matrix": row[4],
}
)
return results
def set_user_expiry(username: str, expires_at: str, action: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
with _connect() as conn:
conn.execute(
"""
INSERT INTO user_expiry (user_id, expires_at, action)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
expires_at = excluded.expires_at,
action = excluded.action
""",
(user_id, expires_at, action),
)
def get_expired_users(now_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, users.is_blocked, user_expiry.expires_at, user_expiry.action, user_expiry.warning_sent_at
FROM user_expiry
JOIN users ON users.id = user_expiry.user_id
WHERE user_expiry.expires_at <= ?
AND user_expiry.deleted_at IS NULL
""",
(now_iso,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"is_blocked": bool(row[1]),
"expires_at": row[2],
"action": row[3],
"warning_sent_at": row[4],
}
)
return results
def get_users_expiring_by(cutoff_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT users.username, user_expiry.expires_at, user_expiry.action, user_expiry.warning_sent_at
FROM user_expiry
JOIN users ON users.id = user_expiry.user_id
WHERE user_expiry.expires_at <= ?
AND user_expiry.warning_sent_at IS NULL
""",
(cutoff_iso,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"username": row[0],
"expires_at": row[1],
"action": row[2],
"warning_sent_at": row[3],
}
)
return results
def mark_expiry_warning_sent(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET warning_sent_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def mark_expiry_disabled(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET disabled_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def mark_expiry_deleted(username: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE user_expiry SET deleted_at = ? WHERE user_id = ?
""",
(timestamp, user_id),
)
def list_invite_profiles() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, max_uses, expires_in_days, require_captcha,
password_rules_json, allow_referrals, referral_uses, user_expiry_days,
user_expiry_action, created_at, updated_at
FROM invite_profiles
ORDER BY name COLLATE NOCASE
"""
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"name": row[1],
"description": row[2],
"max_uses": row[3],
"expires_in_days": row[4],
"require_captcha": bool(row[5]),
"password_rules": json.loads(row[6]) if row[6] else None,
"allow_referrals": bool(row[7]),
"referral_uses": row[8],
"user_expiry_days": row[9],
"user_expiry_action": row[10],
"created_at": row[11],
"updated_at": row[12],
}
)
return results
def get_invite_profile(profile_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, name, description, max_uses, expires_in_days, require_captcha,
password_rules_json, allow_referrals, referral_uses, user_expiry_days,
user_expiry_action
FROM invite_profiles
WHERE id = ?
""",
(profile_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"name": row[1],
"description": row[2],
"max_uses": row[3],
"expires_in_days": row[4],
"require_captcha": bool(row[5]),
"password_rules": json.loads(row[6]) if row[6] else None,
"allow_referrals": bool(row[7]),
"referral_uses": row[8],
"user_expiry_days": row[9],
"user_expiry_action": row[10],
}
def create_invite_profile(
name: str,
description: Optional[str] = None,
max_uses: Optional[int] = None,
expires_in_days: Optional[int] = None,
require_captcha: bool = False,
password_rules: Optional[Dict[str, Any]] = None,
allow_referrals: bool = False,
referral_uses: Optional[int] = None,
user_expiry_days: Optional[int] = None,
user_expiry_action: Optional[str] = None,
) -> int:
now = datetime.now(timezone.utc).isoformat()
rules_json = json.dumps(password_rules, ensure_ascii=True) if password_rules else None
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO invite_profiles (
name,
description,
max_uses,
expires_in_days,
require_captcha,
password_rules_json,
allow_referrals,
referral_uses,
user_expiry_days,
user_expiry_action,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
description,
max_uses,
expires_in_days,
1 if require_captcha else 0,
rules_json,
1 if allow_referrals else 0,
referral_uses,
user_expiry_days,
user_expiry_action,
now,
now,
),
)
return int(cursor.lastrowid)
def create_invite(
code: str,
created_by: Optional[str],
profile_id: Optional[int],
expires_at: Optional[str],
max_uses: Optional[int],
require_captcha: bool,
password_rules: Optional[Dict[str, Any]],
allow_referrals: bool,
referral_uses: Optional[int],
user_expiry_days: Optional[int],
user_expiry_action: Optional[str],
is_referral: bool = False,
) -> None:
now = datetime.now(timezone.utc).isoformat()
rules_json = json.dumps(password_rules, ensure_ascii=True) if password_rules else None
with _connect() as conn:
conn.execute(
"""
INSERT INTO invites (
code,
profile_id,
created_by,
created_at,
expires_at,
max_uses,
require_captcha,
password_rules_json,
allow_referrals,
referral_uses,
user_expiry_days,
user_expiry_action,
is_referral
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
code,
profile_id,
created_by,
now,
expires_at,
max_uses,
1 if require_captcha else 0,
rules_json,
1 if allow_referrals else 0,
referral_uses,
user_expiry_days,
user_expiry_action,
1 if is_referral else 0,
),
)
def list_invites(limit: int = 200) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 500))
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, code, profile_id, created_by, created_at, expires_at, max_uses, uses_count,
disabled, require_captcha, password_rules_json, allow_referrals, referral_uses,
user_expiry_days, user_expiry_action, is_referral
FROM invites
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"code": row[1],
"profile_id": row[2],
"created_by": row[3],
"created_at": row[4],
"expires_at": row[5],
"max_uses": row[6],
"uses_count": row[7],
"disabled": bool(row[8]),
"require_captcha": bool(row[9]),
"password_rules": json.loads(row[10]) if row[10] else None,
"allow_referrals": bool(row[11]),
"referral_uses": row[12],
"user_expiry_days": row[13],
"user_expiry_action": row[14],
"is_referral": bool(row[15]),
}
)
return results
def list_invites_by_creator(username: str, is_referral: bool = False) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT code, created_at, expires_at, max_uses, uses_count, disabled, is_referral
FROM invites
WHERE created_by = ? AND is_referral = ?
ORDER BY created_at DESC
""",
(username, 1 if is_referral else 0),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"code": row[0],
"created_at": row[1],
"expires_at": row[2],
"max_uses": row[3],
"uses_count": row[4],
"disabled": bool(row[5]),
"is_referral": bool(row[6]),
}
)
return results
def get_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, profile_id, created_by, created_at, expires_at, max_uses, uses_count,
disabled, require_captcha, password_rules_json, allow_referrals, referral_uses,
user_expiry_days, user_expiry_action, is_referral
FROM invites
WHERE code = ?
""",
(code,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"code": row[1],
"profile_id": row[2],
"created_by": row[3],
"created_at": row[4],
"expires_at": row[5],
"max_uses": row[6],
"uses_count": row[7],
"disabled": bool(row[8]),
"require_captcha": bool(row[9]),
"password_rules": json.loads(row[10]) if row[10] else None,
"allow_referrals": bool(row[11]),
"referral_uses": row[12],
"user_expiry_days": row[13],
"user_expiry_action": row[14],
"is_referral": bool(row[15]),
}
def increment_invite_use(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE invites SET uses_count = uses_count + 1 WHERE code = ?
""",
(code,),
)
def disable_invite(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE invites SET disabled = 1 WHERE code = ?
""",
(code,),
)
def delete_invite(code: str) -> None:
with _connect() as conn:
conn.execute(
"""
DELETE FROM invites WHERE code = ?
""",
(code,),
)
def create_password_reset(token: str, username: str, expires_at: str) -> None:
user_id = get_user_id(username)
if user_id is None:
return
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO password_resets (token, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
""",
(token, user_id, created_at, expires_at),
)
def get_password_reset(token: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT users.username, password_resets.expires_at, password_resets.used_at
FROM password_resets
JOIN users ON users.id = password_resets.user_id
WHERE password_resets.token = ?
""",
(token,),
).fetchone()
if not row:
return None
return {"username": row[0], "expires_at": row[1], "used_at": row[2]}
def mark_password_reset_used(token: str) -> None:
used_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE password_resets SET used_at = ? WHERE token = ?
""",
(used_at, token),
)
def save_announcement(
created_by: Optional[str],
subject: str,
body_md: str,
channels_csv: Optional[str],
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO announcements (created_by, subject, body_md, channels_csv, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(created_by, subject, body_md, channels_csv, created_at),
)
def log_notification(channel: str, recipient: Optional[str], status: str, detail: Optional[str]) -> None:
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO notification_log (channel, recipient, status, detail, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(channel, recipient, status, detail, created_at),
)
def _backfill_auth_providers() -> None: def _backfill_auth_providers() -> None:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
@@ -1348,6 +609,22 @@ 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 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,9 +18,8 @@ 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 .services.jellyseerr_sync import run_jellyseerr_sync_loop
from .services.expiry import run_expiry_loop
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
@@ -42,15 +41,15 @@ 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())
asyncio.create_task(run_jellyseerr_sync_loop())
asyncio.create_task(startup_warmup_requests_cache()) asyncio.create_task(startup_warmup_requests_cache())
asyncio.create_task(run_requests_delta_loop()) asyncio.create_task(run_requests_delta_loop())
asyncio.create_task(run_daily_requests_full_sync()) asyncio.create_task(run_daily_requests_full_sync())
asyncio.create_task(run_daily_db_cleanup()) asyncio.create_task(run_daily_db_cleanup())
asyncio.create_task(run_expiry_loop())
app.include_router(requests_router) app.include_router(requests_router)
@@ -60,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

@@ -1,6 +1,4 @@
from typing import Any, Dict, List from typing import Any, Dict, List
from datetime import datetime, timedelta, timezone
import secrets
import os import os
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -10,17 +8,7 @@ from ..config import settings as env_settings
from ..db import ( from ..db import (
delete_setting, delete_setting,
get_all_users, get_all_users,
get_invite_profile,
list_invite_profiles,
create_invite_profile,
create_invite,
list_invites,
disable_invite,
delete_invite,
delete_user,
get_all_contacts,
get_request_cache_overview, get_request_cache_overview,
save_announcement,
get_settings_overrides, get_settings_overrides,
get_user_by_username, get_user_by_username,
set_setting, set_setting,
@@ -32,6 +20,7 @@ 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
@@ -39,12 +28,10 @@ from ..clients.radarr import RadarrClient
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.jellyseerr_sync import sync_jellyseerr_users
import logging import logging
from ..logging_config import configure_logging from ..logging_config import configure_logging
from ..routers import requests as requests_router from ..routers import requests as requests_router
from ..routers.branding import save_branding_image from ..routers.branding import save_branding_image
from ..services.notifications import send_notification
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -56,17 +43,6 @@ SENSITIVE_KEYS = {
"radarr_api_key", "radarr_api_key",
"prowlarr_api_key", "prowlarr_api_key",
"qbittorrent_password", "qbittorrent_password",
"smtp_password",
"hcaptcha_secret_key",
"recaptcha_secret_key",
"turnstile_secret_key",
"telegram_bot_token",
"matrix_password",
"matrix_access_token",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_token",
} }
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
@@ -81,10 +57,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",
@@ -99,59 +77,11 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time", "requests_cleanup_time",
"requests_cleanup_days", "requests_cleanup_days",
"requests_data_source", "requests_data_source",
"invites_enabled", "site_build_number",
"invites_require_captcha", "site_banner_enabled",
"invite_default_profile_id", "site_banner_message",
"signup_allow_referrals", "site_banner_tone",
"referral_default_uses", "site_changelog",
"password_min_length",
"password_require_upper",
"password_require_lower",
"password_require_number",
"password_require_symbol",
"password_reset_enabled",
"captcha_provider",
"hcaptcha_site_key",
"hcaptcha_secret_key",
"recaptcha_site_key",
"recaptcha_secret_key",
"turnstile_site_key",
"turnstile_secret_key",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from",
"smtp_tls",
"smtp_starttls",
"notify_email_enabled",
"notify_discord_enabled",
"notify_telegram_enabled",
"notify_matrix_enabled",
"notify_pushover_enabled",
"notify_pushbullet_enabled",
"notify_gotify_enabled",
"notify_ntfy_enabled",
"telegram_bot_token",
"telegram_chat_id",
"matrix_homeserver",
"matrix_user",
"matrix_password",
"matrix_access_token",
"matrix_room_id",
"pushover_token",
"pushover_user_key",
"pushbullet_token",
"gotify_url",
"gotify_token",
"ntfy_url",
"ntfy_topic",
"expiry_default_days",
"expiry_default_action",
"expiry_warning_days",
"expiry_check_interval_minutes",
"jellyseerr_sync_users",
"jellyseerr_sync_interval_minutes",
] ]
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]: def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
@@ -286,12 +216,6 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
return {"status": "ok", "imported": imported} return {"status": "ok", "imported": imported}
@router.post("/jellyseerr/users/sync")
async def jellyseerr_users_sync() -> Dict[str, Any]:
imported = await sync_jellyseerr_users()
return {"status": "ok", "imported": imported}
@router.post("/requests/sync") @router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]: async def requests_sync() -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -358,7 +282,28 @@ 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)} 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")
@@ -449,157 +394,3 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
) )
set_user_password(username, new_password.strip()) set_user_password(username, new_password.strip())
return {"status": "ok", "username": username} return {"status": "ok", "username": username}
@router.post("/users/bulk")
async def bulk_user_action(payload: Dict[str, Any]) -> Dict[str, Any]:
action = str(payload.get("action") or "").strip().lower()
usernames = payload.get("usernames")
if not isinstance(usernames, list) or not usernames:
raise HTTPException(status_code=400, detail="User list required")
if action not in {"block", "unblock", "delete", "role"}:
raise HTTPException(status_code=400, detail="Invalid action")
updated = 0
for username in usernames:
if not isinstance(username, str) or not username.strip():
continue
name = username.strip()
if action == "block":
set_user_blocked(name, True)
elif action == "unblock":
set_user_blocked(name, False)
elif action == "delete":
delete_user(name)
elif action == "role":
role = str(payload.get("role") or "").strip().lower()
if role not in {"admin", "user"}:
raise HTTPException(status_code=400, detail="Invalid role")
set_user_role(name, role)
updated += 1
return {"status": "ok", "updated": updated}
@router.get("/invite-profiles")
async def invite_profiles() -> Dict[str, Any]:
return {"profiles": list_invite_profiles()}
@router.post("/invite-profiles")
async def create_profile(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Profile name required")
profile_id = create_invite_profile(
name=name,
description=str(payload.get("description") or "").strip() or None,
max_uses=payload.get("max_uses"),
expires_in_days=payload.get("expires_in_days"),
require_captcha=bool(payload.get("require_captcha")),
password_rules=payload.get("password_rules") if isinstance(payload.get("password_rules"), dict) else None,
allow_referrals=bool(payload.get("allow_referrals")),
referral_uses=payload.get("referral_uses"),
user_expiry_days=payload.get("user_expiry_days"),
user_expiry_action=str(payload.get("user_expiry_action") or "").strip() or None,
)
return {"status": "ok", "id": profile_id}
@router.get("/invites")
async def list_invites_endpoint(limit: int = 200) -> Dict[str, Any]:
return {"invites": list_invites(limit)}
@router.post("/invites")
async def create_invite_endpoint(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
runtime = get_runtime_settings()
profile_id = payload.get("profile_id")
profile = None
if profile_id is not None:
try:
profile = get_invite_profile(int(profile_id))
except (TypeError, ValueError):
profile = None
expires_in_days = payload.get("expires_in_days") or (profile.get("expires_in_days") if profile else None)
expires_at = None
if expires_in_days:
try:
expires_at = (
datetime.now(timezone.utc) + timedelta(days=float(expires_in_days))
).isoformat()
except (TypeError, ValueError):
expires_at = None
require_captcha = bool(payload.get("require_captcha"))
if not require_captcha and profile:
require_captcha = bool(profile.get("require_captcha"))
if not require_captcha:
require_captcha = runtime.invites_require_captcha
password_rules = payload.get("password_rules")
if not isinstance(password_rules, dict):
password_rules = profile.get("password_rules") if profile else None
allow_referrals = bool(payload.get("allow_referrals"))
if not allow_referrals and profile:
allow_referrals = bool(profile.get("allow_referrals"))
user_expiry_days = payload.get("user_expiry_days") or (profile.get("user_expiry_days") if profile else None)
user_expiry_action = payload.get("user_expiry_action") or (profile.get("user_expiry_action") if profile else None)
code = secrets.token_urlsafe(8)
create_invite(
code=code,
created_by=user.get("username"),
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
max_uses=payload.get("max_uses") or (profile.get("max_uses") if profile else None),
require_captcha=require_captcha,
password_rules=password_rules if isinstance(password_rules, dict) else None,
allow_referrals=allow_referrals,
referral_uses=payload.get("referral_uses") or (profile.get("referral_uses") if profile else None),
user_expiry_days=user_expiry_days,
user_expiry_action=str(user_expiry_action) if user_expiry_action else None,
is_referral=bool(payload.get("is_referral")),
)
return {"status": "ok", "code": code}
@router.post("/invites/{code}/disable")
async def disable_invite_endpoint(code: str) -> Dict[str, Any]:
disable_invite(code)
return {"status": "ok", "code": code, "disabled": True}
@router.delete("/invites/{code}")
async def delete_invite_endpoint(code: str) -> Dict[str, Any]:
delete_invite(code)
return {"status": "ok", "code": code, "deleted": True}
@router.post("/announcements")
async def send_announcement(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin)
) -> Dict[str, Any]:
subject = str(payload.get("subject") or "").strip()
body = str(payload.get("body") or "").strip()
channels = payload.get("channels") if isinstance(payload.get("channels"), list) else []
if not subject or not body:
raise HTTPException(status_code=400, detail="Subject and message required")
results: Dict[str, Any] = {}
email_count = 0
email_failed = 0
if "email" in [str(c).lower() for c in channels]:
for contact in get_all_contacts():
email = contact.get("email")
if not email:
continue
outcome = await send_notification(subject, body, channels=["email"], email=email)
if outcome.get("email") == "sent":
email_count += 1
else:
email_failed += 1
results["email"] = {"sent": email_count, "failed": email_failed}
other_channels = [c for c in channels if str(c).lower() != "email"]
if other_channels:
results.update(await send_notification(subject, body, channels=other_channels))
save_announcement(user.get("username"), subject, body, ",".join(channels))
return {"status": "ok", "results": results}

View File

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

View File

@@ -68,6 +68,7 @@ _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",
@@ -269,10 +270,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 +401,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 +445,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 +453,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)
@@ -516,6 +535,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 +543,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 +575,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 +583,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)
@@ -999,6 +1025,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 []
@@ -1607,78 +1775,43 @@ 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 = get_runtime_settings()
runtime.qbittorrent_base_url, if not download_url:
runtime.qbittorrent_username, raise HTTPException(status_code=400, detail="Missing downloadUrl")
runtime.qbittorrent_password, if snapshot.request_type.value == "tv":
) category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
if not qbit.configured(): if snapshot.request_type.value == "movie":
raise HTTPException(status_code=400, detail="qBittorrent not configured") category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
try: if snapshot.request_type.value not in {"tv", "movie"}:
await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}") raise HTTPException(status_code=400, detail="Unknown request type")
except httpx.HTTPStatusError as qbit_exc:
raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
await asyncio.to_thread( if not qbittorrent_added:
save_action, raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent")
request_id, action_message = f"Grab sent to qBittorrent (category {category})."
"grab", await asyncio.to_thread(
"Grab release", save_action, request_id, "grab", "Grab release", "ok", action_message
"ok", )
"Sent to qBittorrent via Prowlarr.", return {"status": "ok", "response": {"qbittorrent": "queued"}}
)
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

@@ -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

@@ -2,43 +2,17 @@ from .config import settings
from .db import get_settings_overrides from .db import get_settings_overrides
_INT_FIELDS = { _INT_FIELDS = {
"expiry_check_interval_minutes",
"expiry_default_days",
"expiry_warning_days",
"sonarr_quality_profile_id", "sonarr_quality_profile_id",
"radarr_quality_profile_id", "radarr_quality_profile_id",
"invite_default_profile_id",
"referral_default_uses",
"jwt_exp_minutes", "jwt_exp_minutes",
"requests_sync_ttl_minutes", "requests_sync_ttl_minutes",
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",
"requests_cleanup_days", "requests_cleanup_days",
"smtp_port",
"jellyseerr_sync_interval_minutes",
"password_min_length",
} }
_BOOL_FIELDS = { _BOOL_FIELDS = {
"invites_enabled",
"invites_require_captcha",
"signup_allow_referrals",
"password_require_upper",
"password_require_lower",
"password_require_number",
"password_require_symbol",
"password_reset_enabled",
"smtp_tls",
"smtp_starttls",
"notify_email_enabled",
"notify_discord_enabled",
"notify_telegram_enabled",
"notify_matrix_enabled",
"notify_pushover_enabled",
"notify_pushbullet_enabled",
"notify_gotify_enabled",
"notify_ntfy_enabled",
"jellyfin_sync_to_arr", "jellyfin_sync_to_arr",
"jellyseerr_sync_users", "site_banner_enabled",
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,9 +11,14 @@ 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_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 +46,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")
@@ -381,10 +415,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 +554,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 +578,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 +634,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

View File

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

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'
@@ -29,9 +29,12 @@ const SECTION_LABELS: Record<string, string> = {
qbittorrent: 'qBittorrent', qbittorrent: 'qBittorrent',
log: 'Activity log', log: 'Activity log',
requests: 'Request syncing', requests: 'Request syncing',
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.',
@@ -44,6 +47,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.', qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.', requests: 'Sync and refresh cadence for requests.',
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> = {
@@ -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) =>
@@ -78,6 +83,11 @@ const labelFromKey = (key: string) =>
.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')
type SettingsPageProps = { type SettingsPageProps = {
section: string section: string
@@ -107,7 +117,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
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 +149,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 +163,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
} }, [])
const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
const loadOptions = 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 +194,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 () => {
@@ -213,7 +222,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') { if (section === 'radarr') {
void loadOptions('radarr') void loadOptions('radarr')
} }
}, [router, section]) }, [loadArtworkPrefetchStatus, loadOptions, loadSettings, router, section])
const groupedSettings = useMemo(() => { const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {} const groups: Record<string, AdminSetting[]> = {}
@@ -271,10 +280,12 @@ 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.',
@@ -289,6 +300,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_data_source: 'Pick where Magent should read requests from.', requests_data_source: 'Pick where Magent should read requests from.',
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 footer (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 = (
@@ -472,7 +488,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [artworkPrefetch?.status]) }, [artworkPrefetch])
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') { if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -482,7 +498,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 +526,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 +536,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 +563,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,7 +574,7 @@ 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)
@@ -1007,6 +1023,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'
@@ -1085,6 +1129,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</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">

View File

@@ -11,14 +11,9 @@ const ALLOWED_SECTIONS = new Set([
'qbittorrent', 'qbittorrent',
'requests', 'requests',
'cache', 'cache',
'invites',
'password',
'captcha',
'smtp',
'notifications',
'expiry',
'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

@@ -968,6 +968,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;
@@ -1496,6 +1539,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 +1632,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',
@@ -35,6 +36,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<HeaderActions /> <HeaderActions />
</div> </div>
</header> </header>
<SiteStatus />
{children} {children}
</div> </div>
</body> </body>

View File

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

View File

@@ -10,25 +10,12 @@ type ProfileInfo = {
auth_provider: string auth_provider: string
} }
type ContactInfo = {
email?: string | null
discord?: string | null
telegram?: string | null
matrix?: string | null
}
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 [contact, setContact] = useState<ContactInfo>({})
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)
const [contactStatus, setContactStatus] = useState<string | null>(null)
const [referrals, setReferrals] = useState<
{ code: string; uses_count?: number; max_uses?: number | null }[]
>([])
const [referralStatus, setReferralStatus] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
@@ -51,23 +38,6 @@ export default function ProfilePage() {
role: data?.role ?? 'user', role: data?.role ?? 'user',
auth_provider: data?.auth_provider ?? 'local', auth_provider: data?.auth_provider ?? 'local',
}) })
const contactResponse = await authFetch(`${baseUrl}/auth/contact`)
if (contactResponse.ok) {
const contactData = await contactResponse.json()
setContact({
email: contactData?.contact?.email ?? '',
discord: contactData?.contact?.discord ?? '',
telegram: contactData?.contact?.telegram ?? '',
matrix: contactData?.contact?.matrix ?? '',
})
}
const referralResponse = await authFetch(`${baseUrl}/auth/referrals`)
if (referralResponse.ok) {
const referralData = await referralResponse.json()
if (Array.isArray(referralData?.invites)) {
setReferrals(referralData.invites)
}
}
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('Could not load your profile.') setStatus('Could not load your profile.')
@@ -108,55 +78,6 @@ export default function ProfilePage() {
} }
} }
const submitContact = async (event: React.FormEvent) => {
event.preventDefault()
setContactStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: contact.email,
discord: contact.discord,
telegram: contact.telegram,
matrix: contact.matrix,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
setContactStatus('Contact details saved.')
} catch (err) {
console.error(err)
setContactStatus('Could not update contact details.')
}
}
const createReferral = async () => {
setReferralStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/referrals`, { method: 'POST' })
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Could not create referral invite')
}
const data = await response.json()
if (data?.code) {
setReferrals((current) => [
{ code: data.code, uses_count: 0, max_uses: 1 },
...current,
])
}
setReferralStatus('Referral invite created.')
} catch (err) {
console.error(err)
setReferralStatus('Could not create a referral invite.')
}
}
if (loading) { if (loading) {
return <main className="card">Loading profile...</main> return <main className="card">Loading profile...</main>
} }
@@ -200,82 +121,6 @@ export default function ProfilePage() {
</div> </div>
</form> </form>
)} )}
<form onSubmit={submitContact} className="auth-form">
<h2>Contact details</h2>
<label>
Email address
<input
type="email"
value={contact.email || ''}
onChange={(event) => setContact((current) => ({ ...current, email: event.target.value }))}
autoComplete="email"
/>
</label>
<label>
Discord handle
<input
type="text"
value={contact.discord || ''}
onChange={(event) =>
setContact((current) => ({ ...current, discord: event.target.value }))
}
/>
</label>
<label>
Telegram ID
<input
type="text"
value={contact.telegram || ''}
onChange={(event) =>
setContact((current) => ({ ...current, telegram: event.target.value }))
}
/>
</label>
<label>
Matrix ID
<input
type="text"
value={contact.matrix || ''}
onChange={(event) =>
setContact((current) => ({ ...current, matrix: event.target.value }))
}
/>
</label>
{contactStatus && <div className="status-banner">{contactStatus}</div>}
<div className="auth-actions">
<button type="submit">Save contact details</button>
</div>
</form>
<section className="summary-card">
<h2>Referral invites</h2>
<p className="meta">
Share a referral invite with friends or family. Each invite has limited uses.
</p>
{referralStatus && <div className="status-banner">{referralStatus}</div>}
<div className="auth-actions">
<button type="button" onClick={createReferral}>
Create referral invite
</button>
</div>
{referrals.length === 0 ? (
<div className="meta">No referral invites yet.</div>
) : (
<div className="cache-table">
<div className="cache-row cache-head">
<span>Code</span>
<span>Uses</span>
</div>
{referrals.map((invite) => (
<div key={invite.code} className="cache-row">
<span>{invite.code}</span>
<span>
{invite.uses_count ?? 0}/{invite.max_uses ?? '∞'}
</span>
</div>
))}
</div>
)}
</section>
</main> </main>
) )
} }

View File

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

View File

@@ -1,5 +1,6 @@
'use client' '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

@@ -22,20 +22,10 @@ const NAV_GROUPS = [
{ href: '/admin/cache', label: 'Cache' }, { href: '/admin/cache', label: 'Cache' },
], ],
}, },
{
title: 'Accounts',
items: [
{ href: '/admin/invites', label: 'Invites' },
{ href: '/admin/password', label: 'Password rules' },
{ href: '/admin/captcha', label: 'Captcha' },
{ href: '/admin/smtp', label: 'Email (SMTP)' },
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/expiry', label: 'Account expiry' },
],
},
{ {
title: 'Admin', 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

@@ -49,6 +49,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="/changelog">Changelog</a>
<a href="/profile">My profile</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}> <button type="button" className="header-link" onClick={signOut}>

View File

@@ -0,0 +1,65 @@
'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}
{info?.buildNumber ? (
<div className="site-version">Build {info.buildNumber}</div>
) : null}
</>
)
}

View File

@@ -25,9 +25,6 @@ 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 [selected, setSelected] = useState<string[]>([])
const [bulkAction, setBulkAction] = useState('block')
const [bulkRole, setBulkRole] = useState('user')
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -106,43 +103,6 @@ export default function UsersPage() {
} }
} }
const toggleSelect = (username: string, isChecked: boolean) => {
setSelected((current) =>
isChecked ? [...new Set([...current, username])] : current.filter((name) => name !== username)
)
}
const toggleSelectAll = (isChecked: boolean) => {
setSelected(isChecked ? users.map((user) => user.username) : [])
}
const runBulkAction = async () => {
if (selected.length === 0) {
setError('Select at least one user to run a bulk action.')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: bulkAction,
role: bulkRole,
usernames: selected,
}),
})
if (!response.ok) {
throw new Error('Bulk update failed')
}
setSelected([])
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not run the bulk action.')
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
@@ -168,35 +128,6 @@ export default function UsersPage() {
> >
<section className="admin-section"> <section className="admin-section">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{users.length > 0 && (
<div className="summary-card user-bulk-bar">
<label className="toggle">
<input
type="checkbox"
checked={selected.length === users.length}
onChange={(event) => toggleSelectAll(event.target.checked)}
/>
<span>Select all</span>
</label>
<div className="user-bulk-actions">
<select value={bulkAction} onChange={(event) => setBulkAction(event.target.value)}>
<option value="block">Block access</option>
<option value="unblock">Allow access</option>
<option value="role">Set role</option>
<option value="delete">Delete users</option>
</select>
{bulkAction === 'role' && (
<select value={bulkRole} onChange={(event) => setBulkRole(event.target.value)}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
)}
<button type="button" onClick={runBulkAction}>
Apply to {selected.length} selected
</button>
</div>
</div>
)}
{users.length === 0 ? ( {users.length === 0 ? (
<div className="status-banner">No users found yet.</div> <div className="status-banner">No users found yet.</div>
) : ( ) : (
@@ -204,14 +135,6 @@ export default function UsersPage() {
{users.map((user) => ( {users.map((user) => (
<div key={user.username} className="summary-card user-card"> <div key={user.username} className="summary-card user-card">
<div> <div>
<label className="toggle">
<input
type="checkbox"
checked={selected.includes(user.username)}
onChange={(event) => toggleSelect(user.username, event.target.checked)}
/>
<span>Select</span>
</label>
<strong>{user.username}</strong> <strong>{user.username}</strong>
<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>

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