Compare commits

...

3 Commits

14 changed files with 2178 additions and 57 deletions

View File

@@ -1 +1 @@
271261539 2602260022

View File

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

View File

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

View File

@@ -30,3 +30,14 @@ class ApiClient:
response = await client.post(url, headers=self.headers(), json=payload) response = await client.post(url, headers=self.headers(), json=payload)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.put(url, headers=self.headers(), json=payload)
response.raise_for_status()
if not response.content:
return None
return response.json()

View File

@@ -9,6 +9,9 @@ class RadarrClient(ApiClient):
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]: async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id}) return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/movie/{movie_id}")
async def get_movies(self) -> Optional[Dict[str, Any]]: async def get_movies(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie") return await self.get("/api/v3/movie")
@@ -44,6 +47,9 @@ class RadarrClient(ApiClient):
} }
return await self.post("/api/v3/movie", payload=payload) return await self.post("/api/v3/movie", payload=payload)
async def update_movie(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/movie", payload=payload)
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})

View File

@@ -9,6 +9,9 @@ class SonarrClient(ApiClient):
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]: async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id}) return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/series/{series_id}")
async def get_root_folders(self) -> Optional[Dict[str, Any]]: async def get_root_folders(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/rootfolder") return await self.get("/api/v3/rootfolder")
@@ -51,6 +54,9 @@ class SonarrClient(ApiClient):
payload["title"] = title payload["title"] = title
return await self.post("/api/v3/series", payload=payload) return await self.post("/api/v3/series", payload=payload)
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self.put("/api/v3/series", payload=payload)
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})

View File

@@ -149,6 +149,7 @@ def init_db() -> None:
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT, last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0, is_blocked INTEGER NOT NULL DEFAULT 0,
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
jellyfin_password_hash TEXT, jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT last_jellyfin_auth_at TEXT
) )
@@ -264,6 +265,10 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER") conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try: try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -424,7 +429,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at
FROM users FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
""", """,
@@ -442,8 +447,9 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"created_at": row[6], "created_at": row[6],
"last_login_at": row[7], "last_login_at": row[7],
"is_blocked": bool(row[8]), "is_blocked": bool(row[8]),
"jellyfin_password_hash": row[9], "auto_search_enabled": bool(row[9]),
"last_jellyfin_auth_at": row[10], "jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
} }
@@ -452,7 +458,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at
FROM users FROM users
WHERE id = ? WHERE id = ?
""", """,
@@ -470,15 +476,16 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"created_at": row[6], "created_at": row[6],
"last_login_at": row[7], "last_login_at": row[7],
"is_blocked": bool(row[8]), "is_blocked": bool(row[8]),
"jellyfin_password_hash": row[9], "auto_search_enabled": bool(row[9]),
"last_jellyfin_auth_at": row[10], "jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
} }
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(
""" """
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled
FROM users FROM users
ORDER BY username COLLATE NOCASE ORDER BY username COLLATE NOCASE
""" """
@@ -495,6 +502,7 @@ def get_all_users() -> list[Dict[str, Any]]:
"created_at": row[5], "created_at": row[5],
"last_login_at": row[6], "last_login_at": row[6],
"is_blocked": bool(row[7]), "is_blocked": bool(row[7]),
"auto_search_enabled": bool(row[8]),
} }
) )
return results return results
@@ -551,6 +559,27 @@ def set_user_role(username: str, role: str) -> None:
) )
def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET auto_search_enabled = ? WHERE username = ?
""",
(1 if enabled else 0, username),
)
def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE users SET auto_search_enabled = ? WHERE role != 'admin'
""",
(1 if enabled else 0,),
)
return cursor.rowcount
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:

View File

