Build 2602262159: restore jellyfin-first user source

This commit is contained in:
2026-02-26 22:00:19 +13:00
parent 1c6b8255c1
commit 7257d32d6c
5 changed files with 139 additions and 34 deletions

View File

@@ -1 +1 @@
2602262049
2602262159

View File

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

View File

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

View File

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

View File

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