Skip to content

Commit

Permalink
Add basic Radarr V5 support (#16)
Browse files Browse the repository at this point in the history
This adds basic support for Radarr V5, to the point that Buildarr can manage such instances with all currently available configuration parameters. Support for new features have not yet been added.

Backwards compatibility with Radarr V4 is retained.

* Add the Radarr instance version to the secrets metadata, to allow for version-specific handling
* Add support for the `external` authentication method (and maintain compatibility with the `none` authentication method for older versions)
* Add support for the `radarr.settings.general.security.authentication_method` configuration field
* Loosen the Pushover API key validation requirements in Buildarr, to work around passwords and API keys being obfuscated in Radarr V5 (see #20)

Other changes:

* Update `radarr-py` to `v0.4.0`
* Add the `packaging` library as a dependency for version number comparison
  • Loading branch information
Callum027 committed Nov 12, 2023
1 parent e688075 commit d70962b
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 115 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ repos:
args: []
additional_dependencies:
- buildarr==0.7.0
- packaging==23.2
- types-requests==2.31.0.10
- repo: https://github.com/python-poetry/poetry
rev: "1.7.0"
Expand Down
17 changes: 13 additions & 4 deletions buildarr_radarr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def radarr_api_client(
*,
secrets: Optional[RadarrSecrets] = None,
host_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Generator[ApiClient, None, None]:
"""
Create a Radarr API client object, and make it available within a context.
Expand All @@ -71,6 +72,8 @@ def radarr_api_client(

if secrets:
configuration.api_key["X-Api-Key"] = secrets.api_key.get_secret_value()
elif api_key:
configuration.api_key["X-Api-Key"] = api_key

with ApiClient(configuration) as api_client:
yield api_client
Expand All @@ -79,9 +82,11 @@ def radarr_api_client(
def api_get(
secrets: Union[RadarrSecrets, str],
api_url: str,
session: Optional[requests.Session] = None,
*,
api_key: Optional[str] = None,
use_api_key: bool = True,
expected_status_code: HTTPStatus = HTTPStatus.OK,
session: Optional[requests.Session] = None,
) -> Any:
"""
Send a `GET` request to a Radarr instance.
Expand All @@ -97,10 +102,14 @@ def api_get(

if isinstance(secrets, str):
host_url = secrets
api_key = None
host_api_key = api_key
else:
host_url = secrets.host_url
api_key = secrets.api_key.get_secret_value() if use_api_key else None
host_api_key = secrets.api_key.get_secret_value()

if not use_api_key:
host_api_key = None

url = f"{host_url}/{api_url.lstrip('/')}"

logger.debug("GET %s", url)
Expand All @@ -109,7 +118,7 @@ def api_get(
session = requests.Session()
res = session.get(
url,
headers={"X-Api-Key": api_key} if api_key else None,
headers={"X-Api-Key": host_api_key} if host_api_key else None,
timeout=state.request_timeout,
)
res_json = res.json()
Expand Down
142 changes: 81 additions & 61 deletions buildarr_radarr/config/settings/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@

from buildarr.config import RemoteMapEntry
from buildarr.types import BaseEnum, NonEmptyStr, Password, Port
from pydantic import Field, SecretStr, validator
from packaging.version import Version
from pydantic import Field, SecretStr
from typing_extensions import Self

from ...api import radarr_api_client
Expand All @@ -38,11 +39,12 @@ class AuthenticationMethod(BaseEnum):
none = "none"
basic = "basic"
form = "forms"
external = "external"


# class AuthenticationRequired(BaseEnum):
# enabled = "enabled"
# local_disabled = "disabledForLocalAddresses"
class AuthenticationRequired(BaseEnum):
enabled = "enabled"
local_disabled = "disabledForLocalAddresses"


class CertificateValidation(BaseEnum):
Expand Down Expand Up @@ -73,10 +75,24 @@ class UpdateMechanism(BaseEnum):

class GeneralSettings(RadarrConfigBase):
_remote_map: List[RemoteMapEntry]
_v4_remote_map: List[RemoteMapEntry] = []
_v5_remote_map: List[RemoteMapEntry] = []

@classmethod
def _from_remote(cls, remote_attrs: Mapping[str, Any]) -> Self:
return cls(**cls.get_local_attrs(cls._remote_map, remote_attrs))
def _from_remote(cls, secrets: RadarrSecrets, remote_attrs: Mapping[str, Any]) -> Self:
return cls(
**cls.get_local_attrs(
remote_map=(
cls._remote_map
+ (
cls._v5_remote_map
if Version(secrets.version) >= Version("5.0")
else cls._v4_remote_map
)
),
remote_attrs=remote_attrs,
),
)

def _update_remote_attrs(
self,
Expand All @@ -86,9 +102,16 @@ def _update_remote_attrs(
check_unmanaged: bool = False,
) -> Tuple[bool, Dict[str, Any]]:
return self.get_update_remote_attrs(
tree,
remote,
self._remote_map,
tree=tree,
remote=remote,
remote_map=(
self._remote_map
+ (
self._v5_remote_map
if Version(secrets.version) >= Version("5.0")
else self._v4_remote_map
)
),
check_unmanaged=check_unmanaged,
set_unchanged=True,
)
Expand Down Expand Up @@ -198,29 +221,43 @@ class SecurityGeneralSettings(GeneralSettings):
Radarr instance security (authentication) settings.
"""

authentication: AuthenticationMethod = AuthenticationMethod.none
authentication: AuthenticationMethod = AuthenticationMethod.external
"""
Authentication method for logging into Radarr.
By default, do not require authentication.
Values:
* `none` - No authentication
* `none` - No authentication (Radarr V4 only)
* `basic` - Authentication using HTTP basic auth (browser popup)
* `form` - Authentication using a login page
* `form`/`forms` - Authentication using a login page
* `external` - External authentication using a reverse proxy (Radarr V5 and above)
!!! warning
When the authentication method is set to `none` or `external`,
**authentication is disabled within Radarr itself.**
**Make sure access to Radarr is secured**, either by using a reverse proxy with
forward authentication configured, or not exposing Radarr to the public Internet.
Requires a restart of Radarr to take effect.
*Changed in version 0.2.0: Added support for the `external` authentication method.*
"""

authentication_required: AuthenticationRequired = AuthenticationRequired.enabled
"""
Authentication requirement settings for accessing Radarr.
Available on Radarr V5 and above. Unused when managing Radarr V4 instances.
Values:
* `enabled` - Enabled
* `local-disabled` - Disabled for Local Addresses
# authentication_required: AuthenticationRequired = AuthenticationRequired.enabled
# """
# Authentication requirement settings for accessing Radarr.
#
# Values:
#
# * `enabled` - Enabled
# * `local-disabled` - Disabled for Local Addresses
# """
*New in version 0.2.0.*
"""

username: Optional[str] = None
"""
Expand Down Expand Up @@ -250,7 +287,6 @@ class SecurityGeneralSettings(GeneralSettings):

_remote_map: List[RemoteMapEntry] = [
("authentication", "authenticationMethod", {}),
# ("authentication_required", "authenticationRequired", {}),
(
"username",
"username",
Expand Down Expand Up @@ -280,37 +316,9 @@ class SecurityGeneralSettings(GeneralSettings):
("certificate_validation", "certificateValidation", {}),
]

@validator("username", "password")
def required_when_auth_enabled(
cls,
value: Optional[str],
values: Dict[str, Any],
) -> Optional[str]:
"""
Enforce the following constraints on the validated attributes:
* If `authentication` is `none`, set the attribute value to `None`.
* If `authentication` is a value other than `none` (i.e. require authentication),
ensure that the attribute set to a value other than `None`.
This will apply to both the local Buildarr configuration and
the remote Radarr instance configuration.
Args:
value (Optional[str]): Value to validate
values (Dict[str, Any]): Configuration attributes
Raises:
ValueError: If the attribute is required but empty
Returns:
Validated attribute value
"""
if values["authentication"] == AuthenticationMethod.none:
return None
elif not value:
raise ValueError("required when 'authentication' is not set to 'none'")
return value
_v5_remote_map: List[RemoteMapEntry] = [
("authentication_required", "authenticationRequired", {}),
]


class ProxyGeneralSettings(GeneralSettings):
Expand Down Expand Up @@ -553,13 +561,25 @@ def from_remote(cls, secrets: RadarrSecrets) -> Self:
with radarr_api_client(secrets=secrets) as api_client:
remote_attrs = radarr.HostConfigApi(api_client).get_host_config().to_dict()
return cls(
host=HostGeneralSettings._from_remote(remote_attrs),
security=SecurityGeneralSettings._from_remote(remote_attrs),
proxy=ProxyGeneralSettings._from_remote(remote_attrs),
logging=LoggingGeneralSettings._from_remote(remote_attrs),
analytics=AnalyticsGeneralSettings._from_remote(remote_attrs),
updates=UpdatesGeneralSettings._from_remote(remote_attrs),
backup=BackupGeneralSettings._from_remote(remote_attrs),
host=HostGeneralSettings._from_remote(secrets=secrets, remote_attrs=remote_attrs),
security=SecurityGeneralSettings._from_remote(
secrets=secrets,
remote_attrs=remote_attrs,
),
proxy=ProxyGeneralSettings._from_remote(secrets=secrets, remote_attrs=remote_attrs),
logging=LoggingGeneralSettings._from_remote(
secrets=secrets,
remote_attrs=remote_attrs,
),
analytics=AnalyticsGeneralSettings._from_remote(
secrets=secrets,
remote_attrs=remote_attrs,
),
updates=UpdatesGeneralSettings._from_remote(
secrets=secrets,
remote_attrs=remote_attrs,
),
backup=BackupGeneralSettings._from_remote(secrets=secrets, remote_attrs=remote_attrs),
)

def update_remote(
Expand Down
4 changes: 2 additions & 2 deletions buildarr_radarr/config/settings/notifications/pushover.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from typing import Any, List, Literal, Mapping, Optional, Set, Union

from buildarr.config import RemoteMapEntry
from buildarr.types import BaseEnum, NonEmptyStr
from buildarr.types import BaseEnum, NonEmptyStr, Password
from pydantic import ConstrainedInt, Field, SecretStr, validator

from .base import Notification
Expand Down Expand Up @@ -60,7 +60,7 @@ class PushoverNotification(Notification):
User key to use to authenticate with your Pushover account.
"""

api_key: PushoverApiKey
api_key: Password
"""
API key assigned to this application in Pushover.
"""
Expand Down
67 changes: 28 additions & 39 deletions buildarr_radarr/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
from __future__ import annotations

from http import HTTPStatus
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from typing import TYPE_CHECKING, cast

import radarr

Expand Down Expand Up @@ -56,64 +55,54 @@ class RadarrSecrets(_RadarrSecrets):
port: Port
protocol: RadarrProtocol
api_key: ArrApiKey
version: NonEmptyStr

@property
def host_url(self) -> str:
return f"{self.protocol}://{self.hostname}:{self.port}"

@classmethod
def from_url(cls, base_url: str, api_key: str) -> Self:
url_obj = urlparse(base_url)
hostname_port = url_obj.netloc.rsplit(":", 1)
hostname = hostname_port[0]
protocol = url_obj.scheme
port = (
int(hostname_port[1])
if len(hostname_port) > 1
else (443 if protocol == "https" else 80)
)
return cls(
**{ # type: ignore[arg-type]
"hostname": hostname,
"port": port,
"protocol": protocol,
"api_key": api_key,
},
)

@classmethod
def get(cls, config: RadarrConfig) -> Self:
if config.api_key:
api_key = config.api_key
api_key = config.api_key.get_secret_value()
else:
try:
api_key = api_get(config.host_url, "/initialize.json")["apiKey"]
initialize_json = api_get(config.host_url, "/initialize.json")
except RadarrAPIError as err:
if err.status_code == HTTPStatus.UNAUTHORIZED:
raise RadarrSecretsUnauthorizedError(
"Unable to retrieve the API key for the Radarr instance "
f"at '{config.host_url}': Authentication is enabled. "
"Please try manually setting the "
"'Settings -> General -> Authentication Required' attribute "
"to 'Disabled for Local Addresses', or if that does not work, "
"explicitly define the API key in the Buildarr configuration.",
(
"Unable to retrieve the API key for the Radarr instance "
f"at '{config.host_url}': Authentication is enabled. "
"Please try manually setting the "
"'Settings -> General -> Authentication Required' attribute "
"to 'Disabled for Local Addresses', or if that does not work, "
"explicitly define the API key in the Buildarr configuration."
),
) from None
else:
raise
# TODO: Switch to `radarr.InitializeJsApi.get_initialize_js` when fixed.
# with radarr_api_client(host_url=config.host_url) as api_client:
# api_key = radarr.InitializeJsApi(api_client).get_initialize_js().api_key
else:
api_key = initialize_json["apiKey"]
try:
with radarr_api_client(host_url=config.host_url, api_key=api_key) as api_client:
system_status = radarr.SystemApi(api_client).get_system_status()
except UnauthorizedException:
raise RadarrSecretsUnauthorizedError(
(
f"Incorrect API key for the Radarr instance at '{config.host_url}'. "
"Please check that the API key is set correctly in the Buildarr "
"configuration, and that it is set to the value as shown in "
"'Settings -> General -> API Key' on the Radarr instance."
),
) from None
return cls(
hostname=config.hostname,
port=config.port,
protocol=config.protocol,
api_key=api_key,
api_key=cast(ArrApiKey, api_key),
version=system_status.version,
)

def test(self) -> bool:
with radarr_api_client(secrets=self) as api_client:
try:
radarr.SystemApi(api_client).get_system_status()
except UnauthorizedException:
return False
return True
Loading

0 comments on commit d70962b

Please sign in to comment.