fix: resolve Jellyfin usernames to internal Id and harden qBittorrent login

Jellyfin: get_user_id() returned the configured user_id verbatim, so a
username like "admin" hit /Users/admin/Views and got HTTP 400 ("The value
'admin' is not valid."). The index worker already had username->Id
resolution, but the live API paths (dashboard counts, media query) did not.
Route all user-scoped paths through the new JellyfinClient.resolve_user_id()
(exact Id match -> Name match -> first user), cached per service/credentials
in get_user_id() so repeated requests don't re-list users. The worker is
simplified to call the same method.

qBittorrent: _login() raised "qBittorrent login failed: " (empty) on a 200
with an empty body, which happens when base_url doesn't reach the qBittorrent
login handler (wrong URL/path or a reverse proxy misroute) — not a credentials
issue. Now accepts the SID cookie as a success signal (reverse proxies that
mangle the body), returns a clear "invalid username or password" for "Fails.",
and surfaces a diagnostic error (HTTP status + body + base_url/proxy hint) for
any other/empty body.

Tests: new tests/test_jellyfin_client.py (5) + 3 qBittorrent login tests.
Full backend suite (384) passes; ruff clean.
This commit is contained in:
Developer
2026-07-11 11:21:31 +00:00
parent ecabc65dd4
commit 84dcf9e010
12 changed files with 196 additions and 66 deletions
@@ -263,17 +263,41 @@ def get_settings_store() -> SettingsStore:
def get_user_id(request: Request = None) -> str:
"""Return the configured Jellyfin user ID or discover the first available one."""
"""Return the Jellyfin user Id, resolving a configured username if needed.
The service ``user_id`` config field accepts either the internal Jellyfin Id
or a username (e.g. ``'admin'``). Jellyfin's ``/Users/{id}/...`` endpoints
reject usernames with HTTP 400 (``"The value 'admin' is not valid."``), so
always resolve to the internal Id before use. Resolution is cached per
(service, base_url, api_key, configured) so repeated dashboard/media requests
don't re-list users on every call.
"""
store = get_settings_store()
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id)
if service and service.get("config", {}).get("user_id"):
return str(service["config"]["user_id"])
client = get_jellyfin_client(request)
users = client.users()
if not users:
if service is None:
raise HTTPException(
status_code=503,
detail="No Jellyfin users found and no user_id configured on the service",
detail="No Jellyfin service is configured. Add a Jellyfin service on the Services page.",
)
return users[0]["Id"]
configured = str(service.get("config", {}).get("user_id") or "").strip()
base_url = str(service.get("config", {}).get("base_url") or "")
api_key = str(service.get("secrets", {}).get("api_key") or "")
if not base_url or not api_key:
raise HTTPException(
status_code=503,
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
)
return _resolved_user_id((service["id"], base_url, api_key, configured))
@lru_cache(maxsize=64)
def _resolved_user_id(cache_key: tuple[str, str, str, str]) -> str:
"""Resolve a configured Jellyfin identifier (Id or username) to the internal Id.
Keyed by (service_id, base_url, api_key, configured) so a credentials change
or a different configured user busts the cache automatically.
"""
service_id, base_url, api_key, configured = cache_key
client = _jellyfin_client_for((service_id, base_url, api_key))
return client.resolve_user_id(configured or None)