Files
wizarr/app/services/servers.py
Matthieu B 31005dcf87 Fix recent onboarding and media-server bugs (#1318)
* fix: resolve button widget context URLs

Fixes #1280

* fix: return relative invitation API URLs

Fixes #1262

* fix: use modern Jellyfin auth headers

Fixes #1309

* fix: use Emby library GUIDs for access policy

Fixes #1303
2026-07-05 18:14:39 +02:00

317 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import requests
from flask_babel import _
from plexapi.exceptions import PlexApiException
from plexapi.server import PlexServer
from requests import exceptions as req_exc
from app.services.media.auth_headers import media_browser_auth_headers
# Raised when a server returns a non-200 status code.
class ServerResponseError(Exception):
def __init__(self, status_code: int, url: str):
self.status_code = status_code
self.url = url
super().__init__(
_("Server returned status code %(status_code)s", status_code=status_code)
)
# Handle connection errors for both Plex and Jellyfin servers.
def handle_connection_error(e: Exception, server_type: str) -> tuple[bool, str]:
if isinstance(e, ServerResponseError):
error_msg = str(e)
logging.error("%s check failed: %s%s", server_type, e.url, e.status_code)
elif isinstance(e, PlexApiException):
error_msg = _(
"%(server_type)s server returned an error: %(error)s",
server_type=server_type,
error=str(e),
)
logging.error("%s API error: %s", server_type, str(e))
elif isinstance(e, req_exc.ConnectionError):
error_msg = _(
"Could not connect to the %(server_type)s server. Please check if the server is running and the URL is correct.",
server_type=server_type,
)
logging.error("%s connection error: %s", server_type, str(e))
elif isinstance(e, req_exc.Timeout):
error_msg = _(
"Connection to %(server_type)s server timed out. Please check if the server is running and accessible.",
server_type=server_type,
)
logging.error("%s connection timeout", server_type)
elif isinstance(e, req_exc.RequestException):
error_msg = _(
"An error occurred while connecting to the %(server_type)s server: %(error)s",
server_type=server_type,
error=str(e),
)
logging.error("%s request error: %s", server_type, str(e))
else:
error_msg = _(
"An unexpected error occurred while connecting to the %(server_type)s server: %(error)s",
server_type=server_type,
error=str(e),
)
logging.error("%s check failed: %s", server_type, str(e), exc_info=True)
return False, error_msg
def check_plex(url: str, token: str) -> tuple[bool, str]:
try:
PlexServer(url, token=token)
return True, ""
except Exception as e:
return handle_connection_error(e, _("Plex"))
def check_jellyfin_or_emby_internal(url: str, token: str) -> tuple[bool, str]:
resp = requests.get(
f"{url}/Users", headers=media_browser_auth_headers(token), timeout=10
)
if resp.status_code != 200:
raise ServerResponseError(resp.status_code, resp.url)
return True, ""
def check_jellyfin(url: str, token: str) -> tuple[bool, str]:
try:
return check_jellyfin_or_emby_internal(url, token)
except Exception as e:
return handle_connection_error(e, _("Jellyfin"))
def check_emby(url: str, token: str) -> tuple[bool, str]:
try:
return check_jellyfin_or_emby_internal(url, token)
except Exception as e:
return handle_connection_error(e, _("Emby"))
def check_audiobookshelf(url: str, token: str) -> tuple[bool, str]:
"""Validate Audiobookshelf credentials.
The most lightweight endpoint to probe is ``/ping`` which returns
``{"success": true}`` without requiring authentication. When a
token is provided we additionally fetch ``/api/libraries`` to verify
the token works and that we can access the libraries list.
"""
try:
# 1) base connectivity even works on brand-new instances
resp = requests.get(f"{url.rstrip('/')}/ping", timeout=10)
if resp.status_code != 200:
raise ServerResponseError(resp.status_code, resp.url)
if token:
headers = {"Authorization": f"Bearer {token}"}
lib_resp = requests.get(
f"{url.rstrip('/')}/api/libraries", headers=headers, timeout=10
)
if lib_resp.status_code != 200:
raise ServerResponseError(lib_resp.status_code, lib_resp.url)
return True, ""
except Exception as e:
return handle_connection_error(e, _("Audiobookshelf"))
# ---------------------------------------------------------------------------
# RomM new media-server backend
# ---------------------------------------------------------------------------
def check_romm(url: str, token: str) -> tuple[bool, str]:
"""Quick connectivity check for a RomM instance.
We perform a lightweight GET request to ``/api/platforms`` which is
available to any authenticated user and returns a list of platforms in
JSON. When *token* is set we send it as a *Basic* header.
"""
try:
headers = {"Accept": "application/json"}
if token:
headers["Authorization"] = f"Basic {token}"
resp = requests.get(
f"{url.rstrip('/')}/api/platforms", headers=headers, timeout=10
)
if resp.status_code != 200:
raise ServerResponseError(resp.status_code, resp.url)
# Basic sanity check ensure response is JSON list
if not isinstance(resp.json(), list):
raise ValueError("Unexpected RomM response format")
return True, ""
except Exception as e:
return handle_connection_error(e, _("RomM"))
def check_komga(url: str, token: str) -> tuple[bool, str]:
"""Quick connectivity check for a Komga instance.
We perform a lightweight GET request to ``/api/v1/libraries`` which is
available to authenticated users and returns a list of libraries in
JSON. When *token* is set we send it as an *X-API-Key* header.
"""
try:
headers = {"Accept": "application/json"}
if token:
headers["X-API-Key"] = token
resp = requests.get(
f"{url.rstrip('/')}/api/v1/libraries", headers=headers, timeout=10
)
if resp.status_code != 200:
raise ServerResponseError(resp.status_code, resp.url)
# Basic sanity check ensure response is JSON list
if not isinstance(resp.json(), list):
raise ValueError("Unexpected Komga response format")
return True, ""
except Exception as e:
return handle_connection_error(e, _("Komga"))
def check_kavita(url: str, token: str) -> tuple[bool, str]:
"""Quick connectivity check for a Kavita instance.
We perform a lightweight GET request to ``/api/Health`` which is
available to check server health. If an API key is provided, we authenticate
to get a JWT token and test access to libraries.
"""
try:
headers = {"Accept": "application/json", "Content-Type": "application/json"}
# First check health endpoint (no auth required)
resp = requests.get(
f"{url.rstrip('/')}/api/Health", headers=headers, timeout=10
)
if resp.status_code != 200:
raise ServerResponseError(resp.status_code, resp.url)
# If API key provided, authenticate and test access
if token:
# Step 1: Use API key to get JWT token
auth_url = f"{url.rstrip('/')}/api/Plugin/authenticate"
auth_params = {"apiKey": token, "pluginName": "Wizarr"}
auth_resp = requests.post(
auth_url, params=auth_params, headers=headers, timeout=10
)
if auth_resp.status_code != 200:
raise ServerResponseError(auth_resp.status_code, auth_resp.url)
auth_data = auth_resp.json()
jwt_token = auth_data.get("token", "")
if not jwt_token:
raise ValueError("No JWT token returned from Kavita authentication")
# Step 2: Use JWT token to test library access
jwt_headers = {**headers, "Authorization": f"Bearer {jwt_token}"}
lib_resp = requests.get(
f"{url.rstrip('/')}/api/Library/libraries",
headers=jwt_headers,
timeout=10,
)
if lib_resp.status_code != 200:
raise ServerResponseError(lib_resp.status_code, lib_resp.url)
# Basic sanity check ensure response is JSON list
if not isinstance(lib_resp.json(), list):
raise ValueError("Unexpected Kavita response format")
return True, ""
except Exception as e:
return handle_connection_error(e, _("Kavita"))
def check_navidrome(url: str, token: str) -> tuple[bool, str]:
"""Quick connectivity check for a Navidrome instance.
We perform a lightweight request to the ``/rest/ping`` endpoint which is
available to check server connectivity using the Subsonic API.
When *token* (password) is provided, we authenticate using the salted hash method.
"""
try:
import hashlib
import random
import string
# Build Subsonic API authentication parameters
params = {
"u": "admin", # Default username for API access
"v": "1.16.1", # Supported API version
"c": "wizarr", # Client identifier
"f": "json", # Response format
}
if token:
# Generate random salt for secure authentication
salt = "".join(random.choices(string.ascii_letters + string.digits, k=6))
# Create SHA-256 hash of password + salt (prefer strong hash if supported by Navidrome/Subsonic API)
token_hash = hashlib.sha256((token + salt).encode()).hexdigest()
params.update(
{
"t": token_hash,
"s": salt,
}
)
else:
# If no token provided, try without authentication (some endpoints allow this)
params["p"] = ""
resp = requests.get(f"{url.rstrip('/')}/rest/ping", params=params, timeout=10)
if resp.status_code != 200:
raise ServerResponseError(resp.status_code, resp.url)
# Check for Subsonic API errors in response
data = resp.json()
subsonic_response = data.get("subsonic-response", {})
if subsonic_response.get("status") != "ok":
error = subsonic_response.get("error", {})
raise ValueError(
f"Navidrome API error: {error.get('message', 'Unknown error')}"
)
return True, ""
except Exception as e:
return handle_connection_error(e, _("Navidrome"))
def check_drop(url: str, token: str) -> tuple[bool, str]:
"""Quick connectivity check for a Drop instance.
We perform a lightweight request to the ``/api/v1/user`` endpoint to verify
the System token has the required permissions for basic API access.
"""
try:
# Build request to Drop API
headers = {"Accept": "application/json", "Authorization": f"Bearer {token}"}
# Test connectivity with user endpoint (requires authentication)
response = requests.get(
f"{url.rstrip('/')}/api/v1/user", headers=headers, timeout=10
)
# Check for successful response
if response.status_code == 200:
return True, ""
if response.status_code == 401:
return False, _("Invalid API token or insufficient permissions.")
if response.status_code == 403:
return False, _("API token lacks required permissions.")
response.raise_for_status()
return True, ""
except req_exc.ConnectionError:
return False, _("Could not connect to Drop server.")
except req_exc.Timeout:
return False, _("Connection to Drop server timed out.")
except req_exc.HTTPError as e:
if e.response.status_code == 404:
return False, _("Drop API not found. Check the server URL.")
return False, f"Drop API error: {e.response.status_code}"
except Exception as e:
return handle_connection_error(e, _("Drop"))