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