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,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})
|