Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic Radarr V5 support #16

Merged
merged 14 commits into from
Nov 12, 2023
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