Build 2602262159: restore jellyfin-first user source
This commit is contained in:
@@ -1 +1 @@
|
||||
2602262049
|
||||
2602262159
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user