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,78 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Juju relation interface implementations for Charmarr."""
|
|
5
|
+
|
|
6
|
+
from charmarr_lib.core.interfaces._download_client import (
|
|
7
|
+
DownloadClientChangedEvent,
|
|
8
|
+
DownloadClientProvider,
|
|
9
|
+
DownloadClientProviderData,
|
|
10
|
+
DownloadClientRequirer,
|
|
11
|
+
DownloadClientRequirerData,
|
|
12
|
+
)
|
|
13
|
+
from charmarr_lib.core.interfaces._flaresolverr import (
|
|
14
|
+
FlareSolverrChangedEvent,
|
|
15
|
+
FlareSolverrProvider,
|
|
16
|
+
FlareSolverrProviderData,
|
|
17
|
+
FlareSolverrRequirer,
|
|
18
|
+
)
|
|
19
|
+
from charmarr_lib.core.interfaces._media_indexer import (
|
|
20
|
+
MediaIndexerChangedEvent,
|
|
21
|
+
MediaIndexerProvider,
|
|
22
|
+
MediaIndexerProviderData,
|
|
23
|
+
MediaIndexerRequirer,
|
|
24
|
+
MediaIndexerRequirerData,
|
|
25
|
+
)
|
|
26
|
+
from charmarr_lib.core.interfaces._media_manager import (
|
|
27
|
+
MediaManagerChangedEvent,
|
|
28
|
+
MediaManagerProvider,
|
|
29
|
+
MediaManagerProviderData,
|
|
30
|
+
MediaManagerRequirer,
|
|
31
|
+
MediaManagerRequirerData,
|
|
32
|
+
QualityProfile,
|
|
33
|
+
)
|
|
34
|
+
from charmarr_lib.core.interfaces._media_server import (
|
|
35
|
+
MediaServerChangedEvent,
|
|
36
|
+
MediaServerProvider,
|
|
37
|
+
MediaServerProviderData,
|
|
38
|
+
MediaServerRequirer,
|
|
39
|
+
)
|
|
40
|
+
from charmarr_lib.core.interfaces._media_storage import (
|
|
41
|
+
MediaStorageChangedEvent,
|
|
42
|
+
MediaStorageProvider,
|
|
43
|
+
MediaStorageProviderData,
|
|
44
|
+
MediaStorageRequirer,
|
|
45
|
+
MediaStorageRequirerData,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DownloadClientChangedEvent",
|
|
50
|
+
"DownloadClientProvider",
|
|
51
|
+
"DownloadClientProviderData",
|
|
52
|
+
"DownloadClientRequirer",
|
|
53
|
+
"DownloadClientRequirerData",
|
|
54
|
+
"FlareSolverrChangedEvent",
|
|
55
|
+
"FlareSolverrProvider",
|
|
56
|
+
"FlareSolverrProviderData",
|
|
57
|
+
"FlareSolverrRequirer",
|
|
58
|
+
"MediaIndexerChangedEvent",
|
|
59
|
+
"MediaIndexerProvider",
|
|
60
|
+
"MediaIndexerProviderData",
|
|
61
|
+
"MediaIndexerRequirer",
|
|
62
|
+
"MediaIndexerRequirerData",
|
|
63
|
+
"MediaManagerChangedEvent",
|
|
64
|
+
"MediaManagerProvider",
|
|
65
|
+
"MediaManagerProviderData",
|
|
66
|
+
"MediaManagerRequirer",
|
|
67
|
+
"MediaManagerRequirerData",
|
|
68
|
+
"MediaServerChangedEvent",
|
|
69
|
+
"MediaServerProvider",
|
|
70
|
+
"MediaServerProviderData",
|
|
71
|
+
"MediaServerRequirer",
|
|
72
|
+
"MediaStorageChangedEvent",
|
|
73
|
+
"MediaStorageProvider",
|
|
74
|
+
"MediaStorageProviderData",
|
|
75
|
+
"MediaStorageRequirer",
|
|
76
|
+
"MediaStorageRequirerData",
|
|
77
|
+
"QualityProfile",
|
|
78
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Base classes for Juju relation interfaces."""
|
|
5
|
+
|
|
6
|
+
from abc import abstractmethod
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ops import EventBase, Object
|
|
10
|
+
from pydantic import BaseModel, ValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RelationInterfaceBase[TData: BaseModel, TRemote: BaseModel](Object):
|
|
14
|
+
"""Base class for relation interfaces with common patterns."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, charm: Any, relation_name: str) -> None:
|
|
17
|
+
super().__init__(charm, relation_name)
|
|
18
|
+
self._charm = charm
|
|
19
|
+
self._relation_name = relation_name
|
|
20
|
+
|
|
21
|
+
def _publish_to_all_relations(self, data: TData) -> None:
|
|
22
|
+
"""Publish data to all relations on this endpoint."""
|
|
23
|
+
if not self._charm.unit.is_leader():
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
for relation in self._charm.model.relations.get(self._relation_name, []):
|
|
27
|
+
relation.data[self._charm.app]["config"] = data.model_dump_json()
|
|
28
|
+
|
|
29
|
+
def _publish_to_single_relation(self, data: TData) -> None:
|
|
30
|
+
"""Publish data to the single relation on this endpoint."""
|
|
31
|
+
if not self._charm.unit.is_leader():
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
relation = self._charm.model.get_relation(self._relation_name)
|
|
35
|
+
if relation:
|
|
36
|
+
relation.data[self._charm.app]["config"] = data.model_dump_json()
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def _get_remote_data_model(self) -> type[TRemote]:
|
|
40
|
+
"""Return the Pydantic model class for parsing remote data."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def _get_all_remote_app_data(self) -> list[TRemote]:
|
|
44
|
+
"""Get parsed data from all remote applications on this endpoint."""
|
|
45
|
+
model_cls = self._get_remote_data_model()
|
|
46
|
+
results: list[TRemote] = []
|
|
47
|
+
for relation in self._charm.model.relations.get(self._relation_name, []):
|
|
48
|
+
try:
|
|
49
|
+
app_data = relation.data[relation.app]
|
|
50
|
+
if app_data and "config" in app_data:
|
|
51
|
+
results.append(model_cls.model_validate_json(app_data["config"]))
|
|
52
|
+
except (ValidationError, KeyError):
|
|
53
|
+
continue
|
|
54
|
+
return results
|
|
55
|
+
|
|
56
|
+
def _get_all_provider_data(self) -> list[TRemote]:
|
|
57
|
+
"""Get parsed data from all provider applications (for requirers with multiple relations)."""
|
|
58
|
+
model_cls = self._get_remote_data_model()
|
|
59
|
+
results: list[TRemote] = []
|
|
60
|
+
for relation in self._charm.model.relations.get(self._relation_name, []):
|
|
61
|
+
try:
|
|
62
|
+
provider_app = relation.app
|
|
63
|
+
if provider_app:
|
|
64
|
+
app_data = relation.data[provider_app]
|
|
65
|
+
if app_data and "config" in app_data:
|
|
66
|
+
results.append(model_cls.model_validate_json(app_data["config"]))
|
|
67
|
+
except (ValidationError, KeyError):
|
|
68
|
+
continue
|
|
69
|
+
return results
|
|
70
|
+
|
|
71
|
+
def _get_single_provider_data(self) -> TRemote | None:
|
|
72
|
+
"""Get parsed data from a single provider (for single-relation requirers)."""
|
|
73
|
+
model_cls = self._get_remote_data_model()
|
|
74
|
+
relation = self._charm.model.get_relation(self._relation_name)
|
|
75
|
+
if not relation:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
provider_app = relation.app
|
|
80
|
+
if provider_app:
|
|
81
|
+
app_data = relation.data[provider_app]
|
|
82
|
+
if app_data and "config" in app_data:
|
|
83
|
+
return model_cls.model_validate_json(app_data["config"])
|
|
84
|
+
except (ValidationError, KeyError):
|
|
85
|
+
pass
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EventObservingMixin:
|
|
90
|
+
"""Mixin for interfaces that observe relation events and emit custom events."""
|
|
91
|
+
|
|
92
|
+
_charm: Any
|
|
93
|
+
_relation_name: str
|
|
94
|
+
|
|
95
|
+
def _setup_event_observation(self) -> None:
|
|
96
|
+
"""Set up observation of relation_changed and relation_broken events."""
|
|
97
|
+
events = self._charm.on[self._relation_name]
|
|
98
|
+
self.framework.observe(events.relation_changed, self._emit_changed) # type: ignore[attr-defined]
|
|
99
|
+
self.framework.observe(events.relation_broken, self._emit_changed) # type: ignore[attr-defined]
|
|
100
|
+
|
|
101
|
+
def _emit_changed(self, event: EventBase) -> None:
|
|
102
|
+
"""Emit the custom 'changed' event."""
|
|
103
|
+
self.on.changed.emit() # type: ignore[attr-defined]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Download client interface for download client ↔ media manager integration."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ops import EventBase, EventSource, ObjectEvents
|
|
9
|
+
from pydantic import BaseModel, ValidationError, model_validator
|
|
10
|
+
|
|
11
|
+
from charmarr_lib.core.enums import DownloadClient, DownloadClientType, MediaManager
|
|
12
|
+
from charmarr_lib.core.interfaces._base import (
|
|
13
|
+
EventObservingMixin,
|
|
14
|
+
RelationInterfaceBase,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DownloadClientProviderData(BaseModel):
|
|
19
|
+
"""Data published by download clients.
|
|
20
|
+
|
|
21
|
+
Must have EITHER api_key_secret_id (SABnzbd) OR credentials_secret_id
|
|
22
|
+
(qBittorrent, Deluge, Transmission), but not both.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
api_url: str
|
|
26
|
+
api_key_secret_id: str | None = None
|
|
27
|
+
credentials_secret_id: str | None = None
|
|
28
|
+
client: DownloadClient
|
|
29
|
+
client_type: DownloadClientType
|
|
30
|
+
instance_name: str
|
|
31
|
+
base_path: str | None = None
|
|
32
|
+
|
|
33
|
+
@model_validator(mode="after")
|
|
34
|
+
def validate_auth_fields(self) -> "DownloadClientProviderData":
|
|
35
|
+
"""Validate that exactly one auth method is provided (XOR)."""
|
|
36
|
+
has_api_key = self.api_key_secret_id is not None
|
|
37
|
+
has_credentials = self.credentials_secret_id is not None
|
|
38
|
+
|
|
39
|
+
if not has_api_key and not has_credentials:
|
|
40
|
+
raise ValueError("Must provide either api_key_secret_id or credentials_secret_id")
|
|
41
|
+
|
|
42
|
+
if has_api_key and has_credentials:
|
|
43
|
+
raise ValueError("Cannot provide both api_key_secret_id and credentials_secret_id")
|
|
44
|
+
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DownloadClientRequirerData(BaseModel):
|
|
49
|
+
"""Data published by media managers."""
|
|
50
|
+
|
|
51
|
+
manager: MediaManager
|
|
52
|
+
instance_name: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DownloadClientChangedEvent(EventBase):
|
|
56
|
+
"""Event emitted when download-client relation state changes."""
|
|
57
|
+
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DownloadClientProvider(
|
|
62
|
+
RelationInterfaceBase[DownloadClientProviderData, DownloadClientRequirerData]
|
|
63
|
+
):
|
|
64
|
+
"""Provider side of download-client interface."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, charm: Any, relation_name: str = "download-client") -> None:
|
|
67
|
+
super().__init__(charm, relation_name)
|
|
68
|
+
|
|
69
|
+
def _get_remote_data_model(self) -> type[DownloadClientRequirerData]:
|
|
70
|
+
return DownloadClientRequirerData
|
|
71
|
+
|
|
72
|
+
def publish_data(self, data: DownloadClientProviderData) -> None:
|
|
73
|
+
"""Publish provider data to all relations."""
|
|
74
|
+
self._publish_to_all_relations(data)
|
|
75
|
+
|
|
76
|
+
def get_requirers(self) -> list[DownloadClientRequirerData]:
|
|
77
|
+
"""Get all connected requirers with valid data."""
|
|
78
|
+
return self._get_all_remote_app_data()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DownloadClientRequirerEvents(ObjectEvents):
|
|
82
|
+
"""Events emitted by DownloadClientRequirer."""
|
|
83
|
+
|
|
84
|
+
changed = EventSource(DownloadClientChangedEvent)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DownloadClientRequirer(
|
|
88
|
+
EventObservingMixin,
|
|
89
|
+
RelationInterfaceBase[DownloadClientRequirerData, DownloadClientProviderData],
|
|
90
|
+
):
|
|
91
|
+
"""Requirer side of download-client interface."""
|
|
92
|
+
|
|
93
|
+
on = DownloadClientRequirerEvents() # type: ignore[assignment]
|
|
94
|
+
|
|
95
|
+
def __init__(self, charm: Any, relation_name: str = "download-client") -> None:
|
|
96
|
+
super().__init__(charm, relation_name)
|
|
97
|
+
self._setup_event_observation()
|
|
98
|
+
|
|
99
|
+
def _get_remote_data_model(self) -> type[DownloadClientProviderData]:
|
|
100
|
+
return DownloadClientProviderData
|
|
101
|
+
|
|
102
|
+
def publish_data(self, data: DownloadClientRequirerData) -> None:
|
|
103
|
+
"""Publish requirer data to all relations."""
|
|
104
|
+
self._publish_to_all_relations(data)
|
|
105
|
+
|
|
106
|
+
def get_providers(self) -> list[DownloadClientProviderData]:
|
|
107
|
+
"""Get all connected download clients with valid data."""
|
|
108
|
+
return self._get_all_provider_data()
|
|
109
|
+
|
|
110
|
+
def is_ready(self) -> bool:
|
|
111
|
+
"""Check if requirer has published data and has >=1 valid provider."""
|
|
112
|
+
relations = self._charm.model.relations.get(self._relation_name, [])
|
|
113
|
+
if not relations:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
first_relation = relations[0]
|
|
118
|
+
published_data = first_relation.data[self._charm.app]
|
|
119
|
+
if not published_data or "config" not in published_data:
|
|
120
|
+
return False
|
|
121
|
+
DownloadClientRequirerData.model_validate_json(published_data["config"])
|
|
122
|
+
except (ValidationError, KeyError):
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
return len(self.get_providers()) > 0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""FlareSolverr interface for Cloudflare bypass proxy."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ops import EventBase, EventSource, ObjectEvents
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from charmarr_lib.core.interfaces._base import (
|
|
12
|
+
EventObservingMixin,
|
|
13
|
+
RelationInterfaceBase,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FlareSolverrProviderData(BaseModel):
|
|
18
|
+
"""Data published by flaresolverr-k8s charm."""
|
|
19
|
+
|
|
20
|
+
url: str = Field(description="FlareSolverr API URL (e.g., http://host:8191)")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FlareSolverrChangedEvent(EventBase):
|
|
24
|
+
"""Event emitted when flaresolverr relation state changes."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FlareSolverrProvider(RelationInterfaceBase[FlareSolverrProviderData, BaseModel]):
|
|
30
|
+
"""Provider side of flaresolverr interface."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, charm: Any, relation_name: str = "flaresolverr") -> None:
|
|
33
|
+
super().__init__(charm, relation_name)
|
|
34
|
+
|
|
35
|
+
def _get_remote_data_model(self) -> type[BaseModel]:
|
|
36
|
+
return BaseModel
|
|
37
|
+
|
|
38
|
+
def publish_data(self, data: FlareSolverrProviderData) -> None:
|
|
39
|
+
"""Publish provider data to all relations."""
|
|
40
|
+
self._publish_to_all_relations(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FlareSolverrRequirerEvents(ObjectEvents):
|
|
44
|
+
"""Events emitted by FlareSolverrRequirer."""
|
|
45
|
+
|
|
46
|
+
changed = EventSource(FlareSolverrChangedEvent)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FlareSolverrRequirer(
|
|
50
|
+
EventObservingMixin, RelationInterfaceBase[BaseModel, FlareSolverrProviderData]
|
|
51
|
+
):
|
|
52
|
+
"""Requirer side of flaresolverr interface."""
|
|
53
|
+
|
|
54
|
+
on = FlareSolverrRequirerEvents() # type: ignore[assignment]
|
|
55
|
+
|
|
56
|
+
def __init__(self, charm: Any, relation_name: str = "flaresolverr") -> None:
|
|
57
|
+
super().__init__(charm, relation_name)
|
|
58
|
+
self._setup_event_observation()
|
|
59
|
+
|
|
60
|
+
def _get_remote_data_model(self) -> type[FlareSolverrProviderData]:
|
|
61
|
+
return FlareSolverrProviderData
|
|
62
|
+
|
|
63
|
+
def get_provider(self) -> FlareSolverrProviderData | None:
|
|
64
|
+
"""Get FlareSolverr provider data if available."""
|
|
65
|
+
return self._get_single_provider_data()
|
|
66
|
+
|
|
67
|
+
def is_ready(self) -> bool:
|
|
68
|
+
"""Check if FlareSolverr is available."""
|
|
69
|
+
return self.get_provider() is not None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Media indexer interface for indexer manager ↔ media manager integration."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ops import EventBase, EventSource, ObjectEvents
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
|
|
11
|
+
from charmarr_lib.core.enums import MediaIndexer, MediaManager
|
|
12
|
+
from charmarr_lib.core.interfaces._base import (
|
|
13
|
+
EventObservingMixin,
|
|
14
|
+
RelationInterfaceBase,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MediaIndexerProviderData(BaseModel):
|
|
19
|
+
"""Data published by the indexer manager."""
|
|
20
|
+
|
|
21
|
+
api_url: str
|
|
22
|
+
api_key_secret_id: str
|
|
23
|
+
indexer: MediaIndexer
|
|
24
|
+
base_path: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MediaIndexerRequirerData(BaseModel):
|
|
28
|
+
"""Data published by the media manager."""
|
|
29
|
+
|
|
30
|
+
api_url: str
|
|
31
|
+
api_key_secret_id: str
|
|
32
|
+
manager: MediaManager
|
|
33
|
+
instance_name: str
|
|
34
|
+
base_path: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MediaIndexerChangedEvent(EventBase):
|
|
38
|
+
"""Event emitted when media-indexer relation state changes."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MediaIndexerProviderEvents(ObjectEvents):
|
|
44
|
+
"""Events emitted by MediaIndexerProvider."""
|
|
45
|
+
|
|
46
|
+
changed = EventSource(MediaIndexerChangedEvent)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MediaIndexerProvider(
|
|
50
|
+
EventObservingMixin, RelationInterfaceBase[MediaIndexerProviderData, MediaIndexerRequirerData]
|
|
51
|
+
):
|
|
52
|
+
"""Provider side of media-indexer interface."""
|
|
53
|
+
|
|
54
|
+
on = MediaIndexerProviderEvents() # type: ignore[assignment]
|
|
55
|
+
|
|
56
|
+
def __init__(self, charm: Any, relation_name: str = "media-indexer") -> None:
|
|
57
|
+
super().__init__(charm, relation_name)
|
|
58
|
+
self._setup_event_observation()
|
|
59
|
+
|
|
60
|
+
def _get_remote_data_model(self) -> type[MediaIndexerRequirerData]:
|
|
61
|
+
return MediaIndexerRequirerData
|
|
62
|
+
|
|
63
|
+
def publish_data(self, data: MediaIndexerProviderData) -> None:
|
|
64
|
+
"""Publish provider data to all relations."""
|
|
65
|
+
self._publish_to_all_relations(data)
|
|
66
|
+
|
|
67
|
+
def get_requirers(self) -> list[MediaIndexerRequirerData]:
|
|
68
|
+
"""Get all connected requirers with valid data."""
|
|
69
|
+
return self._get_all_remote_app_data()
|
|
70
|
+
|
|
71
|
+
def is_ready(self) -> bool:
|
|
72
|
+
"""Check if provider has published data and has >=1 valid requirer."""
|
|
73
|
+
relations = self._charm.model.relations.get(self._relation_name, [])
|
|
74
|
+
if not relations:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
first_relation = relations[0]
|
|
79
|
+
published_data = first_relation.data[self._charm.app]
|
|
80
|
+
if not published_data or "config" not in published_data:
|
|
81
|
+
return False
|
|
82
|
+
MediaIndexerProviderData.model_validate_json(published_data["config"])
|
|
83
|
+
except (ValidationError, KeyError):
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
return len(self.get_requirers()) > 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MediaIndexerRequirerEvents(ObjectEvents):
|
|
90
|
+
"""Events emitted by MediaIndexerRequirer."""
|
|
91
|
+
|
|
92
|
+
changed = EventSource(MediaIndexerChangedEvent)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MediaIndexerRequirer(
|
|
96
|
+
EventObservingMixin, RelationInterfaceBase[MediaIndexerRequirerData, MediaIndexerProviderData]
|
|
97
|
+
):
|
|
98
|
+
"""Requirer side of media-indexer interface."""
|
|
99
|
+
|
|
100
|
+
on = MediaIndexerRequirerEvents() # type: ignore[assignment]
|
|
101
|
+
|
|
102
|
+
def __init__(self, charm: Any, relation_name: str = "media-indexer") -> None:
|
|
103
|
+
super().__init__(charm, relation_name)
|
|
104
|
+
self._setup_event_observation()
|
|
105
|
+
|
|
106
|
+
def _get_remote_data_model(self) -> type[MediaIndexerProviderData]:
|
|
107
|
+
return MediaIndexerProviderData
|
|
108
|
+
|
|
109
|
+
def publish_data(self, data: MediaIndexerRequirerData) -> None:
|
|
110
|
+
"""Publish requirer data to relation."""
|
|
111
|
+
self._publish_to_single_relation(data)
|
|
112
|
+
|
|
113
|
+
def get_provider_data(self) -> MediaIndexerProviderData | None:
|
|
114
|
+
"""Get provider data if available."""
|
|
115
|
+
return self._get_single_provider_data()
|
|
116
|
+
|
|
117
|
+
def is_ready(self) -> bool:
|
|
118
|
+
"""Check if both requirer and provider have published valid data."""
|
|
119
|
+
relation = self._charm.model.get_relation(self._relation_name)
|
|
120
|
+
if not relation:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
published_data = relation.data[self._charm.app]
|
|
125
|
+
if not published_data or "config" not in published_data:
|
|
126
|
+
return False
|
|
127
|
+
MediaIndexerRequirerData.model_validate_json(published_data["config"])
|
|
128
|
+
except (ValidationError, KeyError):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
return self.get_provider_data() is not None
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Media manager interface for connecting media managers to request managers."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ops import EventBase, EventSource, ObjectEvents
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from charmarr_lib.core.enums import ContentVariant, MediaManager, RequestManager
|
|
12
|
+
from charmarr_lib.core.interfaces._base import (
|
|
13
|
+
EventObservingMixin,
|
|
14
|
+
RelationInterfaceBase,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QualityProfile(BaseModel):
|
|
19
|
+
"""Quality profile from Radarr/Sonarr."""
|
|
20
|
+
|
|
21
|
+
id: int = Field(description="Quality profile ID from the media manager API")
|
|
22
|
+
name: str = Field(description="Quality profile name (e.g., HD-Bluray+WEB, UHD-Bluray+WEB)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MediaManagerProviderData(BaseModel):
|
|
26
|
+
"""Data published by media manager charms (Radarr, Sonarr, etc.)."""
|
|
27
|
+
|
|
28
|
+
# Connection details
|
|
29
|
+
api_url: str = Field(description="Full API URL (e.g., http://radarr:7878)")
|
|
30
|
+
api_key_secret_id: str = Field(description="Juju secret ID containing API key")
|
|
31
|
+
|
|
32
|
+
# Identity
|
|
33
|
+
manager: MediaManager = Field(description="Type of media manager")
|
|
34
|
+
instance_name: str = Field(description="Juju app name (e.g., radarr-4k)")
|
|
35
|
+
base_path: str | None = Field(default=None, description="URL base path if configured")
|
|
36
|
+
|
|
37
|
+
# Configuration (populated from media manager API after Recyclarr sync)
|
|
38
|
+
quality_profiles: list[QualityProfile] = Field(
|
|
39
|
+
description="Available quality profiles from the media manager"
|
|
40
|
+
)
|
|
41
|
+
root_folders: list[str] = Field(description="Available root folder paths")
|
|
42
|
+
variant: ContentVariant = Field(
|
|
43
|
+
default=ContentVariant.STANDARD,
|
|
44
|
+
description="Content variant: standard (catch-all), 4k, or anime",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MediaManagerRequirerData(BaseModel):
|
|
49
|
+
"""Data published by request manager charms (Overseerr, Jellyseerr)."""
|
|
50
|
+
|
|
51
|
+
requester: RequestManager = Field(description="Type of request manager")
|
|
52
|
+
instance_name: str = Field(description="Juju application name")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MediaManagerChangedEvent(EventBase):
|
|
56
|
+
"""Event emitted when media-manager relation state changes."""
|
|
57
|
+
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MediaManagerProvider(
|
|
62
|
+
RelationInterfaceBase[MediaManagerProviderData, MediaManagerRequirerData]
|
|
63
|
+
):
|
|
64
|
+
"""Provider side of media-manager interface (passive - no events)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, charm: Any, relation_name: str = "media-manager") -> None:
|
|
67
|
+
super().__init__(charm, relation_name)
|
|
68
|
+
|
|
69
|
+
def _get_remote_data_model(self) -> type[MediaManagerRequirerData]:
|
|
70
|
+
return MediaManagerRequirerData
|
|
71
|
+
|
|
72
|
+
def publish_data(self, data: MediaManagerProviderData) -> None:
|
|
73
|
+
"""Publish provider data to all relations."""
|
|
74
|
+
self._publish_to_all_relations(data)
|
|
75
|
+
|
|
76
|
+
def get_requirers(self) -> list[MediaManagerRequirerData]:
|
|
77
|
+
"""Get data from all connected requirer applications."""
|
|
78
|
+
return self._get_all_remote_app_data()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MediaManagerRequirerEvents(ObjectEvents):
|
|
82
|
+
"""Events emitted by MediaManagerRequirer."""
|
|
83
|
+
|
|
84
|
+
changed = EventSource(MediaManagerChangedEvent)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MediaManagerRequirer(
|
|
88
|
+
EventObservingMixin, RelationInterfaceBase[MediaManagerRequirerData, MediaManagerProviderData]
|
|
89
|
+
):
|
|
90
|
+
"""Requirer side of media-manager interface."""
|
|
91
|
+
|
|
92
|
+
on = MediaManagerRequirerEvents() # type: ignore[assignment]
|
|
93
|
+
|
|
94
|
+
def __init__(self, charm: Any, relation_name: str = "media-manager") -> None:
|
|
95
|
+
super().__init__(charm, relation_name)
|
|
96
|
+
self._setup_event_observation()
|
|
97
|
+
|
|
98
|
+
def _get_remote_data_model(self) -> type[MediaManagerProviderData]:
|
|
99
|
+
return MediaManagerProviderData
|
|
100
|
+
|
|
101
|
+
def publish_data(self, data: MediaManagerRequirerData) -> None:
|
|
102
|
+
"""Publish requirer data to all relations."""
|
|
103
|
+
self._publish_to_all_relations(data)
|
|
104
|
+
|
|
105
|
+
def get_providers(self) -> list[MediaManagerProviderData]:
|
|
106
|
+
"""Get data from all connected provider applications."""
|
|
107
|
+
return self._get_all_provider_data()
|
|
108
|
+
|
|
109
|
+
def is_ready(self) -> bool:
|
|
110
|
+
"""Check if at least one media manager is connected and ready."""
|
|
111
|
+
return len(self.get_providers()) > 0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Media server interface for connecting media servers (Plex, Jellyfin) to request managers."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ops import EventBase, EventSource, ObjectEvents
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from charmarr_lib.core.interfaces._base import (
|
|
12
|
+
EventObservingMixin,
|
|
13
|
+
RelationInterfaceBase,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MediaServerProviderData(BaseModel):
|
|
18
|
+
"""Data published by media server charms (Plex, Jellyfin)."""
|
|
19
|
+
|
|
20
|
+
name: str = Field(description="Server name (e.g., 'My Plex Server')")
|
|
21
|
+
api_url: str = Field(description="API URL (e.g., http://plex-k8s:32400)")
|
|
22
|
+
web_url: str | None = Field(
|
|
23
|
+
default=None,
|
|
24
|
+
description="Optional web UI URL for 'Open in Plex' links",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MediaServerChangedEvent(EventBase):
|
|
29
|
+
"""Event emitted when media-server relation state changes."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MediaServerProvider(RelationInterfaceBase[MediaServerProviderData, BaseModel]):
|
|
35
|
+
"""Provider side of media-server interface (Plex, Jellyfin)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, charm: Any, relation_name: str = "media-server") -> None:
|
|
38
|
+
super().__init__(charm, relation_name)
|
|
39
|
+
|
|
40
|
+
def _get_remote_data_model(self) -> type[BaseModel]:
|
|
41
|
+
return BaseModel
|
|
42
|
+
|
|
43
|
+
def publish_data(self, data: MediaServerProviderData) -> None:
|
|
44
|
+
"""Publish provider data to all relations."""
|
|
45
|
+
self._publish_to_all_relations(data)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MediaServerRequirerEvents(ObjectEvents):
|
|
49
|
+
"""Events emitted by MediaServerRequirer."""
|
|
50
|
+
|
|
51
|
+
changed = EventSource(MediaServerChangedEvent)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MediaServerRequirer(
|
|
55
|
+
EventObservingMixin, RelationInterfaceBase[BaseModel, MediaServerProviderData]
|
|
56
|
+
):
|
|
57
|
+
"""Requirer side of media-server interface (Overseerr, Jellyseerr)."""
|
|
58
|
+
|
|
59
|
+
on = MediaServerRequirerEvents() # type: ignore[assignment]
|
|
60
|
+
|
|
61
|
+
def __init__(self, charm: Any, relation_name: str = "media-server") -> None:
|
|
62
|
+
super().__init__(charm, relation_name)
|
|
63
|
+
self._setup_event_observation()
|
|
64
|
+
|
|
65
|
+
def _get_remote_data_model(self) -> type[MediaServerProviderData]:
|
|
66
|
+
return MediaServerProviderData
|
|
67
|
+
|
|
68
|
+
def get_provider(self) -> MediaServerProviderData | None:
|
|
69
|
+
"""Get media server provider data if available."""
|
|
70
|
+
return self._get_single_provider_data()
|
|
71
|
+
|
|
72
|
+
def is_ready(self) -> bool:
|
|
73
|
+
"""Check if a media server is available."""
|
|
74
|
+
return self.get_provider() is not None
|