charmarr-lib-core 0.12.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- charmarr_lib/core/__init__.py +126 -0
- charmarr_lib/core/_arr/__init__.py +72 -0
- charmarr_lib/core/_arr/_arr_client.py +154 -0
- charmarr_lib/core/_arr/_base_client.py +314 -0
- charmarr_lib/core/_arr/_config_builders.py +214 -0
- charmarr_lib/core/_arr/_config_xml.py +121 -0
- charmarr_lib/core/_arr/_protocols.py +54 -0
- charmarr_lib/core/_arr/_reconcilers.py +269 -0
- charmarr_lib/core/_arr/_recyclarr.py +150 -0
- charmarr_lib/core/_juju/__init__.py +27 -0
- charmarr_lib/core/_juju/_pebble.py +102 -0
- charmarr_lib/core/_juju/_reconciler.py +137 -0
- charmarr_lib/core/_juju/_secrets.py +44 -0
- charmarr_lib/core/_k8s/__init__.py +43 -0
- charmarr_lib/core/_k8s/_hardware.py +191 -0
- charmarr_lib/core/_k8s/_permission_check.py +310 -0
- charmarr_lib/core/_k8s/_storage.py +253 -0
- charmarr_lib/core/_variant.py +37 -0
- charmarr_lib/core/_version.py +3 -0
- charmarr_lib/core/constants.py +29 -0
- charmarr_lib/core/enums.py +55 -0
- charmarr_lib/core/interfaces/__init__.py +78 -0
- charmarr_lib/core/interfaces/_base.py +103 -0
- charmarr_lib/core/interfaces/_download_client.py +125 -0
- charmarr_lib/core/interfaces/_flaresolverr.py +69 -0
- charmarr_lib/core/interfaces/_media_indexer.py +131 -0
- charmarr_lib/core/interfaces/_media_manager.py +111 -0
- charmarr_lib/core/interfaces/_media_server.py +74 -0
- charmarr_lib/core/interfaces/_media_storage.py +99 -0
- charmarr_lib_core-0.12.2.dist-info/METADATA +136 -0
- charmarr_lib_core-0.12.2.dist-info/RECORD +32 -0
- charmarr_lib_core-0.12.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Core libraries for Charmarr charms."""
|
|
5
|
+
|
|
6
|
+
from charmarr_lib.core._arr import (
|
|
7
|
+
ApplicationConfigBuilder,
|
|
8
|
+
ArrApiClient,
|
|
9
|
+
ArrApiConnectionError,
|
|
10
|
+
ArrApiError,
|
|
11
|
+
ArrApiResponseError,
|
|
12
|
+
BaseArrApiClient,
|
|
13
|
+
DownloadClientConfigBuilder,
|
|
14
|
+
DownloadClientResponse,
|
|
15
|
+
HostConfigResponse,
|
|
16
|
+
MediaIndexerClient,
|
|
17
|
+
MediaManagerConnection,
|
|
18
|
+
QualityProfileResponse,
|
|
19
|
+
RecyclarrError,
|
|
20
|
+
RootFolderResponse,
|
|
21
|
+
SecretGetter,
|
|
22
|
+
config_has_api_key,
|
|
23
|
+
generate_api_key,
|
|
24
|
+
read_api_key,
|
|
25
|
+
reconcile_config_xml,
|
|
26
|
+
reconcile_download_clients,
|
|
27
|
+
reconcile_external_url,
|
|
28
|
+
reconcile_media_manager_connections,
|
|
29
|
+
reconcile_root_folder,
|
|
30
|
+
sync_trash_profiles,
|
|
31
|
+
update_api_key,
|
|
32
|
+
)
|
|
33
|
+
from charmarr_lib.core._juju import (
|
|
34
|
+
all_events,
|
|
35
|
+
ensure_pebble_user,
|
|
36
|
+
get_config_hash,
|
|
37
|
+
get_secret_rotation_policy,
|
|
38
|
+
observe_events,
|
|
39
|
+
reconcilable_events_k8s,
|
|
40
|
+
reconcilable_events_k8s_workloadless,
|
|
41
|
+
sync_secret_rotation_policy,
|
|
42
|
+
)
|
|
43
|
+
from charmarr_lib.core._k8s import (
|
|
44
|
+
K8sResourceManager,
|
|
45
|
+
PermissionCheckResult,
|
|
46
|
+
PermissionCheckStatus,
|
|
47
|
+
ReconcileResult,
|
|
48
|
+
check_storage_permissions,
|
|
49
|
+
delete_permission_check_job,
|
|
50
|
+
is_hardware_device_mounted,
|
|
51
|
+
is_storage_mounted,
|
|
52
|
+
reconcile_hardware_transcoding,
|
|
53
|
+
reconcile_storage_volume,
|
|
54
|
+
)
|
|
55
|
+
from charmarr_lib.core._variant import (
|
|
56
|
+
get_default_trash_profiles,
|
|
57
|
+
get_root_folder,
|
|
58
|
+
)
|
|
59
|
+
from charmarr_lib.core.constants import (
|
|
60
|
+
MEDIA_MANAGER_IMPLEMENTATIONS,
|
|
61
|
+
MEDIA_TYPE_DOWNLOAD_PATHS,
|
|
62
|
+
)
|
|
63
|
+
from charmarr_lib.core.enums import (
|
|
64
|
+
ContentVariant,
|
|
65
|
+
DownloadClient,
|
|
66
|
+
DownloadClientType,
|
|
67
|
+
MediaIndexer,
|
|
68
|
+
MediaManager,
|
|
69
|
+
RequestManager,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"MEDIA_MANAGER_IMPLEMENTATIONS",
|
|
74
|
+
"MEDIA_TYPE_DOWNLOAD_PATHS",
|
|
75
|
+
"ApplicationConfigBuilder",
|
|
76
|
+
"ArrApiClient",
|
|
77
|
+
"ArrApiConnectionError",
|
|
78
|
+
"ArrApiError",
|
|
79
|
+
"ArrApiResponseError",
|
|
80
|
+
"BaseArrApiClient",
|
|
81
|
+
"ContentVariant",
|
|
82
|
+
"DownloadClient",
|
|
83
|
+
"DownloadClientConfigBuilder",
|
|
84
|
+
"DownloadClientResponse",
|
|
85
|
+
"DownloadClientType",
|
|
86
|
+
"HostConfigResponse",
|
|
87
|
+
"K8sResourceManager",
|
|
88
|
+
"MediaIndexer",
|
|
89
|
+
"MediaIndexerClient",
|
|
90
|
+
"MediaManager",
|
|
91
|
+
"MediaManagerConnection",
|
|
92
|
+
"PermissionCheckResult",
|
|
93
|
+
"PermissionCheckStatus",
|
|
94
|
+
"QualityProfileResponse",
|
|
95
|
+
"ReconcileResult",
|
|
96
|
+
"RecyclarrError",
|
|
97
|
+
"RequestManager",
|
|
98
|
+
"RootFolderResponse",
|
|
99
|
+
"SecretGetter",
|
|
100
|
+
"all_events",
|
|
101
|
+
"check_storage_permissions",
|
|
102
|
+
"config_has_api_key",
|
|
103
|
+
"delete_permission_check_job",
|
|
104
|
+
"ensure_pebble_user",
|
|
105
|
+
"generate_api_key",
|
|
106
|
+
"get_config_hash",
|
|
107
|
+
"get_default_trash_profiles",
|
|
108
|
+
"get_root_folder",
|
|
109
|
+
"get_secret_rotation_policy",
|
|
110
|
+
"is_hardware_device_mounted",
|
|
111
|
+
"is_storage_mounted",
|
|
112
|
+
"observe_events",
|
|
113
|
+
"read_api_key",
|
|
114
|
+
"reconcilable_events_k8s",
|
|
115
|
+
"reconcilable_events_k8s_workloadless",
|
|
116
|
+
"reconcile_config_xml",
|
|
117
|
+
"reconcile_download_clients",
|
|
118
|
+
"reconcile_external_url",
|
|
119
|
+
"reconcile_hardware_transcoding",
|
|
120
|
+
"reconcile_media_manager_connections",
|
|
121
|
+
"reconcile_root_folder",
|
|
122
|
+
"reconcile_storage_volume",
|
|
123
|
+
"sync_secret_rotation_policy",
|
|
124
|
+
"sync_trash_profiles",
|
|
125
|
+
"update_api_key",
|
|
126
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""API clients, config builders, and reconcilers for *ARR applications."""
|
|
5
|
+
|
|
6
|
+
from charmarr_lib.core._arr._arr_client import (
|
|
7
|
+
ArrApiClient,
|
|
8
|
+
DownloadClientResponse,
|
|
9
|
+
HostConfigResponse,
|
|
10
|
+
QualityProfileResponse,
|
|
11
|
+
RootFolderResponse,
|
|
12
|
+
)
|
|
13
|
+
from charmarr_lib.core._arr._base_client import (
|
|
14
|
+
ArrApiConnectionError,
|
|
15
|
+
ArrApiError,
|
|
16
|
+
ArrApiResponseError,
|
|
17
|
+
BaseArrApiClient,
|
|
18
|
+
)
|
|
19
|
+
from charmarr_lib.core._arr._config_builders import (
|
|
20
|
+
ApplicationConfigBuilder,
|
|
21
|
+
DownloadClientConfigBuilder,
|
|
22
|
+
SecretGetter,
|
|
23
|
+
)
|
|
24
|
+
from charmarr_lib.core._arr._config_xml import (
|
|
25
|
+
config_has_api_key,
|
|
26
|
+
generate_api_key,
|
|
27
|
+
read_api_key,
|
|
28
|
+
reconcile_config_xml,
|
|
29
|
+
update_api_key,
|
|
30
|
+
)
|
|
31
|
+
from charmarr_lib.core._arr._protocols import (
|
|
32
|
+
MediaIndexerClient,
|
|
33
|
+
MediaManagerConnection,
|
|
34
|
+
)
|
|
35
|
+
from charmarr_lib.core._arr._reconcilers import (
|
|
36
|
+
reconcile_download_clients,
|
|
37
|
+
reconcile_external_url,
|
|
38
|
+
reconcile_media_manager_connections,
|
|
39
|
+
reconcile_root_folder,
|
|
40
|
+
)
|
|
41
|
+
from charmarr_lib.core._arr._recyclarr import (
|
|
42
|
+
RecyclarrError,
|
|
43
|
+
sync_trash_profiles,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ApplicationConfigBuilder",
|
|
48
|
+
"ArrApiClient",
|
|
49
|
+
"ArrApiConnectionError",
|
|
50
|
+
"ArrApiError",
|
|
51
|
+
"ArrApiResponseError",
|
|
52
|
+
"BaseArrApiClient",
|
|
53
|
+
"DownloadClientConfigBuilder",
|
|
54
|
+
"DownloadClientResponse",
|
|
55
|
+
"HostConfigResponse",
|
|
56
|
+
"MediaIndexerClient",
|
|
57
|
+
"MediaManagerConnection",
|
|
58
|
+
"QualityProfileResponse",
|
|
59
|
+
"RecyclarrError",
|
|
60
|
+
"RootFolderResponse",
|
|
61
|
+
"SecretGetter",
|
|
62
|
+
"config_has_api_key",
|
|
63
|
+
"generate_api_key",
|
|
64
|
+
"read_api_key",
|
|
65
|
+
"reconcile_config_xml",
|
|
66
|
+
"reconcile_download_clients",
|
|
67
|
+
"reconcile_external_url",
|
|
68
|
+
"reconcile_media_manager_connections",
|
|
69
|
+
"reconcile_root_folder",
|
|
70
|
+
"sync_trash_profiles",
|
|
71
|
+
"update_api_key",
|
|
72
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""API client for Radarr, Sonarr, and Lidarr (/api/v3)."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from charmarr_lib.core._arr._base_client import RESPONSE_MODEL_CONFIG, BaseArrApiClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DownloadClientResponse(BaseModel):
|
|
14
|
+
"""Download client response from *arr API."""
|
|
15
|
+
|
|
16
|
+
model_config = RESPONSE_MODEL_CONFIG
|
|
17
|
+
|
|
18
|
+
id: int
|
|
19
|
+
name: str
|
|
20
|
+
enable: bool
|
|
21
|
+
protocol: str
|
|
22
|
+
implementation: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RootFolderResponse(BaseModel):
|
|
26
|
+
"""Root folder response from *arr API."""
|
|
27
|
+
|
|
28
|
+
model_config = RESPONSE_MODEL_CONFIG
|
|
29
|
+
|
|
30
|
+
id: int
|
|
31
|
+
path: str
|
|
32
|
+
accessible: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class QualityProfileResponse(BaseModel):
|
|
36
|
+
"""Quality profile response from *arr API."""
|
|
37
|
+
|
|
38
|
+
model_config = RESPONSE_MODEL_CONFIG
|
|
39
|
+
|
|
40
|
+
id: int
|
|
41
|
+
name: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HostConfigResponse(BaseModel):
|
|
45
|
+
"""Host configuration response from *arr API."""
|
|
46
|
+
|
|
47
|
+
model_config = RESPONSE_MODEL_CONFIG
|
|
48
|
+
|
|
49
|
+
id: int
|
|
50
|
+
bind_address: str = Field(alias="bindAddress")
|
|
51
|
+
port: int
|
|
52
|
+
url_base: str | None = Field(default=None, alias="urlBase")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ArrApiClient(BaseArrApiClient):
|
|
56
|
+
"""API client for Radarr, Sonarr, and Lidarr (/api/v3).
|
|
57
|
+
|
|
58
|
+
Provides methods for managing download clients, root folders,
|
|
59
|
+
quality profiles, and host configuration.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
base_url: str,
|
|
65
|
+
api_key: str,
|
|
66
|
+
*,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
max_retries: int = 3,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Initialize the v3 API client.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
base_url: Base URL of the arr application (e.g., "http://localhost:7878")
|
|
74
|
+
api_key: API key for authentication
|
|
75
|
+
timeout: Request timeout in seconds
|
|
76
|
+
max_retries: Maximum number of retries for transient failures
|
|
77
|
+
"""
|
|
78
|
+
super().__init__(
|
|
79
|
+
base_url=base_url,
|
|
80
|
+
api_key=api_key,
|
|
81
|
+
api_version="v3",
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
max_retries=max_retries,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Download Clients
|
|
87
|
+
|
|
88
|
+
def get_download_clients(self) -> list[DownloadClientResponse]:
|
|
89
|
+
"""Get all configured download clients."""
|
|
90
|
+
return self._get_validated_list("/downloadclient", DownloadClientResponse)
|
|
91
|
+
|
|
92
|
+
def get_download_client(self, client_id: int) -> dict[str, Any]:
|
|
93
|
+
"""Get a download client by ID as raw dict.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
client_id: ID of the download client
|
|
97
|
+
"""
|
|
98
|
+
return self._get(f"/downloadclient/{client_id}")
|
|
99
|
+
|
|
100
|
+
def add_download_client(self, config: dict[str, Any]) -> DownloadClientResponse:
|
|
101
|
+
"""Add a new download client.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
config: Download client configuration payload
|
|
105
|
+
"""
|
|
106
|
+
return self._post_validated("/downloadclient", config, DownloadClientResponse)
|
|
107
|
+
|
|
108
|
+
def update_download_client(
|
|
109
|
+
self, client_id: int, config: dict[str, Any]
|
|
110
|
+
) -> DownloadClientResponse:
|
|
111
|
+
"""Update an existing download client.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
client_id: ID of the download client to update
|
|
115
|
+
config: Updated download client configuration
|
|
116
|
+
"""
|
|
117
|
+
config_with_id = {**config, "id": client_id}
|
|
118
|
+
return self._put_validated(
|
|
119
|
+
f"/downloadclient/{client_id}", config_with_id, DownloadClientResponse
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def delete_download_client(self, client_id: int) -> None:
|
|
123
|
+
"""Delete a download client.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
client_id: ID of the download client to delete
|
|
127
|
+
"""
|
|
128
|
+
self._delete(f"/downloadclient/{client_id}")
|
|
129
|
+
|
|
130
|
+
# Root Folders
|
|
131
|
+
|
|
132
|
+
def get_root_folders(self) -> list[RootFolderResponse]:
|
|
133
|
+
"""Get all configured root folders."""
|
|
134
|
+
return self._get_validated_list("/rootfolder", RootFolderResponse)
|
|
135
|
+
|
|
136
|
+
def add_root_folder(self, path: str) -> RootFolderResponse:
|
|
137
|
+
"""Add a new root folder.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
path: Filesystem path for the root folder
|
|
141
|
+
"""
|
|
142
|
+
return self._post_validated("/rootfolder", {"path": path}, RootFolderResponse)
|
|
143
|
+
|
|
144
|
+
# Quality Profiles (read-only for media-manager relation)
|
|
145
|
+
|
|
146
|
+
def get_quality_profiles(self) -> list[QualityProfileResponse]:
|
|
147
|
+
"""Get all configured quality profiles."""
|
|
148
|
+
return self._get_validated_list("/qualityprofile", QualityProfileResponse)
|
|
149
|
+
|
|
150
|
+
# Host Config (get_host_config_raw and update_host_config are in BaseArrApiClient)
|
|
151
|
+
|
|
152
|
+
def get_host_config(self) -> HostConfigResponse:
|
|
153
|
+
"""Get host configuration with typed response."""
|
|
154
|
+
return self._get_validated("/config/host", HostConfigResponse)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Base API client for *arr applications."""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
from tenacity import (
|
|
12
|
+
retry,
|
|
13
|
+
retry_if_exception_type,
|
|
14
|
+
stop_after_attempt,
|
|
15
|
+
wait_exponential,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Response models use extra="allow" to accept unknown fields from the API.
|
|
21
|
+
# This ensures forward compatibility when *arr APIs add new fields, while
|
|
22
|
+
# still providing type safety for the fields we actually use.
|
|
23
|
+
RESPONSE_MODEL_CONFIG = ConfigDict(extra="allow", populate_by_name=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArrApiError(Exception):
|
|
27
|
+
"""Base exception for arr API errors."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ArrApiConnectionError(ArrApiError):
|
|
35
|
+
"""Raised when connection to the API fails."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ArrApiResponseError(ArrApiError):
|
|
39
|
+
"""Raised when the API returns an error response."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaseArrApiClient:
|
|
43
|
+
"""Shared HTTP mechanics for all arr applications.
|
|
44
|
+
|
|
45
|
+
Provides common HTTP patterns for interacting with *arr application APIs.
|
|
46
|
+
Handles session management, API key authentication, and error handling
|
|
47
|
+
with exponential backoff retries for transient failures.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: str,
|
|
53
|
+
api_key: str,
|
|
54
|
+
api_version: str,
|
|
55
|
+
*,
|
|
56
|
+
timeout: float = 30.0,
|
|
57
|
+
max_retries: int = 3,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initialize the API client.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
base_url: Base URL of the arr application (e.g., "http://localhost:7878")
|
|
63
|
+
api_key: API key for authentication
|
|
64
|
+
api_version: API version string (e.g., "v3" for Radarr, "v1" for Prowlarr)
|
|
65
|
+
timeout: Request timeout in seconds
|
|
66
|
+
max_retries: Maximum number of retries for transient failures
|
|
67
|
+
"""
|
|
68
|
+
self._base_url = base_url.rstrip("/")
|
|
69
|
+
self._api_key = api_key
|
|
70
|
+
self._api_version = api_version
|
|
71
|
+
self._timeout = timeout
|
|
72
|
+
self._max_retries = max_retries
|
|
73
|
+
self._client: httpx.Client | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def client(self) -> httpx.Client:
|
|
77
|
+
"""Get or create the HTTP client with configured headers."""
|
|
78
|
+
if self._client is None:
|
|
79
|
+
self._client = httpx.Client(
|
|
80
|
+
headers={"X-Api-Key": self._api_key},
|
|
81
|
+
timeout=self._timeout,
|
|
82
|
+
)
|
|
83
|
+
return self._client
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
"""Close the HTTP client and release resources."""
|
|
87
|
+
if self._client is not None:
|
|
88
|
+
self._client.close()
|
|
89
|
+
self._client = None
|
|
90
|
+
|
|
91
|
+
def __enter__(self) -> Self:
|
|
92
|
+
"""Context manager entry."""
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
96
|
+
"""Context manager exit - close client."""
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
def _url(self, endpoint: str) -> str:
|
|
100
|
+
"""Build full URL for an API endpoint.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
endpoint: API endpoint path (e.g., "/downloadclient")
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Full URL including base URL and API version prefix
|
|
107
|
+
"""
|
|
108
|
+
endpoint = endpoint.lstrip("/")
|
|
109
|
+
return f"{self._base_url}/api/{self._api_version}/{endpoint}"
|
|
110
|
+
|
|
111
|
+
def _request(
|
|
112
|
+
self,
|
|
113
|
+
method: str,
|
|
114
|
+
endpoint: str,
|
|
115
|
+
*,
|
|
116
|
+
json: dict[str, Any] | None = None,
|
|
117
|
+
params: dict[str, Any] | None = None,
|
|
118
|
+
) -> httpx.Response:
|
|
119
|
+
"""Make an HTTP request with exponential backoff retry.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
123
|
+
endpoint: API endpoint path
|
|
124
|
+
json: JSON body for POST/PUT requests
|
|
125
|
+
params: Query parameters
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
HTTP response object
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ArrApiConnectionError: If connection fails after all retries
|
|
132
|
+
ArrApiResponseError: If the API returns an error response
|
|
133
|
+
"""
|
|
134
|
+
url = self._url(endpoint)
|
|
135
|
+
|
|
136
|
+
@retry(
|
|
137
|
+
retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException)),
|
|
138
|
+
stop=stop_after_attempt(self._max_retries),
|
|
139
|
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
|
140
|
+
reraise=True,
|
|
141
|
+
)
|
|
142
|
+
def _do_request() -> httpx.Response:
|
|
143
|
+
response = self.client.request(
|
|
144
|
+
method=method,
|
|
145
|
+
url=url,
|
|
146
|
+
json=json,
|
|
147
|
+
params=params,
|
|
148
|
+
)
|
|
149
|
+
response.raise_for_status()
|
|
150
|
+
return response
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
return _do_request()
|
|
154
|
+
except httpx.ConnectError as e:
|
|
155
|
+
raise ArrApiConnectionError(
|
|
156
|
+
f"Failed to connect to {url} after {self._max_retries} attempts"
|
|
157
|
+
) from e
|
|
158
|
+
except httpx.TimeoutException as e:
|
|
159
|
+
raise ArrApiConnectionError(
|
|
160
|
+
f"Request to {url} timed out after {self._max_retries} attempts"
|
|
161
|
+
) from e
|
|
162
|
+
except httpx.HTTPStatusError as e:
|
|
163
|
+
raise ArrApiResponseError(
|
|
164
|
+
f"API request failed: {e.response.status_code} {e.response.reason_phrase}",
|
|
165
|
+
status_code=e.response.status_code,
|
|
166
|
+
) from e
|
|
167
|
+
|
|
168
|
+
def _get(self, endpoint: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
169
|
+
"""Make a GET request and return JSON response.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
endpoint: API endpoint path
|
|
173
|
+
params: Optional query parameters
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Parsed JSON response (dict or list)
|
|
177
|
+
"""
|
|
178
|
+
response = self._request("GET", endpoint, params=params)
|
|
179
|
+
return response.json()
|
|
180
|
+
|
|
181
|
+
def _post(self, endpoint: str, json: dict[str, Any]) -> dict[str, Any]:
|
|
182
|
+
"""Make a POST request and return JSON response.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
endpoint: API endpoint path
|
|
186
|
+
json: JSON body to send
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Parsed JSON response
|
|
190
|
+
"""
|
|
191
|
+
response = self._request("POST", endpoint, json=json)
|
|
192
|
+
return response.json()
|
|
193
|
+
|
|
194
|
+
def _put(self, endpoint: str, json: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
"""Make a PUT request and return JSON response.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
endpoint: API endpoint path
|
|
199
|
+
json: JSON body to send
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Parsed JSON response
|
|
203
|
+
"""
|
|
204
|
+
response = self._request("PUT", endpoint, json=json)
|
|
205
|
+
return response.json()
|
|
206
|
+
|
|
207
|
+
def _delete(self, endpoint: str) -> None:
|
|
208
|
+
"""Make a DELETE request.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
endpoint: API endpoint path
|
|
212
|
+
"""
|
|
213
|
+
self._request("DELETE", endpoint)
|
|
214
|
+
|
|
215
|
+
def _get_validated[ModelT: BaseModel](
|
|
216
|
+
self,
|
|
217
|
+
endpoint: str,
|
|
218
|
+
response_model: type[ModelT],
|
|
219
|
+
*,
|
|
220
|
+
params: dict[str, Any] | None = None,
|
|
221
|
+
) -> ModelT:
|
|
222
|
+
"""Make a GET request and validate response against a Pydantic model.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
endpoint: API endpoint path
|
|
226
|
+
response_model: Pydantic model class to validate against
|
|
227
|
+
params: Optional query parameters
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Validated Pydantic model instance
|
|
231
|
+
"""
|
|
232
|
+
data = self._get(endpoint, params=params)
|
|
233
|
+
return response_model.model_validate(data)
|
|
234
|
+
|
|
235
|
+
def _get_validated_list[ModelT: BaseModel](
|
|
236
|
+
self,
|
|
237
|
+
endpoint: str,
|
|
238
|
+
item_model: type[ModelT],
|
|
239
|
+
*,
|
|
240
|
+
params: dict[str, Any] | None = None,
|
|
241
|
+
) -> list[ModelT]:
|
|
242
|
+
"""Make a GET request and validate response as a list of Pydantic models.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
endpoint: API endpoint path
|
|
246
|
+
item_model: Pydantic model class for list items
|
|
247
|
+
params: Optional query parameters
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of validated Pydantic model instances
|
|
251
|
+
"""
|
|
252
|
+
data = self._get(endpoint, params=params)
|
|
253
|
+
return [item_model.model_validate(item) for item in data]
|
|
254
|
+
|
|
255
|
+
def _post_validated[ModelT: BaseModel](
|
|
256
|
+
self,
|
|
257
|
+
endpoint: str,
|
|
258
|
+
json: dict[str, Any],
|
|
259
|
+
response_model: type[ModelT],
|
|
260
|
+
) -> ModelT:
|
|
261
|
+
"""Make a POST request and validate response against a Pydantic model.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
endpoint: API endpoint path
|
|
265
|
+
json: JSON body to send
|
|
266
|
+
response_model: Pydantic model class to validate against
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Validated Pydantic model instance
|
|
270
|
+
"""
|
|
271
|
+
data = self._post(endpoint, json)
|
|
272
|
+
return response_model.model_validate(data)
|
|
273
|
+
|
|
274
|
+
def _put_validated[ModelT: BaseModel](
|
|
275
|
+
self,
|
|
276
|
+
endpoint: str,
|
|
277
|
+
json: dict[str, Any],
|
|
278
|
+
response_model: type[ModelT],
|
|
279
|
+
) -> ModelT:
|
|
280
|
+
"""Make a PUT request and validate response against a Pydantic model.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
endpoint: API endpoint path
|
|
284
|
+
json: JSON body to send
|
|
285
|
+
response_model: Pydantic model class to validate against
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Validated Pydantic model instance
|
|
289
|
+
"""
|
|
290
|
+
data = self._put(endpoint, json)
|
|
291
|
+
return response_model.model_validate(data)
|
|
292
|
+
|
|
293
|
+
def get_host_config_raw(self) -> dict[str, Any]:
|
|
294
|
+
"""Get host configuration as raw dict.
|
|
295
|
+
|
|
296
|
+
All *arr applications have a /config/host endpoint that returns
|
|
297
|
+
settings like bindAddress, port, urlBase, and applicationUrl.
|
|
298
|
+
"""
|
|
299
|
+
return self._get("/config/host")
|
|
300
|
+
|
|
301
|
+
def update_host_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
302
|
+
"""Update host configuration.
|
|
303
|
+
|
|
304
|
+
Merges provided config with current settings and PUTs the result.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
config: Host configuration settings to update
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Updated host configuration
|
|
311
|
+
"""
|
|
312
|
+
current = self._get("/config/host")
|
|
313
|
+
updated = {**current, **config}
|
|
314
|
+
return self._put("/config/host", updated)
|