/
/
/
1"""
2Plex Connect plugin for Music Assistant.
3
4This plugin allows Music Assistant players to appear as controllable devices
5in the official Plex apps (Plexamp, web player, etc.). Each plugin instance
6links a single MA player to Plex, making it available for remote control.
7
8Multiple instances can be created to expose multiple MA players to Plex.
9"""
10
11from __future__ import annotations
12
13import asyncio
14from collections.abc import Callable
15from typing import TYPE_CHECKING, cast
16
17from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
18from music_assistant_models.enums import ConfigEntryType, EventType, ProviderFeature
19
20from music_assistant.helpers.util import is_port_in_use, select_free_port
21from music_assistant.models.plugin import PluginProvider
22
23from .server import PlayerRemoteInstance
24
25if TYPE_CHECKING:
26 from music_assistant_models.config_entries import ProviderConfig
27 from music_assistant_models.event import MassEvent
28 from music_assistant_models.provider import ProviderManifest
29
30 from music_assistant.mass import MusicAssistant
31 from music_assistant.models import ProviderInstanceType
32 from music_assistant.providers.plex import PlexProvider
33
34CONF_MASS_PLAYER_ID = "mass_player_id"
35CONF_PLEX_PROVIDER_ID = "plex_provider_id"
36CONF_PLAYER_NAME = "player_name"
37CONF_DEVICE_CLASS = "device_class"
38CONF_PORT = "port"
39
40# Range to search for a free port when auto-assigning one for an instance.
41PORT_RANGE_START = 32500
42PORT_RANGE_ATTEMPTS = 100
43
44# No special features needed for this plugin
45SUPPORTED_FEATURES: set[ProviderFeature] = set()
46
47
48async def setup(
49 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
50) -> ProviderInstanceType:
51 """Initialize provider(instance) with given configuration."""
52 return PlexConnectProvider(mass, manifest, config)
53
54
55class PlexConnectProvider(PluginProvider):
56 """Plex Connect plugin provider implementation."""
57
58 reload_on_streams_network_change = True
59
60 def __init__(
61 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
62 ) -> None:
63 """
64 Initialize the plugin provider.
65
66 :param mass: MusicAssistant instance.
67 :param manifest: Provider manifest.
68 :param config: Provider configuration.
69 """
70 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
71 self.mass_player_id = cast("str", self.get_setup_value(CONF_MASS_PLAYER_ID))
72 self.plex_provider_id = cast("str", self.get_setup_value(CONF_PLEX_PROVIDER_ID))
73 self.custom_player_name = cast("str | None", self.config.get_value(CONF_PLAYER_NAME))
74 self.device_class = cast("str", self.config.get_value(CONF_DEVICE_CLASS)) or "speaker"
75
76 self._plex_provider: PlexProvider | None = None
77 self._player_instance: PlayerRemoteInstance | None = None
78 self._allocated_port: int | None = None
79 self._stop_called: bool = False
80 self._on_unload_callbacks: list[Callable[..., None]] = []
81
82 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
83 """Return Config entries to configure this provider."""
84 return (
85 ConfigEntry(
86 key=CONF_PLAYER_NAME,
87 type=ConfigEntryType.STRING,
88 required=False,
89 default_value=None,
90 ),
91 ConfigEntry(
92 key=CONF_DEVICE_CLASS,
93 type=ConfigEntryType.STRING,
94 required=False,
95 default_value="speaker",
96 options=[
97 ConfigValueOption("speaker"),
98 ConfigValueOption("phone"),
99 ConfigValueOption("tablet"),
100 ConfigValueOption("stb"),
101 ConfigValueOption("tv"),
102 ConfigValueOption("pc"),
103 ConfigValueOption("cloud"),
104 ],
105 ),
106 ConfigEntry(
107 key=CONF_PORT,
108 type=ConfigEntryType.INTEGER,
109 required=False,
110 default_value=None,
111 advanced=True,
112 requires_reload=True,
113 ),
114 )
115
116 async def handle_async_init(self) -> None:
117 """Handle async initialization of the provider."""
118 # Wait for the Plex provider to be available (with timeout)
119 max_retries = 30 # 15 seconds total
120 retry_delay = 0.5
121 for attempt in range(max_retries):
122 self._plex_provider = self.mass.get_provider(self.plex_provider_id) # type: ignore[assignment]
123 if self._plex_provider:
124 break
125 if attempt == 0:
126 self.logger.info(
127 f"Waiting for Plex provider {self.plex_provider_id} to become available..."
128 )
129 await asyncio.sleep(retry_delay)
130 else:
131 timeout_seconds = max_retries * retry_delay
132 self.logger.error(
133 f"Plex provider {self.plex_provider_id} not found after {timeout_seconds}s"
134 )
135 return
136
137 self.logger.debug(f"Plex provider {self.plex_provider_id} is ready")
138
139 # Subscribe to player events first
140 self._on_unload_callbacks.append(
141 self.mass.subscribe(
142 self._on_mass_player_event,
143 (EventType.PLAYER_ADDED, EventType.PLAYER_REMOVED),
144 id_filter=self.mass_player_id,
145 )
146 )
147
148 # Now try to setup the player instance
149 player = self.mass.players.get_player(self.mass_player_id)
150 if not player:
151 self.logger.info(
152 f"Player {self.mass_player_id} not found yet, waiting for PLAYER_ADDED event"
153 )
154 else:
155 # Setup the player instance immediately
156 await self._setup_player_instance()
157
158 async def unload(self, is_removed: bool = False) -> None:
159 """
160 Handle close/cleanup of the provider.
161
162 :param is_removed: Whether the provider is being removed.
163 """
164 self._stop_called = True
165
166 # Stop player instance
167 if self._player_instance:
168 await self._player_instance.stop()
169 self._player_instance = None
170
171 # Unsubscribe from events
172 for callback in self._on_unload_callbacks:
173 callback()
174 self._on_unload_callbacks.clear()
175
176 async def _resolve_port(self) -> int:
177 """
178 Return the long-term port for this instance, allocating one if needed.
179
180 The port is persisted in the instance config so it stays stable across restarts.
181 A new port is allocated (and persisted) only on first setup, or if the configured
182 port is currently taken by another process.
183
184 :return: The port to bind this instance's remote control server to.
185 """
186 configured_port = self.config.get_value(CONF_PORT)
187 # Probe on IPv4 all-interfaces, matching how the remote control server binds
188 if isinstance(configured_port, int) and not await is_port_in_use(
189 configured_port, host="0.0.0.0"
190 ):
191 return configured_port
192
193 port = await select_free_port(
194 PORT_RANGE_START, PORT_RANGE_START + PORT_RANGE_ATTEMPTS, host="0.0.0.0"
195 )
196 if port != configured_port:
197 try:
198 self.mass.config.set_raw_provider_config_value(self.instance_id, CONF_PORT, port)
199 except Exception as err:
200 self.logger.debug("Failed to persist port %s: %s", port, err)
201 return port
202
203 async def _setup_player_instance(self) -> None:
204 """Set up the Plex remote control instance for the player."""
205 # Don't create duplicate instances
206 if self._player_instance:
207 self.logger.debug("Player instance already exists, skipping setup")
208 return
209
210 if not self._plex_provider:
211 self.logger.error("Cannot setup player instance: Plex provider not available")
212 return
213
214 player = self.mass.players.get_player(self.mass_player_id)
215 if not player:
216 self.logger.warning(f"Player {self.mass_player_id} not found")
217 return
218
219 # Resolve the long-term port for this instance (persisted across restarts)
220 if not self._allocated_port:
221 self._allocated_port = await self._resolve_port()
222
223 # Use custom name if provided, otherwise use player's display name
224 player_name = self.custom_player_name or player.display_name
225
226 # Create remote control instance
227 self._player_instance = PlayerRemoteInstance(
228 plex_provider=self._plex_provider,
229 ma_player_id=self.mass_player_id,
230 player_name=player_name,
231 port=self._allocated_port,
232 device_class=self.device_class,
233 remote_control=True,
234 )
235
236 try:
237 await self._player_instance.start()
238 self.logger.info(
239 f"Plex Connect ready: '{player_name}' is now available in Plex apps "
240 f"on port {self._allocated_port}"
241 )
242 except Exception:
243 self.logger.exception("Failed to start Plex remote control")
244 self._player_instance = None
245
246 async def _teardown_player_instance(self) -> None:
247 """Tear down the Plex remote control instance."""
248 if self._player_instance:
249 await self._player_instance.stop()
250 self._player_instance = None
251
252 def _on_mass_player_event(self, event: MassEvent) -> None:
253 """
254 Handle player added/removed events.
255
256 :param event: The event that occurred.
257 """
258 if event.object_id != self.mass_player_id:
259 return
260
261 if event.event == EventType.PLAYER_REMOVED:
262 # Player was removed - stop the instance
263 self.logger.info(f"Player {self.mass_player_id} removed, stopping Plex Connect")
264 self.mass.create_task(self._teardown_player_instance())
265
266 elif event.event == EventType.PLAYER_ADDED:
267 # Player was added - start the instance (if not already running)
268 if not self._player_instance:
269 self.logger.info(f"Player {self.mass_player_id} added, starting Plex Connect")
270 self.mass.create_task(self._setup_player_instance())
271