|
| 1 | +""" |
| 2 | +Support for discovery using GDM (Good Day Mate), multicast protocol by Plex. |
| 3 | +
|
| 4 | +# Licensed Apache 2.0 |
| 5 | +# From https://github.com/home-assistant/netdisco/netdisco/gdm.py |
| 6 | +
|
| 7 | +Inspired by |
| 8 | + hippojay's plexGDM: |
| 9 | + https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py |
| 10 | + iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py |
| 11 | +""" |
| 12 | +import socket |
| 13 | +import struct |
| 14 | + |
| 15 | + |
| 16 | +class GDM: |
| 17 | + """Base class to discover GDM services.""" |
| 18 | + |
| 19 | + def __init__(self): |
| 20 | + self.entries = [] |
| 21 | + self.last_scan = None |
| 22 | + |
| 23 | + def scan(self, scan_for_clients=False): |
| 24 | + """Scan the network.""" |
| 25 | + self.update(scan_for_clients) |
| 26 | + |
| 27 | + def all(self): |
| 28 | + """Return all found entries. |
| 29 | +
|
| 30 | + Will scan for entries if not scanned recently. |
| 31 | + """ |
| 32 | + self.scan() |
| 33 | + return list(self.entries) |
| 34 | + |
| 35 | + def find_by_content_type(self, value): |
| 36 | + """Return a list of entries that match the content_type.""" |
| 37 | + self.scan() |
| 38 | + return [entry for entry in self.entries |
| 39 | + if value in entry['data']['Content_Type']] |
| 40 | + |
| 41 | + def find_by_data(self, values): |
| 42 | + """Return a list of entries that match the search parameters.""" |
| 43 | + self.scan() |
| 44 | + return [entry for entry in self.entries |
| 45 | + if all(item in entry['data'].items() |
| 46 | + for item in values.items())] |
| 47 | + |
| 48 | + def update(self, scan_for_clients): |
| 49 | + """Scan for new GDM services. |
| 50 | +
|
| 51 | + Examples of the dict list assigned to self.entries by this function: |
| 52 | +
|
| 53 | + Server: |
| 54 | + [{'data': { |
| 55 | + 'Content-Type': 'plex/media-server', |
| 56 | + 'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct', |
| 57 | + 'Name': 'myfirstplexserver', |
| 58 | + 'Port': '32400', |
| 59 | + 'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7', |
| 60 | + 'Updated-At': '1585769946', |
| 61 | + 'Version': '1.18.8.2527-740d4c206', |
| 62 | + }, |
| 63 | + 'from': ('10.10.10.100', 32414)}] |
| 64 | +
|
| 65 | + Clients: |
| 66 | + [{'data': {'Content-Type': 'plex/media-player', |
| 67 | + 'Device-Class': 'stb', |
| 68 | + 'Name': 'plexamp', |
| 69 | + 'Port': '36000', |
| 70 | + 'Product': 'Plexamp', |
| 71 | + 'Protocol': 'plex', |
| 72 | + 'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation', |
| 73 | + 'Protocol-Version': '1', |
| 74 | + 'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e', |
| 75 | + 'Version': '1.1.0', |
| 76 | + }, |
| 77 | + 'from': ('10.10.10.101', 32412)}] |
| 78 | + """ |
| 79 | + |
| 80 | + gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii') |
| 81 | + gdm_timeout = 1 |
| 82 | + |
| 83 | + self.entries = [] |
| 84 | + known_responses = [] |
| 85 | + |
| 86 | + # setup socket for discovery -> multicast message |
| 87 | + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| 88 | + sock.settimeout(gdm_timeout) |
| 89 | + |
| 90 | + # Set the time-to-live for messages for local network |
| 91 | + sock.setsockopt(socket.IPPROTO_IP, |
| 92 | + socket.IP_MULTICAST_TTL, |
| 93 | + struct.pack("B", gdm_timeout)) |
| 94 | + |
| 95 | + if scan_for_clients: |
| 96 | + # setup socket for broadcast to Plex clients |
| 97 | + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 98 | + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) |
| 99 | + gdm_ip = '255.255.255.255' |
| 100 | + gdm_port = 32412 |
| 101 | + else: |
| 102 | + # setup socket for multicast to Plex server(s) |
| 103 | + gdm_ip = '239.0.0.250' |
| 104 | + gdm_port = 32414 |
| 105 | + |
| 106 | + try: |
| 107 | + # Send data to the multicast group |
| 108 | + sock.sendto(gdm_msg, (gdm_ip, gdm_port)) |
| 109 | + |
| 110 | + # Look for responses from all recipients |
| 111 | + while True: |
| 112 | + try: |
| 113 | + bdata, host = sock.recvfrom(1024) |
| 114 | + data = bdata.decode('utf-8') |
| 115 | + if '200 OK' in data.splitlines()[0]: |
| 116 | + ddata = {k: v.strip() for (k, v) in ( |
| 117 | + line.split(':') for line in |
| 118 | + data.splitlines() if ':' in line)} |
| 119 | + identifier = ddata.get('Resource-Identifier') |
| 120 | + if identifier and identifier in known_responses: |
| 121 | + continue |
| 122 | + known_responses.append(identifier) |
| 123 | + self.entries.append({'data': ddata, |
| 124 | + 'from': host}) |
| 125 | + except socket.timeout: |
| 126 | + break |
| 127 | + finally: |
| 128 | + sock.close() |
| 129 | + |
| 130 | + |
| 131 | +def main(): |
| 132 | + """Test GDM discovery.""" |
| 133 | + from pprint import pprint |
| 134 | + |
| 135 | + gdm = GDM() |
| 136 | + |
| 137 | + pprint("Scanning GDM for servers...") |
| 138 | + gdm.scan() |
| 139 | + pprint(gdm.entries) |
| 140 | + |
| 141 | + pprint("Scanning GDM for clients...") |
| 142 | + gdm.scan(scan_for_clients=True) |
| 143 | + pprint(gdm.entries) |
| 144 | + |
| 145 | + |
| 146 | +if __name__ == "__main__": |
| 147 | + main() |
0 commit comments