diff --git a/.build_number b/.build_number index 6f9e668..32efbaf 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602262049 +2602262159 \ No newline at end of file diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 70479ad..023d207 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,3 @@ -BUILD_NUMBER = "2602262049" +BUILD_NUMBER = "2602262159" 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/db.py b/backend/app/db.py index b7867c2..edc0a45 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -681,9 +681,9 @@ def get_all_users() -> list[Dict[str, Any]]: ORDER BY username COLLATE NOCASE """ ).fetchall() - results: list[Dict[str, Any]] = [] + all_rows: list[Dict[str, Any]] = [] for row in rows: - results.append( + all_rows.append( { "id": row[0], "username": row[1], @@ -702,6 +702,55 @@ def get_all_users() -> list[Dict[str, Any]]: "is_expired": _is_datetime_in_past(row[11]), } ) + # Admin user management uses Jellyfin as the source of truth for non-admin + # user objects. Jellyseerr rows are treated as enrichment-only and hidden + # from admin/user-management views to avoid duplicate accounts in the UI. + def _provider_rank(user: Dict[str, Any]) -> int: + provider = str(user.get("auth_provider") or "local").strip().lower() + if provider == "jellyfin": + return 0 + if provider == "local": + return 1 + if provider == "jellyseerr": + return 2 + return 2 + + visible_candidates = [ + user + for user in all_rows + if not ( + str(user.get("auth_provider") or "local").strip().lower() == "jellyseerr" + and str(user.get("role") or "user").strip().lower() != "admin" + ) + ] + + visible_candidates.sort( + key=lambda user: ( + 0 if str(user.get("role") or "user").strip().lower() == "admin" else 1, + 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1, + _provider_rank(user), + 0 if user.get("last_login_at") else 1, + int(user.get("id") or 0), + ) + ) + seen_usernames: set[str] = set() + seen_jellyseerr_ids: set[int] = set() + results: list[Dict[str, Any]] = [] + for user in visible_candidates: + username = str(user.get("username") or "").strip() + if not username: + continue + username_key = username.lower() + jellyseerr_user_id = user.get("jellyseerr_user_id") + if isinstance(jellyseerr_user_id, int) and jellyseerr_user_id in seen_jellyseerr_ids: + continue + if username_key in seen_usernames: + continue + results.append(user) + seen_usernames.add(username_key) + if isinstance(jellyseerr_user_id, int): + seen_jellyseerr_ids.add(jellyseerr_user_id) + results.sort(key=lambda user: str(user.get("username") or "").lower()) return results @@ -725,6 +774,17 @@ def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) -> ) +def set_user_auth_provider(username: str, auth_provider: str) -> None: + provider = (auth_provider or "local").strip().lower() or "local" + with _connect() as conn: + conn.execute( + """ + UPDATE users SET auth_provider = ? WHERE username = ? + """, + (provider, username), + ) + + def set_last_login(username: str) -> None: timestamp = datetime.now(timezone.utc).isoformat() with _connect() as conn: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 037d602..b8e2450 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -19,6 +19,7 @@ from ..db import ( set_user_password, set_jellyfin_auth_cache, set_user_jellyseerr_id, + set_user_auth_provider, get_signup_invite_by_code, get_signup_invite_by_id, list_signup_invites, @@ -79,6 +80,26 @@ def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> No bucket.popleft() +def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None: + if not users: + return None + requested = (requested_username or "").strip() + requested_lower = requested.lower() + + def _rank(user: dict) -> tuple[int, int, int, int]: + provider = str(user.get("auth_provider") or "local").strip().lower() + role = str(user.get("role") or "user").strip().lower() + username = str(user.get("username") or "") + return ( + 0 if role == "admin" else 1, + 0 if isinstance(user.get("jellyseerr_user_id"), int) else 1, + 0 if provider == "jellyfin" else (1 if provider == "local" else (2 if provider == "jellyseerr" else 3)), + 0 if username.lower() == requested_lower else 1, + ) + + return sorted(users, key=_rank)[0] + + def _record_login_failure(request: Request, username: str) -> None: now = time.monotonic() window = max(int(settings.auth_rate_limit_window_seconds or 60), 1) @@ -492,13 +513,20 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) username = form_data.username password = form_data.password - user = get_user_by_username(username) + ci_matches = get_users_by_username_ci(username) + preferred_match = _pick_preferred_ci_user_match(ci_matches, username) + canonical_username = str(preferred_match.get("username") or username) if preferred_match else username + user = preferred_match or get_user_by_username(username) _assert_user_can_login(user) if user and _has_valid_jellyfin_cache(user, password): - token = create_access_token(username, "user") + token = create_access_token(canonical_username, "user") _clear_login_failures(request, username) - set_last_login(username) - return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} + set_last_login(canonical_username) + return { + "access_token": token, + "token_type": "bearer", + "user": {"username": canonical_username, "role": "user"}, + } try: response = await client.authenticate_by_name(username, password) except Exception as exc: @@ -506,30 +534,36 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm if not isinstance(response, dict) or not response.get("User"): _record_login_failure(request, username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") - create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") - user = get_user_by_username(username) + if not preferred_match: + create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin") + elif ( + user + and str(user.get("role") or "user").strip().lower() != "admin" + and str(user.get("auth_provider") or "local").strip().lower() != "jellyfin" + ): + set_user_auth_provider(canonical_username, "jellyfin") + user = get_user_by_username(canonical_username) + user = get_user_by_username(canonical_username) _assert_user_can_login(user) try: users = await client.get_users() if isinstance(users, list): save_jellyfin_users_cache(users) - for jellyfin_user in users: - if not isinstance(jellyfin_user, dict): - continue - name = jellyfin_user.get("Name") - if isinstance(name, str) and name: - create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") except Exception: pass - set_jellyfin_auth_cache(username, password) + set_jellyfin_auth_cache(canonical_username, password) if user and user.get("jellyseerr_user_id") is None and candidate_map: - matched_id = match_jellyseerr_user_id(username, candidate_map) + matched_id = match_jellyseerr_user_id(canonical_username, candidate_map) if matched_id is not None: - set_user_jellyseerr_id(username, matched_id) - token = create_access_token(username, "user") + set_user_jellyseerr_id(canonical_username, matched_id) + token = create_access_token(canonical_username, "user") _clear_login_failures(request, username) - set_last_login(username) - return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} + set_last_login(canonical_username) + return { + "access_token": token, + "token_type": "bearer", + "user": {"username": canonical_username, "role": "user"}, + } @router.post("/jellyseerr/login") @@ -548,21 +582,29 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor _record_login_failure(request, form_data.username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") jellyseerr_user_id = _extract_jellyseerr_user_id(response) - create_user_if_missing( - form_data.username, - "jellyseerr-user", - role="user", - auth_provider="jellyseerr", - jellyseerr_user_id=jellyseerr_user_id, - ) - user = get_user_by_username(form_data.username) + ci_matches = get_users_by_username_ci(form_data.username) + preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username) + canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username + if not preferred_match: + create_user_if_missing( + canonical_username, + "jellyseerr-user", + role="user", + auth_provider="jellyseerr", + jellyseerr_user_id=jellyseerr_user_id, + ) + user = get_user_by_username(canonical_username) _assert_user_can_login(user) if jellyseerr_user_id is not None: - set_user_jellyseerr_id(form_data.username, jellyseerr_user_id) - token = create_access_token(form_data.username, "user") + set_user_jellyseerr_id(canonical_username, jellyseerr_user_id) + token = create_access_token(canonical_username, "user") _clear_login_failures(request, form_data.username) - set_last_login(form_data.username) - return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} + set_last_login(canonical_username) + return { + "access_token": token, + "token_type": "bearer", + "user": {"username": canonical_username, "role": "user"}, + } @router.get("/me") diff --git a/backend/app/services/jellyfin_sync.py b/backend/app/services/jellyfin_sync.py index f47ff01..5baa395 100644 --- a/backend/app/services/jellyfin_sync.py +++ b/backend/app/services/jellyfin_sync.py @@ -24,6 +24,8 @@ async def sync_jellyfin_users() -> int: if not isinstance(users, list): return 0 save_jellyfin_users_cache(users) + # Jellyfin is the canonical source for local user objects; Jellyseerr IDs are + # matched as enrichment when possible. jellyseerr_users = get_cached_jellyseerr_users() candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) imported = 0