@@ -1,6 +1,8 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import ipaddress
import os import os
from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -22,6 +24,8 @@ from ..db import (
set_user_jellyseerr_id, set_user_jellyseerr_id,
set_setting, set_setting,
set_user_blocked, set_user_blocked,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_password, set_user_password,
set_user_role, set_user_role,
run_integrity_check, run_integrity_check,
@@ -64,6 +68,16 @@ SENSITIVE_KEYS = {
"qbittorrent_password", "qbittorrent_password",
} }
URL_SETTING_KEYS = {
"jellyseerr_base_url",
"jellyfin_base_url",
"jellyfin_public_url",
"sonarr_base_url",
"radarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
}
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"jellyseerr_base_url", "jellyseerr_base_url",
"jellyseerr_api_key", "jellyseerr_api_key",
@@ -107,6 +121,49 @@ def _normalize_username(value: str) -> str:
normalized = normalized.split("@", 1)[0] normalized = normalized.split("@", 1)[0]
return normalized return normalized
def _is_ip_host(host: str) -> bool:
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
def _normalize_service_url(value: str) -> str:
raw = value.strip()
if not raw:
raise ValueError("URL cannot be empty.")
candidate = raw
if "://" not in candidate:
authority = candidate.split("/", 1)[0].strip()
if authority.startswith("["):
closing = authority.find("]")
host = authority[1:closing] if closing > 0 else authority.strip("[]")
else:
host = authority.split(":", 1)[0]
host = host.strip().lower()
default_scheme = "http" if host in {"localhost"} or _is_ip_host(host) or "." not in host else "https"
candidate = f"{default_scheme}://{candidate}"
parsed = urlparse(candidate)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if not parsed.netloc:
raise ValueError("URL must include a host.")
if parsed.query or parsed.fragment:
raise ValueError("URL must not include query params or fragments.")
if not parsed.hostname:
raise ValueError("URL must include a valid host.")
normalized_path = parsed.path.rstrip("/")
normalized = parsed._replace(path=normalized_path, params="", query="", fragment="")
result = urlunparse(normalized).rstrip("/")
if not result:
raise ValueError("URL is invalid.")
return result
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]: def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
if not isinstance(folders, list): if not isinstance(folders, list):
return [] return []
@@ -203,7 +260,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
delete_setting(key) delete_setting(key)
updates += 1 updates += 1
continue continue
set_setting(key, str(value)) value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1 updates += 1
if key in {"log_level", "log_file"}: if key in {"log_level", "log_file"}:
touched_logging = True touched_logging = True
@@ -598,6 +662,32 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str,
return {"status": "ok", "username": username, "role": role} return {"status": "ok", "username": username, "role": role}
@router.post("/users/{username}/auto-search")
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_user_auto_search_enabled(username, enabled)
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
@router.post("/users/auto-search/bulk")
async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
updated = set_auto_search_enabled_for_non_admin_users(enabled)
return {
"status": "ok",
"enabled": enabled,
"updated": updated,
"scope": "non-admin-users",
}
@router.post("/users/{username}/password") @router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
new_password = payload.get("password") if isinstance(payload, dict) else None new_password = payload.get("password") if isinstance(payload, dict) else None

View File

@@ -120,6 +120,27 @@ def _normalize_username(value: Any) -> Optional[str]:
return normalized if normalized else None return normalized if normalized else None
def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
if user.get("role") == "admin":
return True
return bool(user.get("auto_search_enabled", True))
def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
if _user_can_use_search_auto(user):
return snapshot
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
return snapshot
def _quality_profile_id(value: Any) -> Optional[int]:
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return None
def _request_matches_user(request_data: Any, username: str) -> bool: def _request_matches_user(request_data: Any, username: str) -> bool:
requested_by = None requested_by = None
if isinstance(request_data, dict): if isinstance(request_data, dict):
@@ -1476,7 +1497,8 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured(): if client.configured():
await _ensure_request_access(client, int(request_id), user) await _ensure_request_access(client, int(request_id), user)
return await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
return _filter_snapshot_actions_for_user(snapshot, user)
@router.get("/recent") @router.get("/recent")
@@ -1747,7 +1769,7 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured(): if client.configured():
await _ensure_request_access(client, int(request_id), user) await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
return triage_snapshot(snapshot) return triage_snapshot(snapshot)
@@ -1784,6 +1806,8 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
@router.post("/{request_id}/actions/search_auto") @router.post("/{request_id}/actions/search_auto")
async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured(): if client.configured():
@@ -1797,10 +1821,23 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Sonarr not configured") raise HTTPException(status_code=400, detail="Sonarr not configured")
target_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
profile_message = None
series_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and series_id and current_profile_id != target_profile_id:
series = await client.get_series(series_id)
if not isinstance(series, dict):
raise HTTPException(status_code=502, detail="Could not load Sonarr series before search")
series["qualityProfileId"] = target_profile_id
await client.update_series(series)
profile_message = f"Sonarr quality profile updated to {target_profile_id} before search."
episodes = await client.get_episodes(int(arr_item["id"])) episodes = await client.get_episodes(int(arr_item["id"]))
missing_by_season = _missing_episode_ids_by_season(episodes) missing_by_season = _missing_episode_ids_by_season(episodes)
if not missing_by_season: if not missing_by_season:
message = "No missing monitored episodes found." message = "No missing monitored episodes found."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message save_action, request_id, "search_auto", "Search and auto-download", "ok", message
) )
@@ -1814,6 +1851,8 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
{"season": season_number, "episodeCount": len(episode_ids), "response": response} {"season": season_number, "episodeCount": len(episode_ids), "response": response}
) )
message = "Search sent to Sonarr." message = "Search sent to Sonarr."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message save_action, request_id, "search_auto", "Search and auto-download", "ok", message
) )
@@ -1822,8 +1861,21 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured") raise HTTPException(status_code=400, detail="Radarr not configured")
target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
profile_message = None
movie_id = _quality_profile_id(arr_item.get("id"))
if target_profile_id and movie_id and current_profile_id != target_profile_id:
movie = await client.get_movie(movie_id)
if not isinstance(movie, dict):
raise HTTPException(status_code=502, detail="Could not load Radarr movie before search")
movie["qualityProfileId"] = target_profile_id
await client.update_movie(movie)
profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
response = await client.search(int(arr_item["id"])) response = await client.search(int(arr_item["id"]))
message = "Search sent to Radarr." message = "Search sent to Radarr."
if profile_message:
message = f"{profile_message} {message}"
await asyncio.to_thread( await asyncio.to_thread(
save_action, request_id, "search_auto", "Search and auto-download", "ok", message save_action, request_id, "search_auto", "Search and auto-download", "ok", message
) )

