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.
Files changed (32) hide show
  1. charmarr_lib/core/__init__.py +126 -0
  2. charmarr_lib/core/_arr/__init__.py +72 -0
  3. charmarr_lib/core/_arr/_arr_client.py +154 -0
  4. charmarr_lib/core/_arr/_base_client.py +314 -0
  5. charmarr_lib/core/_arr/_config_builders.py +214 -0
  6. charmarr_lib/core/_arr/_config_xml.py +121 -0
  7. charmarr_lib/core/_arr/_protocols.py +54 -0
  8. charmarr_lib/core/_arr/_reconcilers.py +269 -0
  9. charmarr_lib/core/_arr/_recyclarr.py +150 -0
  10. charmarr_lib/core/_juju/__init__.py +27 -0
  11. charmarr_lib/core/_juju/_pebble.py +102 -0
  12. charmarr_lib/core/_juju/_reconciler.py +137 -0
  13. charmarr_lib/core/_juju/_secrets.py +44 -0
  14. charmarr_lib/core/_k8s/__init__.py +43 -0
  15. charmarr_lib/core/_k8s/_hardware.py +191 -0
  16. charmarr_lib/core/_k8s/_permission_check.py +310 -0
  17. charmarr_lib/core/_k8s/_storage.py +253 -0
  18. charmarr_lib/core/_variant.py +37 -0
  19. charmarr_lib/core/_version.py +3 -0
  20. charmarr_lib/core/constants.py +29 -0
  21. charmarr_lib/core/enums.py +55 -0
  22. charmarr_lib/core/interfaces/__init__.py +78 -0
  23. charmarr_lib/core/interfaces/_base.py +103 -0
  24. charmarr_lib/core/interfaces/_download_client.py +125 -0
  25. charmarr_lib/core/interfaces/_flaresolverr.py +69 -0
  26. charmarr_lib/core/interfaces/_media_indexer.py +131 -0
  27. charmarr_lib/core/interfaces/_media_manager.py +111 -0
  28. charmarr_lib/core/interfaces/_media_server.py +74 -0
  29. charmarr_lib/core/interfaces/_media_storage.py +99 -0
  30. charmarr_lib_core-0.12.2.dist-info/METADATA +136 -0
  31. charmarr_lib_core-0.12.2.dist-info/RECORD +32 -0
  32. charmarr_lib_core-0.12.2.dist-info/WHEEL +4 -0
