/
/
/
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
14import socket
15from collections.abc import Callable
16from typing import TYPE_CHECKING, cast
17
18from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
19from music_assistant_models.enums import ConfigEntryType, EventType, ProviderFeature
20
21from music_assistant.models.plugin import PluginProvider
22
23from .player_remote import PlayerRemoteInstance
24
25if TYPE_CHECKING:
26 from music_assistant_models.config_entries import ConfigValueType, 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"
38
39# No special features needed for this plugin
40SUPPORTED_FEATURES: set[ProviderFeature] = set()
41
42
43async def setup(
44 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
45) -> ProviderInstanceType:
46 """Initialize provider(instance) with given configuration."""
47 return PlexConnectProvider(mass, manifest, config)
48
49
50async def get_config_entries(
51 mass: MusicAssistant,
52 instance_id: str | None = None, # noqa: ARG001
53 action: str | None = None, # noqa: ARG001
54 values: dict[str, ConfigValueType] | None = None,
55) -> tuple[ConfigEntry, ...]:
56 """
57 Return Config entries to setup this provider.
58
59 :param mass: MusicAssistant instance.
60 :param instance_id: id of an existing provider instance (None if new instance setup).
61 :param action: [optional] action key called from config entries UI.
62 :param values: the (intermediate) raw values for config entries sent with the action.
63 """
64 # Get available Plex music providers
65 plex_providers = [
66 provider
67 for provider in mass.get_providers()
68 if provider.domain == "plex" and provider.type.value == "music"
69 ]
70
71 # Get player name default if player is selected
72 player_name_default = None
73 if values and values.get(CONF_MASS_PLAYER_ID):
74 player_id = str(values.get(CONF_MASS_PLAYER_ID))
75 if player := mass.players.get_player(player_id):
76 player_name_default = player.display_name
77
78 return (
79 ConfigEntry(
80 key=CONF_PLEX_PROVIDER_ID,
81 type=ConfigEntryType.STRING,
82 label="Plex Music Provider",
83 description="Select the Plex music provider to use for this connection.",
84 required=True,
85 options=[
86 ConfigValueOption(provider.name, provider.instance_id)
87 for provider in plex_providers
88 ],
89 ),
90 ConfigEntry(
91 key=CONF_MASS_PLAYER_ID,
92 type=ConfigEntryType.STRING,
93 label="Music Assistant Player",
94 description="Select the MA player to advertise as a Plex remote client.",
95 required=True,
96 options=[
97 ConfigValueOption(x.display_name, x.player_id)
98 for x in sorted(
99 mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
100 )
101 ],
102 ),
103 ConfigEntry(
104 key=CONF_PLAYER_NAME,
105 type=ConfigEntryType.STRING,
106 label="Player Name in Plex",
107 description=(
108 "Custom name for this player as it appears in Plex apps. "
109 "Leave empty to use the player's name."
110 ),
111 required=False,
112 default_value=player_name_default,
113 ),
114 ConfigEntry(
115 key=CONF_DEVICE_CLASS,
116 type=ConfigEntryType.STRING,
117 label="Device Class",
118 description="How this player appears in Plex apps.",
119 required=False,
120 default_value="speaker",
121 options=[
122 ConfigValueOption("Speaker", "speaker"),
123 ConfigValueOption("Phone", "phone"),
124 ConfigValueOption("Tablet", "tablet"),
125 ConfigValueOption("Set-Top Box", "stb"),
126 ConfigValueOption("TV", "tv"),
127 ConfigValueOption("PC", "pc"),
128 ConfigValueOption("Cloud", "cloud"),
129 ],
130 ),
131 )
132
133
134class PlexConnectProvider(PluginProvider):
135 """Plex Connect plugin provider implementation."""
136
137 def __init__(
138 self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
139 ) -> None:
140 """Initialize the plugin provider.
141
142 :param mass: MusicAssistant instance.
143 :param manifest: Provider manifest.
144 :param config: Provider configuration.
145 """
146 super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
147 self.mass_player_id = cast("str", self.config.get_value(CONF_MASS_PLAYER_ID))
148 self.plex_provider_id = cast("str", self.config.get_value(CONF_PLEX_PROVIDER_ID))
149 self.custom_player_name = cast("str | None", self.config.get_value(CONF_PLAYER_NAME))
150 self.device_class = cast("str", self.config.get_value(CONF_DEVICE_CLASS)) or "speaker"
151
152 self._plex_provider: PlexProvider | None = None
153 self._player_instance: PlayerRemoteInstance | None = None
154 self._allocated_port: int | None = None
155 self._stop_called: bool = False
156 self._on_unload_callbacks: list[Callable[..., None]] = []
157
158 async def handle_async_init(self) -> None:
159 """Handle async initialization of the provider."""
160 # Wait for the Plex provider to be available (with timeout)
161 max_retries = 30 # 15 seconds total
162 retry_delay = 0.5
163 for attempt in range(max_retries):
164 self._plex_provider = self.mass.get_provider(self.plex_provider_id) # type: ignore[assignment]
165 if self._plex_provider:
166 break
167 if attempt == 0:
168 self.logger.info(
169 f"Waiting for Plex provider {self.plex_provider_id} to become available..."
170 )
171 await asyncio.sleep(retry_delay)
172 else:
173 timeout_seconds = max_retries * retry_delay
174 self.logger.error(
175 f"Plex provider {self.plex_provider_id} not found after {timeout_seconds}s"
176 )
177 return
178
179 self.logger.debug(f"Plex provider {self.plex_provider_id} is ready")
180
181 # Subscribe to player events first
182 self._on_unload_callbacks.append(
183 self.mass.subscribe(
184 self._on_mass_player_event,
185 (EventType.PLAYER_ADDED, EventType.PLAYER_REMOVED),
186 id_filter=self.mass_player_id,
187 )
188 )
189
190 # Now try to setup the player instance
191 player = self.mass.players.get_player(self.mass_player_id)
192 if not player:
193 self.logger.info(
194 f"Player {self.mass_player_id} not found yet, waiting for PLAYER_ADDED event"
195 )
196 else:
197 # Setup the player instance immediately
198 await self._setup_player_instance()
199
200 async def unload(self, is_removed: bool = False) -> None:
201 """Handle close/cleanup of the provider.
202
203 :param is_removed: Whether the provider is being removed.
204 """
205 self._stop_called = True
206
207 # Stop player instance
208 if self._player_instance:
209 await self._player_instance.stop()
210 self._player_instance = None
211
212 # Unsubscribe from events
213 for callback in self._on_unload_callbacks:
214 callback()
215 self._on_unload_callbacks.clear()
216
217 def _is_port_available(self, port: int) -> bool:
218 """Check if a port is available by attempting to bind to it.
219
220 :param port: Port number to check.
221 :return: True if port is available, False otherwise.
222 """
223 try:
224 # Try to bind to the port on all interfaces
225 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
226 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
227 sock.bind(("", port))
228 return True
229 except OSError:
230 return False
231
232 def _find_available_port(self) -> int:
233 """Find the first available port starting from 32500.
234
235 :return: First available port number.
236 """
237 port = 32500
238 max_attempts = 100 # Prevent infinite loop
239 attempts = 0
240
241 while attempts < max_attempts:
242 if self._is_port_available(port):
243 return port
244 port += 1
245 attempts += 1
246
247 # Fallback - should rarely happen
248 msg = f"Could not find available port in range 32500-{32500 + max_attempts}"
249 raise RuntimeError(msg)
250
251 async def _setup_player_instance(self) -> None:
252 """Set up the Plex remote control instance for the player."""
253 # Don't create duplicate instances
254 if self._player_instance:
255 self.logger.debug("Player instance already exists, skipping setup")
256 return
257
258 if not self._plex_provider:
259 self.logger.error("Cannot setup player instance: Plex provider not available")
260 return
261
262 player = self.mass.players.get_player(self.mass_player_id)
263 if not player:
264 self.logger.warning(f"Player {self.mass_player_id} not found")
265 return
266
267 # Allocate a port if we haven't already
268 if not self._allocated_port:
269 self._allocated_port = self._find_available_port()
270
271 # Use custom name if provided, otherwise use player's display name
272 player_name = self.custom_player_name or player.display_name
273
274 # Create remote control instance
275 self._player_instance = PlayerRemoteInstance(
276 plex_provider=self._plex_provider,
277 ma_player_id=self.mass_player_id,
278 player_name=player_name,
279 port=self._allocated_port,
280 device_class=self.device_class,
281 remote_control=True,
282 )
283
284 try:
285 await self._player_instance.start()
286 self.logger.info(
287 f"Plex Connect ready: '{player_name}' is now available in Plex apps "
288 f"on port {self._allocated_port}"
289 )
290 except Exception as e:
291 self.logger.exception(f"Failed to start Plex remote control: {e}")
292 self._player_instance = None
293
294 async def _teardown_player_instance(self) -> None:
295 """Tear down the Plex remote control instance."""
296 if self._player_instance:
297 await self._player_instance.stop()
298 self._player_instance = None
299
300 def _on_mass_player_event(self, event: MassEvent) -> None:
301 """Handle player added/removed events.
302
303 :param event: The event that occurred.
304 """
305 if event.object_id != self.mass_player_id:
306 return
307
308 if event.event == EventType.PLAYER_REMOVED:
309 # Player was removed - stop the instance
310 self.logger.info(f"Player {self.mass_player_id} removed, stopping Plex Connect")
311 self.mass.create_task(self._teardown_player_instance())
312
313 elif event.event == EventType.PLAYER_ADDED:
314 # Player was added - start the instance (if not already running)
315 if not self._player_instance:
316 self.logger.info(f"Player {self.mass_player_id} added, starting Plex Connect")
317 self.mass.create_task(self._setup_player_instance())
318