View File

@@ -34,6 +34,15 @@ const SECTION_LABELS: Record<string, string> = {
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled']) const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog']) const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const URL_SETTINGS = new Set([
'jellyseerr_base_url',
'jellyfin_base_url',
'jellyfin_public_url',
'sonarr_base_url',
'radarr_base_url',
'prowlarr_base_url',
'qbittorrent_base_url',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
@@ -330,26 +339,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
const settingDescriptions: Record<string, string> = { const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: 'Base URL for your Jellyseerr server.', jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.', jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.', jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.', jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.', jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.', jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.', artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking.', sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.', sonarr_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.', sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies.', radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.', radarr_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.', radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.', prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.', prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.', qbittorrent_base_url:
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.', qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.', qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.', requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
@@ -371,6 +385,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_changelog: 'One update per line for the public changelog.', site_changelog: 'One update per line for the public changelog.',
} }
const settingPlaceholders: Record<string, string> = {
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
}
const buildSelectOptions = ( const buildSelectOptions = (
currentValue: string, currentValue: string,
options: { id: number; label: string; path?: string }[], options: { id: number; label: string; path?: string }[],
@@ -982,6 +1006,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isRadarrProfile = setting.key === 'radarr_quality_profile_id' const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
const isRadarrRoot = setting.key === 'radarr_root_folder' const isRadarrRoot = setting.key === 'radarr_root_folder'
const isBoolSetting = BOOL_SETTINGS.has(setting.key) const isBoolSetting = BOOL_SETTINGS.has(setting.key)
const isUrlSetting = URL_SETTINGS.has(setting.key)
const inputPlaceholder = setting.sensitive && setting.isSet
? 'Configured (enter to replace)'
: settingPlaceholders[setting.key] ?? ''
if (isBoolSetting) { if (isBoolSetting) {
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
@@ -1312,9 +1340,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<input <input
name={setting.key} name={setting.key}
type={setting.sensitive ? 'password' : 'text'} type={setting.sensitive ? 'password' : 'text'}
placeholder={ placeholder={inputPlaceholder}
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : '' autoComplete={isUrlSetting ? 'url' : undefined}
}
value={value} value={value}
onChange={(event) => onChange={(event) =>
setFormValues((current) => ({ setFormValues((current) => ({

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ type AdminUser = {
auth_provider?: string | null auth_provider?: string | null
last_login_at?: string | null last_login_at?: string | null
is_blocked?: boolean is_blocked?: boolean
auto_search_enabled?: boolean
jellyseerr_user_id?: number | null jellyseerr_user_id?: number | null
} }
@@ -130,6 +131,28 @@ export default function UserDetailPage() {
} }
} }
const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
} catch (err) {
console.error(err)
setError('Could not update auto search access.')
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -159,17 +182,26 @@ export default function UserDetailPage() {
) : ( ) : (
<> <>
<div className="user-detail-card"> <div className="user-detail-card">
<div className="user-detail-header"> <div className="user-detail-layout">
<div> <div className="user-detail-identity">
<strong>{user.username}</strong> <div className="user-detail-title-row">
<div className="user-detail-meta"> <strong className="user-detail-name">{user.username}</strong>
<span className="meta">Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</span> <span className={`user-grid-pill ${user.is_blocked ? 'is-blocked' : ''}`}>
<span className="meta">Role: {user.role}</span> {user.is_blocked ? 'Blocked' : 'Active'}
<span className="meta">Login type: {user.auth_provider || 'local'}</span> </span>
<span className="meta">Last login: {formatDateTime(user.last_login_at)}</span> </div>
<div className="user-detail-meta-pills">
<span className="user-detail-chip">
Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}
</span>
<span className="user-detail-chip">Role: {user.role}</span>
<span className="user-detail-chip">Login type: {user.auth_provider || 'local'}</span>
<span className="user-detail-chip">Last login: {formatDateTime(user.last_login_at)}</span>
</div> </div>
</div> </div>
<div className="user-actions"> <div className="user-detail-controls">
<div className="user-detail-controls-title">User controls</div>
<div className="user-detail-actions">
<label className="toggle"> <label className="toggle">
<input <input
type="checkbox" type="checkbox"
@@ -178,6 +210,15 @@ export default function UserDetailPage() {
/> />
<span>Make admin</span> <span>Make admin</span>
</label> </label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.auto_search_enabled ?? true)}
disabled={user.role === 'admin'}
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
/>
<span>Allow auto search/download</span>
</label>
<button <button
type="button" type="button"
className="ghost-button" className="ghost-button"
@@ -186,41 +227,47 @@ export default function UserDetailPage() {
{user.is_blocked ? 'Allow access' : 'Block access'} {user.is_blocked ? 'Allow access' : 'Block access'}
</button> </button>
</div> </div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download access.
</div>
)}
</div>
</div> </div>
<div className="user-detail-grid"> <div className="user-detail-grid">
<div> <div className="user-detail-stat">
<span className="label">Total</span> <span className="label">Total</span>
<span className="value">{stats?.total ?? 0}</span> <span className="value">{stats?.total ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat">
<span className="label">Ready</span> <span className="label">Ready</span>
<span className="value">{stats?.ready ?? 0}</span> <span className="value">{stats?.ready ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat">
<span className="label">Pending</span> <span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span> <span className="value">{stats?.pending ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat">
<span className="label">Approved</span> <span className="label">Approved</span>
<span className="value">{stats?.approved ?? 0}</span> <span className="value">{stats?.approved ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat">
<span className="label">Working</span> <span className="label">Working</span>
<span className="value">{stats?.working ?? 0}</span> <span className="value">{stats?.working ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat">
<span className="label">Partial</span> <span className="label">Partial</span>
<span className="value">{stats?.partial ?? 0}</span> <span className="value">{stats?.partial ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat">
<span className="label">Declined</span> <span className="label">Declined</span>
<span className="value">{stats?.declined ?? 0}</span> <span className="value">{stats?.declined ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat">
<span className="label">In progress</span> <span className="label">In progress</span>
<span className="value">{stats?.in_progress ?? 0}</span> <span className="value">{stats?.in_progress ?? 0}</span>
</div> </div>
<div> <div className="user-detail-stat user-detail-stat--wide">
<span className="label">Last request</span> <span className="label">Last request</span>
<span className="value">{formatDateTime(stats?.last_request_at)}</span> <span className="value">{formatDateTime(stats?.last_request_at)}</span>
</div> </div>

View File

@@ -13,6 +13,7 @@ type AdminUser = {
authProvider?: string | null authProvider?: string | null
lastLoginAt?: string | null lastLoginAt?: string | null
isBlocked?: boolean isBlocked?: boolean
autoSearchEnabled?: boolean
stats?: UserStats stats?: UserStats
} }
@@ -74,6 +75,7 @@ export default function UsersPage() {
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null) const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false) const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false) const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -100,6 +102,7 @@ export default function UsersPage() {
authProvider: user.auth_provider ?? 'local', authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null, lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked), isBlocked: Boolean(user.is_blocked),
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
id: Number(user.id ?? 0), id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats), stats: normalizeStats(user.stats ?? emptyStats),
})) }))
@@ -208,6 +211,33 @@ export default function UsersPage() {
} }
} }
const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Bulk update failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update auto search/download for all users.')
} finally {
setBulkAutoSearchBusy(false)
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -220,6 +250,9 @@ export default function UsersPage() {
return <main className="card">Loading users...</main> return <main className="card">Loading users...</main>
} }
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
return ( return (
<AdminShell <AdminShell
title="Users" title="Users"
@@ -241,6 +274,31 @@ 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>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>} {jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Auto search/download</strong>
<span>
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
</span>
</div>
<div className="user-bulk-actions">
<button
type="button"
onClick={() => bulkUpdateAutoSearch(true)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Enable for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => bulkUpdateAutoSearch(false)}
disabled={bulkAutoSearchBusy}
>
{bulkAutoSearchBusy ? 'Working...' : 'Disable for all users'}
</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>
) : ( ) : (
@@ -260,6 +318,11 @@ export default function UsersPage() {
{user.isBlocked ? 'Blocked' : 'Active'} {user.isBlocked ? 'Blocked' : 'Active'}
</span> </span>
</div> </div>
<div className="user-grid-subpills">
<span className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}>
Auto search {user.autoSearchEnabled === false ? 'Off' : 'On'}
</span>
</div>
<div className="user-grid-stats"> <div className="user-grid-stats">
<div> <div>
<span className="label">Total</span> <span className="label">Total</span>

View File

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