@@ -0,0 +1,214 @@
1
+ # Copyright 2025 The Charmarr Project
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Config builders for transforming relation data into API payloads."""
5
+
6
+ from collections.abc import Callable
7
+ from urllib.parse import urlparse
8
+
9
+ from charmarr_lib.core.constants import MEDIA_MANAGER_IMPLEMENTATIONS
10
+ from charmarr_lib.core.enums import DownloadClient, MediaManager
11
+ from charmarr_lib.core.interfaces import (
12
+ DownloadClientProviderData,
13
+ MediaIndexerRequirerData,
14
+ )
15
+
16
+ # Type alias for secret retrieval callback.
17
+ # Takes secret_id, returns dict with secret content (e.g., {"username": "...", "password": "..."})
18
+ SecretGetter = Callable[[str], dict[str, str]]
19
+
20
+ # Maps media manager types to the category field name in download client API payloads.
21
+ # Each *arr application uses a different field name for the download category.
22
+ _MEDIA_MANAGER_CATEGORY_FIELDS: dict[MediaManager, str] = {
23
+ MediaManager.RADARR: "movieCategory",
24
+ MediaManager.SONARR: "tvCategory",
25
+ MediaManager.LIDARR: "musicCategory",
26
+ MediaManager.READARR: "bookCategory",
27
+ MediaManager.WHISPARR: "movieCategory", # Uses same as Radarr
28
+ }
29
+
30
+ # Maps media manager types to Prowlarr sync category IDs (NewzNab standard).
31
+ # These are the default categories from Prowlarr's application schema.
32
+ # https://wiki.servarr.com/en/prowlarr/settings#categories
33
+ #
34
+ # NewzNab category ID ranges by media type:
35
+ # 2000-2090: Movies (2000=Movies, 2010=Foreign, 2020=Other, 2030=SD, 2040=HD,
36
+ # 2045=UHD, 2050=BluRay, 2060=3D, 2070=DVD, 2080=WEB-DL, 2090=x265)
37
+ # 3000-3060: Audio (3000=Audio, 3010=MP3, 3020=Video, 3030=Audiobook,
38
+ # 3040=Lossless, 3050=Podcast, 3060=Foreign)
39
+ # 5000-5090: TV (5000=TV, 5010=WEB-DL, 5020=Foreign, 5030=SD, 5040=HD,
40
+ # 5045=UHD, 5050=Other, 5060=Sport, 5070=Anime, 5080=Documentary, 5090=x265)
41
+ # 6000-6090: XXX (same structure as Movies)
42
+ # 7000-7060: Books (7000=Books, 7010=Mags, 7020=EBook, 7030=Comics,
43
+ # 7040=Technical, 7050=Foreign, 7060=Undefined)
44
+ _MEDIA_MANAGER_SYNC_CATEGORIES: dict[MediaManager, list[int]] = {
45
+ MediaManager.RADARR: [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080, 2090],
46
+ MediaManager.SONARR: [5000, 5010, 5020, 5030, 5040, 5045, 5050, 5060, 5070, 5080, 5090],
47
+ MediaManager.LIDARR: [3000, 3010, 3020, 3030, 3040, 3050, 3060],
48
+ MediaManager.READARR: [7000, 7010, 7020, 7030, 7040, 7050, 7060],
49
+ MediaManager.WHISPARR: [6000, 6010, 6020, 6030, 6040, 6045, 6050, 6060, 6070, 6080, 6090],
50
+ }
51
+
52
+
53
+ class DownloadClientConfigBuilder:
54
+ """Build download client API payloads from relation data.
55
+
56
+ Transforms DownloadClientProviderData into *arr API payloads
57
+ for configuring download clients (qBittorrent, SABnzbd).
58
+ """
59
+
60
+ @staticmethod
61
+ def build(
62
+ provider: DownloadClientProviderData,
63
+ category: str,
64
+ media_manager: MediaManager,
65
+ get_secret: SecretGetter,
66
+ ) -> dict:
67
+ """Transform relation data into *arr API payload.
68
+
69
+ Args:
70
+ provider: Download client relation data
71
+ category: Category name for downloads (e.g., "radarr", "sonarr")
72
+ media_manager: The type of media manager calling this builder
73
+ get_secret: Callback to retrieve secret content by ID
74
+
75
+ Returns:
76
+ API payload dict ready for add_download_client()
77
+
78
+ Raises:
79
+ ValueError: If client type is not supported
80
+ KeyError: If media_manager is not in category fields mapping
81
+ """
82
+ category_field = _MEDIA_MANAGER_CATEGORY_FIELDS[media_manager]
83
+
84
+ if provider.client == DownloadClient.QBITTORRENT:
85
+ return DownloadClientConfigBuilder._build_qbittorrent(
86
+ provider, category, category_field, get_secret
87
+ )
88
+ elif provider.client == DownloadClient.SABNZBD:
89
+ return DownloadClientConfigBuilder._build_sabnzbd(
90
+ provider, category, category_field, get_secret
91
+ )
92
+ else:
93
+ raise ValueError(f"Unsupported download client: {provider.client}")
94
+
95
+ @staticmethod
96
+ def _build_qbittorrent(
97
+ provider: DownloadClientProviderData,
98
+ category: str,
99
+ category_field: str,
100
+ get_secret: SecretGetter,
101
+ ) -> dict:
102
+ """Build qBittorrent download client config."""
103
+ if provider.credentials_secret_id is None:
104
+ raise ValueError("qBittorrent requires credentials_secret_id")
105
+ credentials = get_secret(provider.credentials_secret_id)
106
+
107
+ parsed = urlparse(provider.api_url)
108
+
109
+ return {
110
+ "enable": True,
111
+ "protocol": "torrent",
112
+ "priority": 1,
113
+ "name": provider.instance_name,
114
+ "implementation": "QBittorrent",
115
+ "configContract": "QBittorrentSettings",
116
+ "fields": [
117
+ {"name": "host", "value": parsed.hostname},
118
+ {"name": "port", "value": parsed.port or 8080},
119
+ {"name": "useSsl", "value": parsed.scheme == "https"},
120
+ {"name": "urlBase", "value": provider.base_path or ""},
121
+ {"name": "username", "value": credentials["username"]},
122
+ {"name": "password", "value": credentials["password"]},
123
+ {"name": category_field, "value": category},
124
+ ],
125
+ "tags": [],
126
+ }
127
+
128
+ @staticmethod
129
+ def _build_sabnzbd(
130
+ provider: DownloadClientProviderData,
131
+ category: str,
132
+ category_field: str,
133
+ get_secret: SecretGetter,
134
+ ) -> dict:
135
+ """Build SABnzbd download client config."""
136
+ if provider.api_key_secret_id is None:
137
+ raise ValueError("SABnzbd requires api_key_secret_id")
138
+ secret = get_secret(provider.api_key_secret_id)
139
+ api_key = secret["api-key"]
140
+
141
+ parsed = urlparse(provider.api_url)
142
+
143
+ return {
144
+ "enable": True,
145
+ "protocol": "usenet",
146
+ "priority": 1,
147
+ "name": provider.instance_name,
148
+ "implementation": "Sabnzbd",
149
+ "configContract": "SabnzbdSettings",
150
+ "fields": [
151
+ {"name": "host", "value": parsed.hostname},
152
+ {"name": "port", "value": parsed.port or 8080},
153
+ {"name": "useSsl", "value": parsed.scheme == "https"},
154
+ {"name": "urlBase", "value": provider.base_path or ""},
155
+ {"name": "apiKey", "value": api_key},
156
+ {"name": category_field, "value": category},
157
+ ],
158
+ "tags": [],
159
+ }
160
+
161
+
162
+ class ApplicationConfigBuilder:
163
+ """Build media indexer application API payloads from relation data.
164
+
165
+ Transforms MediaIndexerRequirerData into application payloads
166
+ for configuring connections to media managers (Radarr, Sonarr, etc.).
167
+
168
+ Note: Currently builds Prowlarr-compatible payloads. The field names
169
+ like "prowlarrUrl" are Prowlarr-specific API requirements.
170
+ """
171
+
172
+ @staticmethod
173
+ def build(
174
+ requirer: MediaIndexerRequirerData,
175
+ indexer_url: str,
176
+ get_secret: SecretGetter,
177
+ ) -> dict:
178
+ """Transform relation data into application payload.
179
+
180
+ Args:
181
+ requirer: Media indexer requirer relation data
182
+ indexer_url: URL of the indexer instance
183
+ get_secret: Callback to retrieve secret content by ID
184
+
185
+ Returns:
186
+ API payload dict ready for add_application()
187
+
188
+ Raises:
189
+ KeyError: If manager type is not in MEDIA_MANAGER_IMPLEMENTATIONS
190
+ """
191
+ implementation, config_contract = MEDIA_MANAGER_IMPLEMENTATIONS[requirer.manager]
192
+
193
+ secret = get_secret(requirer.api_key_secret_id)
194
+ api_key = secret["api-key"]
195
+
196
+ base_url = requirer.api_url.rstrip("/")
197
+ if requirer.base_path:
198
+ base_url = base_url + requirer.base_path
199
+
200
+ sync_categories = _MEDIA_MANAGER_SYNC_CATEGORIES.get(requirer.manager, [])
201
+
202
+ return {
203
+ "name": requirer.instance_name,
204
+ "syncLevel": "fullSync",
205
+ "implementation": implementation,
206
+ "configContract": config_contract,
207
+ "fields": [
208
+ {"name": "prowlarrUrl", "value": indexer_url},
209
+ {"name": "baseUrl", "value": base_url},
210
+ {"name": "apiKey", "value": api_key},
211
+ {"name": "syncCategories", "value": sync_categories},
212
+ ],
213
+ "tags": [],
214
+ }
@@ -0,0 +1,121 @@
1
+ # Copyright 2025 The Charmarr Project
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Configuration XML utilities for *ARR applications.
5
+
6
+ All arr applications (Prowlarr, Radarr, Sonarr, Lidarr) store their
7
+ configuration in /config/config.xml including the auto-generated API key.
8
+ """
9
+
10
+ import re
11
+ import secrets
12
+ import string
13
+
14
+
15
+ def read_api_key(config_content: str) -> str | None:
16
+ """Extract API key from arr config.xml content.
17
+
18
+ Args:
19
+ config_content: The XML content of config.xml
20
+
21
+ Returns:
22
+ The API key string, or None if not found
23
+ """
24
+ match = re.search(r"<ApiKey>([^<]+)</ApiKey>", config_content)
25
+ return match.group(1) if match else None
26
+
27
+
28
+ def config_has_api_key(config_content: str, api_key: str) -> bool:
29
+ """Check if config.xml contains the expected API key.
30
+
31
+ Args:
32
+ config_content: The XML content of config.xml
33
+ api_key: The expected API key
34
+
35
+ Returns:
36
+ True if config has this exact API key, False otherwise
37
+ """
38
+ return read_api_key(config_content) == api_key
39
+
40
+
41
+ def generate_api_key() -> str:
42
+ """Generate a secure API key for arr applications.
43
+
44
+ Returns:
45
+ A 32-character lowercase alphanumeric string suitable for arr API keys
46
+ """
47
+ alphabet = string.ascii_lowercase + string.digits
48
+ return "".join(secrets.choice(alphabet) for _ in range(32))
49
+
50
+
51
+ def update_api_key(config_content: str, new_api_key: str) -> str:
52
+ """Update API key in config.xml content.
53
+
54
+ Args:
55
+ config_content: The XML content of config.xml
56
+ new_api_key: The new API key to set
57
+
58
+ Returns:
59
+ Updated config.xml content
60
+ """
61
+ return re.sub(
62
+ r"<ApiKey>[^<]*</ApiKey>",
63
+ f"<ApiKey>{new_api_key}</ApiKey>",
64
+ config_content,
65
+ )
66
+
67
+
68
+ def _set_element(content: str, element: str, value: str | int) -> str:
69
+ """Add or update an XML element."""
70
+ pattern = rf"<{element}>[^<]*</{element}>"
71
+ replacement = f"<{element}>{value}</{element}>"
72
+ if re.search(pattern, content):
73
+ return re.sub(pattern, replacement, content)
74
+ return re.sub(r"(</Config>)", f" {replacement}\n\\1", content)
75
+
76
+
77
+ def _remove_element(content: str, element: str) -> str:
78
+ """Remove an XML element if it exists."""
79
+ return re.sub(rf"\s*<{element}>[^<]*</{element}>\s*", "", content)
80
+
81
+
82
+ def reconcile_config_xml(
83
+ content: str | None,
84
+ *,
85
+ api_key: str | None = None,
86
+ url_base: str | None = None,
87
+ port: int | None = None,
88
+ bind_address: str | None = None,
89
+ ) -> str:
90
+ """Reconcile config.xml content idempotently.
91
+
92
+ Creates, updates, or removes config elements based on provided values.
93
+ Preserves all other settings (authentication, user preferences, etc.).
94
+
95
+ Args:
96
+ content: Existing config.xml content, or None to create fresh
97
+ api_key: API key value, or None to remove
98
+ url_base: URL base path, or None to remove
99
+ port: Port number, or None to remove
100
+ bind_address: Bind address, or None to remove
101
+
102
+ Returns:
103
+ Updated config.xml content
104
+ """
105
+ if content is None:
106
+ content = '<?xml version="1.0" encoding="utf-8"?>\n<Config>\n</Config>\n'
107
+
108
+ elements = {
109
+ "ApiKey": api_key,
110
+ "UrlBase": url_base,
111
+ "Port": port,
112
+ "BindAddress": bind_address,
113
+ }
114
+
115
+ for element, value in elements.items():
116
+ if value is not None:
117
+ content = _set_element(content, element, value)
118
+ else:
119
+ content = _remove_element(content, element)
120
+
121
+ return content
@@ -0,0 +1,54 @@
1
+ # Copyright 2025 The Charmarr Project
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Protocols for *arr API clients.
5
+
6
+ These protocols define the contracts that API clients must follow
7
+ to be used with the generic reconcilers. Implementations typically
8
+ extend BaseArrApiClient for HTTP mechanics.
9
+ """
10
+
11
+ from typing import Any, Protocol
12
+
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class MediaManagerConnection(BaseModel):
17
+ """A media manager connection registered in an indexer.
18
+
19
+ Represents a connection from an indexer (e.g., Prowlarr) to a
20
+ media manager (e.g., Radarr, Sonarr). The indexer syncs indexers
21
+ to these connected applications.
22
+ """
23
+
24
+ id: int
25
+ name: str
26
+
27
+
28
+ class MediaIndexerClient(Protocol):
29
+ """Protocol for media indexer API clients (e.g., Prowlarr).
30
+
31
+ Any client implementing this protocol can be used with
32
+ reconcile_media_manager_connections. Implementations typically
33
+ extend BaseArrApiClient and add these methods.
34
+ """
35
+
36
+ def get_applications(self) -> list[MediaManagerConnection]:
37
+ """Get all configured media manager connections."""
38
+ ...
39
+
40
+ def get_application(self, app_id: int) -> dict[str, Any]:
41
+ """Get a single application by ID as raw dict."""
42
+ ...
43
+
44
+ def add_application(self, config: dict[str, Any]) -> Any:
45
+ """Add a new media manager connection."""
46
+ ...
47
+
48
+ def update_application(self, app_id: int, config: dict[str, Any]) -> Any:
49
+ """Update an existing media manager connection."""
50
+ ...
51
+
52
+ def delete_application(self, app_id: int) -> None:
53
+ """Delete a media manager connection."""
54
+ ...
@@ -0,0 +1,269 @@
1
+ # Copyright 2025 The Charmarr Project
2
+ # See LICENSE file for licensing details.
3
+
4
+ """Reconcilers for synchronizing *arr application state with Juju relations."""
5
+
6
+ import logging
7
+ from typing import Any, Protocol
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from charmarr_lib.core._arr._arr_client import ArrApiClient
12
+ from charmarr_lib.core._arr._base_client import ArrApiError, BaseArrApiClient
13
+ from charmarr_lib.core._arr._config_builders import (
14
+ ApplicationConfigBuilder,
15
+ DownloadClientConfigBuilder,
16
+ SecretGetter,
17
+ )
18
+ from charmarr_lib.core._arr._protocols import MediaIndexerClient
19
+ from charmarr_lib.core.enums import MediaManager
20
+ from charmarr_lib.core.interfaces import (
21
+ DownloadClientProviderData,
22
+ MediaIndexerRequirerData,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class NamedItem(Protocol):
29
+ """Protocol for items with id and name attributes."""
30
+
31
+ @property
32
+ def id(self) -> int: ...
33
+
34
+ @property
35
+ def name(self) -> str: ...
36
+
37
+
38
+ class ReconcileOperations[T: NamedItem](Protocol):
39
+ """Protocol defining operations needed for generic reconciliation."""
40
+
41
+ def get_current(self) -> list[T]: ...
42
+ def get_full(self, item_id: int) -> dict[str, Any]: ...
43
+ def delete(self, item_id: int) -> None: ...
44
+ def add(self, config: dict[str, Any]) -> Any: ...
45
+ def update(self, item_id: int, config: dict[str, Any]) -> Any: ...
46
+
47
+
48
+ def _extract_field_value(fields: list[dict[str, Any]], field_name: str) -> Any:
49
+ """Extract a field value from *arr API fields array."""
50
+ for field in fields:
51
+ if field.get("name") == field_name:
52
+ return field.get("value")
53
+ return None
54
+
55
+
56
+ def _needs_config_update(
57
+ existing: dict[str, Any],
58
+ desired: dict[str, Any],
59
+ top_level_keys: list[str],
60
+ ) -> bool:
61
+ """Check if *arr config needs to be updated by comparing top-level keys and fields."""
62
+ for key in top_level_keys:
63
+ if existing.get(key) != desired.get(key):
64
+ return True
65
+
66
+ existing_fields = existing.get("fields", [])
67
+ desired_fields = desired.get("fields", [])
68
+ desired_field_values = {f["name"]: f.get("value") for f in desired_fields}
69
+
70
+ for field_name, desired_value in desired_field_values.items():
71
+ existing_value = _extract_field_value(existing_fields, field_name)
72
+ if existing_value != desired_value:
73
+ return True
74
+
75
+ return False
76
+
77
+
78
+ _DOWNLOAD_CLIENT_KEYS = ["enable", "protocol", "implementation", "configContract"]
79
+ _APPLICATION_KEYS = ["syncLevel", "implementation", "configContract"]
80
+
81
+
82
+ def _reconcile_items[T: NamedItem](
83
+ ops: ReconcileOperations[T],
84
+ desired_configs: dict[str, dict[str, Any]],
85
+ comparison_keys: list[str],
86
+ item_type_name: str,
87
+ ) -> None:
88
+ """Generic reconciliation of *arr API items.
89
+
90
+ Args:
91
+ ops: Operations for interacting with the API
92
+ desired_configs: Mapping of item name to desired configuration
93
+ comparison_keys: Top-level keys to compare for update detection
94
+ item_type_name: Human-readable name for logging (e.g., "download client")
95
+ """
96
+ current = ops.get_current()
97
+ current_by_name = {item.name: item for item in current}
98
+
99
+ for name, current_item in current_by_name.items():
100
+ if name not in desired_configs:
101
+ logger.info("Removing %s: %s", item_type_name, name)
102
+ ops.delete(current_item.id)
103
+
104
+ for name, desired_config in desired_configs.items():
105
+ try:
106
+ existing = current_by_name.get(name)
107
+ if existing:
108
+ existing_full = ops.get_full(existing.id)
109
+ if _needs_config_update(existing_full, desired_config, comparison_keys):
110
+ logger.info("Updating %s: %s", item_type_name, name)
111
+ ops.update(existing.id, desired_config)
112
+ else:
113
+ logger.info("Adding %s: %s", item_type_name, name)
114
+ ops.add(desired_config)
115
+ except (ArrApiError, ValidationError) as e:
116
+ logger.warning("Failed to reconcile %s %s: %s", item_type_name, name, e)
117
+
118
+
119
+ class _DownloadClientOps:
120
+ """Operations adapter for download client reconciliation."""
121
+
122
+ def __init__(self, client: ArrApiClient) -> None:
123
+ self._client = client
124
+
125
+ def get_current(self):
126
+ return self._client.get_download_clients()
127
+
128
+ def get_full(self, item_id: int) -> dict[str, Any]:
129
+ return self._client.get_download_client(item_id)
130
+
131
+ def delete(self, item_id: int) -> None:
132
+ self._client.delete_download_client(item_id)
133
+
134
+ def add(self, config: dict[str, Any]):
135
+ return self._client.add_download_client(config)
136
+
137
+ def update(self, item_id: int, config: dict[str, Any]):
138
+ return self._client.update_download_client(item_id, config)
139
+
140
+
141
+ class _ApplicationOps:
142
+ """Operations adapter for media manager application reconciliation."""
143
+
144
+ def __init__(self, client: MediaIndexerClient) -> None:
145
+ self._client = client
146
+
147
+ def get_current(self):
148
+ return self._client.get_applications()
149
+
150
+ def get_full(self, item_id: int) -> dict[str, Any]:
151
+ return self._client.get_application(item_id)
152
+
153
+ def delete(self, item_id: int) -> None:
154
+ self._client.delete_application(item_id)
155
+
156
+ def add(self, config: dict[str, Any]):
157
+ return self._client.add_application(config)
158
+
159
+ def update(self, item_id: int, config: dict[str, Any]):
160
+ return self._client.update_application(item_id, config)
161
+
162
+
163
+ def reconcile_download_clients(
164
+ api_client: ArrApiClient,
165
+ desired_clients: list[DownloadClientProviderData],
166
+ category: str,
167
+ media_manager: MediaManager,
168
+ get_secret: SecretGetter,
169
+ ) -> None:
170
+ """Reconcile download clients in Radarr/Sonarr/Lidarr.
171
+
172
+ Syncs download client configuration (qBittorrent, SABnzbd) to match
173
+ the desired state from Juju relations. Clients not in desired_clients
174
+ will be deleted.
175
+
176
+ Args:
177
+ api_client: API client for Radarr/Sonarr/Lidarr
178
+ desired_clients: Download client data from relations
179
+ category: Category name for downloads (e.g., "radarr", "sonarr")
180
+ media_manager: The type of media manager (determines category field name)
181
+ get_secret: Callback to retrieve secret content by ID
182
+ """
183
+ desired_configs: dict[str, dict[str, Any]] = {}
184
+ for provider in desired_clients:
185
+ config = DownloadClientConfigBuilder.build(
186
+ provider=provider,
187
+ category=category,
188
+ media_manager=media_manager,
189
+ get_secret=get_secret,
190
+ )
191
+ desired_configs[provider.instance_name] = config
192
+
193
+ _reconcile_items(
194
+ _DownloadClientOps(api_client),
195
+ desired_configs,
196
+ _DOWNLOAD_CLIENT_KEYS,
197
+ "download client",
198
+ )
199
+
200
+
201
+ def reconcile_media_manager_connections(
202
+ api_client: MediaIndexerClient,
203
+ desired_managers: list[MediaIndexerRequirerData],
204
+ indexer_url: str,
205
+ get_secret: SecretGetter,
206
+ ) -> None:
207
+ """Reconcile media manager connections in an indexer application.
208
+
209
+ Syncs application configuration (connections to Radarr/Sonarr/Lidarr)
210
+ to match the desired state from Juju relations. Connections not in
211
+ desired_managers will be deleted.
212
+
213
+ Args:
214
+ api_client: API client implementing MediaIndexerClient protocol
215
+ desired_managers: Media manager data from media-indexer relations
216
+ indexer_url: URL of the indexer instance (e.g., Prowlarr)
217
+ get_secret: Callback to retrieve secret content by ID
218
+ """
219
+ desired_configs: dict[str, dict[str, Any]] = {}
220
+ for requirer in desired_managers:
221
+ config = ApplicationConfigBuilder.build(
222
+ requirer=requirer,
223
+ indexer_url=indexer_url,
224
+ get_secret=get_secret,
225
+ )
226
+ desired_configs[requirer.instance_name] = config
227
+
228
+ _reconcile_items(
229
+ _ApplicationOps(api_client),
230
+ desired_configs,
231
+ _APPLICATION_KEYS,
232
+ "media manager connection",
233
+ )
234
+
235
+
236
+ def reconcile_root_folder(
237
+ api_client: ArrApiClient,
238
+ path: str,
239
+ ) -> None:
240
+ """Ensure root folder exists in Radarr/Sonarr/Lidarr. Additive only.
241
+
242
+ Args:
243
+ api_client: API client for Radarr/Sonarr/Lidarr
244
+ path: Filesystem path that should exist as a root folder
245
+ """
246
+ existing = api_client.get_root_folders()
247
+ existing_paths = {rf.path for rf in existing}
248
+
249
+ if path not in existing_paths:
250
+ logger.info("Adding root folder: %s", path)
251
+ api_client.add_root_folder(path)
252
+
253
+
254
+ def reconcile_external_url(
255
+ api_client: BaseArrApiClient,
256
+ external_url: str,
257
+ ) -> None:
258
+ """Configure external URL in any *arr application host config.
259
+
260
+ Args:
261
+ api_client: Any *arr API client (extends BaseArrApiClient)
262
+ external_url: External URL for the application
263
+ """
264
+ current_full = api_client.get_host_config_raw()
265
+ current_external_url = current_full.get("applicationUrl", "")
266
+
267
+ if current_external_url != external_url:
268
+ logger.info("Updating external URL to: %s", external_url)
269
+ api_client.update_host_config({"applicationUrl": external_url})