music-assistant-server

7.8 KBPY
gdm.py
7.8 KB222 lines • python
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