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