/
/
/
1"""GDM (Plex Good Day Mate) advertising for player discovery."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import logging
8import socket
9
10LOGGER = logging.getLogger(__name__)
11
12# GDM broadcast and listen ports (matching test-client.py)
13GDM_BROADCAST_PORT = 32414 # Send HELLO broadcasts here
14GDM_LISTEN_PORT = 32412 # Listen for M-SEARCH queries here
15GDM_BROADCAST_ADDR = "255.255.255.255" # Broadcast address
16
17
18class PlexGDMAdvertiser:
19 """Advertise Music Assistant as a Plex player via GDM."""
20
21 def __init__(
22 self,
23 instance_id: str,
24 port: int,
25 publish_ip: str,
26 name: str = "Music Assistant",
27 product: str = "Music Assistant",
28 version: str = "1.0.0",
29 device_class: str = "speaker",
30 ) -> None:
31 """
32 Initialize GDM advertiser.
33
34 :param instance_id: Unique identifier for this instance.
35 :param port: Port number for the server.
36 :param publish_ip: IP address to advertise for this server.
37 :param name: Display name for the device.
38 :param product: Product name.
39 :param version: Version string.
40 :param device_class: Device class advertised to Plex (pc, speaker, phone, etc.).
41 """
42 self.instance_id = instance_id
43 self.port = port
44 self.name = name
45 self.product = product
46 self.version = version
47 self.device_class = device_class
48 self._running = False
49 self._broadcast_task: asyncio.Task[None] | None = None
50 self._listener_task: asyncio.Task[None] | None = None
51
52 # Pre-build GDM messages (they're static)
53 self._hello_message = self._build_hello_message()
54 self._response_message = self._build_response_message()
55
56 # Sockets for reuse
57 self._broadcast_socket: socket.socket | None = None
58 self._response_socket: socket.socket | None = None
59
60 # Cached publish IP
61 self._local_ip = publish_ip
62
63 def _build_hello_message(self) -> bytes:
64 """Build HELLO broadcast message (static, built once)."""
65 message_lines = [
66 "HELLO * HTTP/1.0",
67 f"Name: {self.name}",
68 f"Port: {self.port}",
69 f"Product: {self.product}",
70 f"Version: {self.version}",
71 "Protocol: plex",
72 "Protocol-Version: 1",
73 "Protocol-Capabilities: timeline,playback,navigation,playqueues",
74 f"Device-Class: {self.device_class}",
75 f"Resource-Identifier: {self.instance_id}",
76 "Content-Type: plex/media-player",
77 "Provides: client,player,pubsub-player",
78 ]
79 return "\r\n".join(message_lines).encode("utf-8")
80
81 def _build_response_message(self) -> bytes:
82 """Build M-SEARCH response message (static, built once)."""
83 message_lines = [
84 "HTTP/1.0 200 OK",
85 f"Name: {self.name}",
86 f"Port: {self.port}",
87 f"Product: {self.product}",
88 f"Version: {self.version}",
89 "Protocol: plex",
90 "Protocol-Version: 1",
91 "Protocol-Capabilities: timeline,playback,navigation,playqueues",
92 f"Device-Class: {self.device_class}",
93 f"Resource-Identifier: {self.instance_id}",
94 "Content-Type: plex/media-player",
95 "Provides: client,player,pubsub-player",
96 ]
97 return "\r\n".join(message_lines).encode("utf-8")
98
99 def start(self) -> None:
100 """Start GDM advertising and listening."""
101 if self._running:
102 return
103 self._running = True
104
105 # Create reusable broadcast socket
106 self._broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
107 self._broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
108
109 # Create reusable response socket
110 self._response_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
111
112 # Start broadcast task
113 self._broadcast_task = asyncio.create_task(self._advertise_loop())
114
115 # Start listener task
116 self._listener_task = asyncio.create_task(self._listen_loop())
117
118 LOGGER.info(f"Started GDM advertising and listening at {self._local_ip}:{self.port}")
119
120 async def stop(self) -> None:
121 """Stop GDM advertising and listening."""
122 self._running = False
123
124 if self._broadcast_task:
125 self._broadcast_task.cancel()
126 with contextlib.suppress(asyncio.CancelledError):
127 await self._broadcast_task
128
129 if self._listener_task:
130 self._listener_task.cancel()
131 with contextlib.suppress(asyncio.CancelledError):
132 await self._listener_task
133
134 # Close reusable sockets
135 if self._broadcast_socket:
136 self._broadcast_socket.close()
137 self._broadcast_socket = None
138
139 if self._response_socket:
140 self._response_socket.close()
141 self._response_socket = None
142
143 LOGGER.info("Stopped GDM advertising")
144
145 async def _advertise_loop(self) -> None:
146 """Continuously advertise via GDM every 30 seconds."""
147 # Send initial announcement immediately
148 await self._send_announcement()
149
150 while self._running:
151 try:
152 await asyncio.sleep(30)
153 await self._send_announcement()
154 except asyncio.CancelledError:
155 break
156 except Exception:
157 LOGGER.exception("Error sending GDM announcement")
158 await asyncio.sleep(30)
159
160 async def _listen_loop(self) -> None:
161 """Listen for GDM discovery requests and respond (matching test-client.py)."""
162
163 def listen() -> None:
164 try:
165 # Create UDP socket
166 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
167 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
168
169 # Bind to GDM listen port (like test-client.py)
170 sock.bind(("", GDM_LISTEN_PORT))
171
172 sock.settimeout(1.0) # 1 second timeout for checking _running
173
174 while self._running:
175 try:
176 data, addr = sock.recvfrom(1024)
177 message = data.decode("utf-8", errors="ignore")
178
179 # Check if this is a discovery request (M-SEARCH) not our own HELLO
180 if "M-SEARCH" in message:
181 # Send response - addr contains the actual client's IP and port
182 self._send_discovery_response(addr)
183
184 except socket.timeout: # noqa: UP041
185 continue
186 except Exception as e:
187 if self._running:
188 LOGGER.debug(f"Error receiving GDM request: {e}")
189
190 sock.close()
191
192 except Exception:
193 LOGGER.exception("Failed to start GDM listener")
194
195 await asyncio.to_thread(listen)
196
197 def _send_discovery_response(self, addr: tuple[str, int]) -> None:
198 """Send GDM response to a discovery request."""
199 if not self._response_socket:
200 LOGGER.warning("Response socket not available")
201 return
202
203 try:
204 self._response_socket.sendto(self._response_message, addr)
205
206 except Exception as e:
207 LOGGER.warning(f"Failed to send GDM response to {addr}: {e}")
208
209 async def _send_announcement(self) -> None:
210 """Send a GDM announcement broadcast (uses pre-built message)."""
211 await asyncio.get_event_loop().run_in_executor(None, self._send_udp)
212
213 def _send_udp(self) -> None:
214 """Send UDP broadcast message (uses cached socket and message)."""
215 if not self._broadcast_socket:
216 LOGGER.warning("Broadcast socket not available")
217 return
218
219 try:
220 self._broadcast_socket.sendto(
221 self._hello_message, (GDM_BROADCAST_ADDR, GDM_BROADCAST_PORT)
222 )
223
224 except Exception:
225 LOGGER.exception("Failed to send GDM announcement")
226