diff --git a/.build_number b/.build_number index 21f3afc..a6dc9ee 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0202261541 +2502262321 diff --git a/backend/app/auth.py b/backend/app/auth.py index bb2d574..dea4358 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -48,6 +48,7 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non "role": user["role"], "auth_provider": user.get("auth_provider", "local"), "jellyseerr_user_id": user.get("jellyseerr_user_id"), + "auto_search_enabled": bool(user.get("auto_search_enabled", True)), } diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 1116c8f..e22aa88 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0202261541" +BUILD_NUMBER = "2502262321" 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' diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 303ca23..d918145 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -30,3 +30,14 @@ class ApiClient: response = await client.post(url, headers=self.headers(), json=payload) response.raise_for_status() 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() diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py index ef5c0d4..83da911 100644 --- a/backend/app/clients/radarr.py +++ b/backend/app/clients/radarr.py @@ -9,6 +9,9 @@ class RadarrClient(ApiClient): 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}) + 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]]: return await self.get("/api/v3/movie") @@ -44,6 +47,9 @@ class RadarrClient(ApiClient): } 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]]: return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id}) diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index 6ad31b6..6de81be 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -9,6 +9,9 @@ class SonarrClient(ApiClient): 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}) + 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]]: return await self.get("/api/v3/rootfolder") @@ -51,6 +54,9 @@ class SonarrClient(ApiClient): payload["title"] = title 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]]: return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id}) diff --git a/backend/app/db.py b/backend/app/db.py index 1f4a13b..a994a6d 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -149,6 +149,7 @@ def init_db() -> None: created_at TEXT NOT NULL, last_login_at TEXT, is_blocked INTEGER NOT NULL DEFAULT 0, + auto_search_enabled INTEGER NOT NULL DEFAULT 1, jellyfin_password_hash 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") except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1") + except sqlite3.OperationalError: + pass try: conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") except sqlite3.OperationalError: @@ -424,7 +429,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: row = conn.execute( """ SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, - created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at + created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at FROM users WHERE username = ? COLLATE NOCASE """, @@ -442,8 +447,9 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: "created_at": row[6], "last_login_at": row[7], "is_blocked": bool(row[8]), - "jellyfin_password_hash": row[9], - "last_jellyfin_auth_at": row[10], + "auto_search_enabled": bool(row[9]), + "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( """ 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 WHERE id = ? """, @@ -470,15 +476,16 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: "created_at": row[6], "last_login_at": row[7], "is_blocked": bool(row[8]), - "jellyfin_password_hash": row[9], - "last_jellyfin_auth_at": row[10], + "auto_search_enabled": bool(row[9]), + "jellyfin_password_hash": row[10], + "last_jellyfin_auth_at": row[11], } def get_all_users() -> list[Dict[str, Any]]: with _connect() as conn: 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 ORDER BY username COLLATE NOCASE """ @@ -495,6 +502,7 @@ def get_all_users() -> list[Dict[str, Any]]: "created_at": row[5], "last_login_at": row[6], "is_blocked": bool(row[7]), + "auto_search_enabled": bool(row[8]), } ) return results @@ -551,6 +559,16 @@ 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 verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]: user = get_user_by_username(username) if not user: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2d5ebee..320ec4e 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -24,6 +24,7 @@ from ..db import ( set_user_jellyseerr_id, set_setting, set_user_blocked, + set_user_auto_search_enabled, set_user_password, set_user_role, run_integrity_check, @@ -660,6 +661,18 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, 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/{username}/password") 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 diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index a0a7f79..22064d6 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -120,6 +120,27 @@ def _normalize_username(value: Any) -> Optional[str]: 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: requested_by = None 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) if client.configured(): 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") @@ -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) if client.configured(): 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) @@ -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") 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() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) 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) if not client.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"])) missing_by_season = _missing_episode_ids_by_season(episodes) if not missing_by_season: message = "No missing monitored episodes found." + if profile_message: + message = f"{profile_message} {message}" await asyncio.to_thread( 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} ) message = "Search sent to Sonarr." + if profile_message: + message = f"{profile_message} {message}" await asyncio.to_thread( 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) if not client.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"])) message = "Search sent to Radarr." + if profile_message: + message = f"{profile_message} {message}" await asyncio.to_thread( save_action, request_id, "search_auto", "Search and auto-download", "ok", message ) diff --git a/frontend/app/users/[id]/page.tsx b/frontend/app/users/[id]/page.tsx index 2fc6ce0..6ea180f 100644 --- a/frontend/app/users/[id]/page.tsx +++ b/frontend/app/users/[id]/page.tsx @@ -24,6 +24,7 @@ type AdminUser = { auth_provider?: string | null last_login_at?: string | null is_blocked?: boolean + auto_search_enabled?: boolean 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(() => { if (!getToken()) { router.push('/login') @@ -178,6 +201,15 @@ export default function UserDetailPage() { /> Make admin + + updateAutoSearchEnabled(event.target.checked)} + /> + Allow auto search/download + + {user.role === 'admin' && ( + + Admins always have auto search/download access. + + )} Total