mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-07-31 07:17:10 -04:00
* 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
317 lines
12 KiB
Python
317 lines
12 KiB
Python
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"))
|