├── LICENSE ├── README.md └── dlnap └── dlnap.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 cherezov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dlnap 2 | Enjoy music on your favorite sound system or share a picture or YouTube video with your folks and friends on smart TV. 3 | Simple network player for DLNA/UPnP devices allows you discover devices and playback media on them. 4 | 5 | ## Requires 6 | * Python (whatever you like: python 2.7+ or python3) 7 | * [youtube-dl](https://github.com/rg3/youtube-dl) to playback YouTube links 8 | 9 | ## TODO 10 | - [ ] Fix '&' bug 11 | - [ ] Set next media 12 | - [x] Volume control 13 | - [ ] Position control 14 | - [ ] Add support to play media from local machine, e.g --play /home/username/media/music.mp3 for py3 15 | - [ ] Try it on Windows 16 | - [ ] Add AVTransport:2 and further support 17 | - [ ] Play on multiple devices 18 | - [x] Integrate [local download proxy](https://github.com/cherezov/red) 19 | - [x] Stop/Pause playback 20 | - [x] Investigate if it possible to play images/video's on DLNA/UPnP powered TV (possible via [download proxy](https://github.com/cherezov/dlnap#proxy)) 21 | 22 | ## Supported devices/software 23 | - [x] Yamaha RX577 24 | - [x] Samsung Smart TV (UE40ES5507) via [proxy](https://github.com/cherezov/dlnap#proxy) 25 | - [x] Marantz MR611 26 | - [x] [Kodi](https://kodi.tv/) 27 | - [ ] [Volumio2](https://github.com/volumio/Volumio2) (?) 28 | * _please email me if it works or doesn't work with your device_ 29 | 30 | ## Usage 31 | ### Overview 32 | ``` 33 | dlnap.py [] [] [] 34 | ``` 35 | __Selectors:__ 36 | ```--ip ``` ip address for faster access to the known device 37 | ```--device ``` discover devices with this name as substring 38 | __Commands:__ 39 | ```--list``` default command. Lists discovered UPnP devices in the network 40 | ```--play ``` set current url for play and start playback it. In case of empty url - continue playing recent media 41 | ```--pause``` pause current playback 42 | ```--stop``` stop current playback 43 | __Features:__ 44 | ```--all``` flag to discover all upnp devices, not only devices with AVTransport ability 45 | ```--proxy``` use sync local download proxy, default is ip of current machine 46 | ```--proxy-port``` port for local download proxy, default is 8000 47 | ```--timeout ``` discover timeout 48 | 49 | ### Discover UPnP devices 50 | **List devices which are able to playback media only** 51 | ``` 52 | > dlnap.py 53 | Discovered devices: 54 | [a] Receiver rx577 @ 192.168.1.40 55 | [a] Samsung TV @ 192.168.1.35 56 | ``` 57 | 58 | **List all available UPnP devices** 59 | ``` 60 | > dlnap.py --all 61 | Discovered devices: 62 | [x] ZyXEL Keenetic Giga @ 192.168.1.1 63 | [a] Samsung TV @ 192.168.1.35 64 | [x] Data @ 192.168.1.50 65 | [a] Receiver rx577 @ 192.168.1.40 66 | ``` 67 | where 68 | **[a]** means that devices allows media playback 69 | **[x]** means that device doesn't allow media playback 70 | 71 | 72 | ### Playback media 73 | **Playback music** 74 | ``` 75 | > dlnap.py --ip 192.168.1.40 --play http://somewhere.com/music.mp3 76 | Receiver rx577 @ 192.168.1.40 77 | ``` 78 | **Playback video** 79 | ``` 80 | > dlnap.py --device tv --play http://somewhere.com/video.mp4 81 | Samsung TV @ 192.168.1.35 82 | ``` 83 | **Show image** 84 | ``` 85 | > dlnap.py --device tv --play http://somewhere.com/image.jpg 86 | Samsung TV @ 192.168.1.35 87 | ``` 88 | **Local files** 89 | ``` 90 | > dlnap.py --device tv --play ~/media/video.mp4 --proxy 91 | Samsung TV @ 192.168.1.35 92 | ``` 93 | 94 | **YouTube links** 95 | ``` 96 | > dlnap.py --device tv --play https://www.youtube.com/watch?v=q0eWOaLxlso 97 | Samsung TV @ 192.168.1.35 98 | ``` 99 | **Note:** requires [youtube-dl](https://github.com/rg3/youtube-dl) installed 100 | 101 | ### Proxy 102 | Some devices doesn not able to play ```https``` links or links pointed outside of the local network. 103 | For such cases ```dlnap.py``` tool allows to redirect such links to embeded download proxy. 104 | 105 | __Example:__ 106 | The following command will set up a local http server at ```http://:8000``` and tells TV to download file ```http://somewhere.com/video.mp4``` from this http server: 107 | ``` 108 | > dlnap.py --device tv --play http://somewhere.com/video.mp4 --proxy 109 | ``` 110 | 111 | So behind the scene the command looks like: 112 | ``` 113 | > dlnap.py --device tv --play 'http://:8000/http://somewhere.com/video.mp4' 114 | ``` 115 | **Note:** proxy is syncronous which means that ```dlnap.py``` will not exit while device downloading file to playback. 116 | 117 | ### We need to go deeper :octocat: 118 | **YouTube/Vimeo/etc videos** 119 | In general device can playback direct links to a video file or a stream url only. 120 | There are tools to convert (YouTube) url to stream url, e.g [youtube-dl tool](https://github.com/rg3/youtube-dl). 121 | Assuming you have download proxy up and running at ```http://:8000``` you can now play a video using command: 122 | ``` 123 | > dlnap.py --device tv --play http://:8000/`youtube-dl -g https://www.youtube.com/watch?v=q0eWOaLxlso` 124 | Samsung TV @ 192.168.1.35 125 | ``` 126 | -------------------------------------------------------------------------------- /dlnap/dlnap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # @file dlnap.py 4 | # @author cherezov.pavel@gmail.com 5 | # @brief Python over the network media player to playback on DLNA UPnP devices. 6 | 7 | # Change log: 8 | # 0.1 initial version. 9 | # 0.2 device renamed to DlnapDevice; DLNAPlayer is disappeared. 10 | # 0.3 debug output is added. Extract location url fixed. 11 | # 0.4 compatible discover mode added. 12 | # 0.5 xml parser introduced for device descriptions 13 | # 0.6 xpath introduced to navigate over xml dictionary 14 | # 0.7 device ip argument introduced 15 | # 0.8 debug output is replaced with standard logging 16 | # 0.9 pause/stop added. Video playback tested on Samsung TV 17 | # 0.10 proxy (draft) is introduced. 18 | # 0.11 sync proxy for py2 and py3 implemented, --proxy-port added 19 | # 0.12 local files can be played as well now via proxy 20 | # 0.13 ssdp protocol version argument added 21 | # 0.14 fixed bug with receiving responses from device 22 | # 0.15 Lot's of fixes and features added thanks @ttopholm and @NicoPy 23 | # 24 | # 1.0 moved from idea version 25 | 26 | __version__ = "0.15" 27 | 28 | import re 29 | import sys 30 | import time 31 | import signal 32 | import socket 33 | import select 34 | import logging 35 | import traceback 36 | import mimetypes 37 | from contextlib import contextmanager 38 | 39 | 40 | import os 41 | py3 = sys.version_info[0] == 3 42 | if py3: 43 | from urllib.request import urlopen 44 | from http.server import HTTPServer 45 | from http.server import BaseHTTPRequestHandler 46 | else: 47 | from urllib2 import urlopen 48 | from BaseHTTPServer import BaseHTTPRequestHandler 49 | from BaseHTTPServer import HTTPServer 50 | 51 | import shutil 52 | import threading 53 | 54 | SSDP_GROUP = ("239.255.255.250", 1900) 55 | URN_AVTransport = "urn:schemas-upnp-org:service:AVTransport:1" 56 | URN_AVTransport_Fmt = "urn:schemas-upnp-org:service:AVTransport:{}" 57 | 58 | URN_RenderingControl = "urn:schemas-upnp-org:service:RenderingControl:1" 59 | URN_RenderingControl_Fmt = "urn:schemas-upnp-org:service:RenderingControl:{}" 60 | 61 | SSDP_ALL = "ssdp:all" 62 | 63 | # ================================================================================================= 64 | # XML to DICT 65 | # 66 | def _get_tag_value(x, i = 0): 67 | """ Get the nearest to 'i' position xml tag name. 68 | 69 | x -- xml string 70 | i -- position to start searching tag from 71 | return -- (tag, value) pair. 72 | e.g 73 | 74 | value4 75 | 76 | result is ('d', 'value4') 77 | """ 78 | x = x.strip() 79 | value = '' 80 | tag = '' 81 | 82 | # skip tag 83 | if x[i:].startswith('' 89 | if x[i:].startswith('': 93 | if x[i] == ' ': 94 | in_attr = True 95 | if not in_attr: 96 | tag += x[i] 97 | i += 1 98 | return (tag.strip(), '', x[i+1:]) 99 | 100 | # not an xml, treat like a value 101 | if not x[i:].startswith('<'): 102 | return ('', x[i:], '') 103 | 104 | i += 1 # < 105 | 106 | # read first open tag 107 | in_attr = False 108 | while i < len(x) and x[i] != '>': 109 | # get rid of attributes 110 | if x[i] == ' ': 111 | in_attr = True 112 | if not in_attr: 113 | tag += x[i] 114 | i += 1 115 | 116 | i += 1 # > 117 | 118 | # replace self-closing by None 119 | empty_elmt = '<' + tag + ' />' 120 | closed_elmt = '<' + tag + '>None' 121 | if x.startswith(empty_elmt): 122 | x = x.replace(empty_elmt, closed_elmt) 123 | 124 | while i < len(x): 125 | value += x[i] 126 | if x[i] == '>' and value.endswith(''): 127 | # Note: will not work with xml like 128 | close_tag_len = len(tag) + 2 # /> 129 | value = value[:-close_tag_len] 130 | break 131 | i += 1 132 | return (tag.strip(), value[:-1], x[i+1:]) 133 | 134 | def _xml2dict(s, ignoreUntilXML = False): 135 | 136 | """ Convert xml to dictionary. 137 | 138 | 139 | 140 | value1 141 | value2 142 | 143 | 144 | value4 145 | 146 | value 147 | 148 | 149 | => 150 | 151 | { 'a': 152 | { 153 | 'b': [ {'bb':value1}, {'bb':value2} ], 154 | 'c': [], 155 | 'd': 156 | { 157 | 'e': [value4] 158 | }, 159 | 'g': [value] 160 | } 161 | } 162 | """ 163 | if ignoreUntilXML: 164 | s = ''.join(re.findall(".*?(<.*)", s, re.M)) 165 | 166 | d = {} 167 | while s: 168 | tag, value, s = _get_tag_value(s) 169 | value = value.strip() 170 | isXml, dummy, dummy2 = _get_tag_value(value) 171 | if tag not in d: 172 | d[tag] = [] 173 | if not isXml: 174 | if not value: 175 | continue 176 | d[tag].append(value.strip()) 177 | else: 178 | if tag not in d: 179 | d[tag] = [] 180 | d[tag].append(_xml2dict(value)) 181 | return d 182 | 183 | s = """ 184 | hello 185 | this is a bad 186 | strings 187 | 188 | 189 | 190 | value1 191 | value2 value3 192 | 193 | 194 | value4 195 | 196 | value 197 | 198 | """ 199 | 200 | def _xpath(d, path): 201 | """ Return value from xml dictionary at path. 202 | 203 | d -- xml dictionary 204 | path -- string path like root/device/serviceList/service@serviceType=URN_AVTransport/controlURL 205 | return -- value at path or None if path not found 206 | """ 207 | 208 | for p in path.split('/'): 209 | tag_attr = p.split('@') 210 | tag = tag_attr[0] 211 | if tag not in d: 212 | return None 213 | 214 | attr = tag_attr[1] if len(tag_attr) > 1 else '' 215 | if attr: 216 | a, aval = attr.split('=') 217 | for s in d[tag]: 218 | if s[a] == [aval]: 219 | d = s 220 | break 221 | else: 222 | d = d[tag][0] 223 | return d 224 | # 225 | # XML to DICT 226 | # ================================================================================================= 227 | # PROXY 228 | # 229 | running = False 230 | class DownloadProxy(BaseHTTPRequestHandler): 231 | 232 | def log_message(self, format, *args): 233 | pass 234 | 235 | def log_request(self, code='-', size='-'): 236 | pass 237 | 238 | def response_success(self): 239 | url = self.path[1:] # replace '/' 240 | 241 | if os.path.exists(url): 242 | f = open(url) 243 | content_type = mimetypes.guess_type(url)[0] 244 | else: 245 | f = urlopen(url=url) 246 | 247 | if py3: 248 | content_type = f.getheader("Content-Type") 249 | else: 250 | content_type = f.info().getheaders("Content-Type")[0] 251 | 252 | self.send_response(200, "ok") 253 | self.send_header('Access-Control-Allow-Origin', '*') 254 | self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS') 255 | self.send_header("Access-Control-Allow-Headers", "X-Requested-With") 256 | self.send_header("Access-Control-Allow-Headers", "Content-Type") 257 | self.send_header("Content-Type", content_type) 258 | self.end_headers() 259 | 260 | def do_OPTIONS(self): 261 | self.response_success() 262 | 263 | def do_HEAD(self): 264 | self.response_success() 265 | 266 | def do_GET(self): 267 | global running 268 | url = self.path[1:] # replace '/' 269 | 270 | content_type = '' 271 | if os.path.exists(url): 272 | f = open(url) 273 | content_type = mimetypes.guess_type(url)[0] 274 | size = os.path.getsize(url) 275 | elif not url or not url.startswith('http'): 276 | self.response_success() 277 | return 278 | else: 279 | f = urlopen(url=url) 280 | 281 | try: 282 | if not content_type: 283 | if py3: 284 | content_type = f.getheader("Content-Type") 285 | size = f.getheader("Content-Length") 286 | else: 287 | content_type = f.info().getheaders("Content-Type")[0] 288 | size = f.info().getheaders("Content-Length")[0] 289 | 290 | self.send_response(200) 291 | self.send_header('Access-Control-Allow-Origin', '*') 292 | self.send_header("Content-Type", content_type) 293 | self.send_header("Content-Disposition", 'attachment; filename="{}"'.format(os.path.basename(url))) 294 | self.send_header("Content-Length", str(size)) 295 | self.end_headers() 296 | shutil.copyfileobj(f, self.wfile) 297 | finally: 298 | running = False 299 | f.close() 300 | 301 | def runProxy(ip = '', port = 8000): 302 | global running 303 | running = True 304 | DownloadProxy.protocol_version = "HTTP/1.0" 305 | httpd = HTTPServer((ip, port), DownloadProxy) 306 | while running: 307 | httpd.handle_request() 308 | 309 | # 310 | # PROXY 311 | # ================================================================================================= 312 | 313 | def _get_port(location): 314 | """ Extract port number from url. 315 | 316 | location -- string like http://anyurl:port/whatever/path 317 | return -- port number 318 | """ 319 | port = re.findall('http://.*?:(\d+).*', location) 320 | return int(port[0]) if port else 80 321 | 322 | 323 | def _get_control_url(xml, urn): 324 | """ Extract AVTransport contol url from device description xml 325 | 326 | xml -- device description xml 327 | return -- control url or empty string if wasn't found 328 | """ 329 | return _xpath(xml, 'root/device/serviceList/service@serviceType={}/controlURL'.format(urn)) 330 | 331 | @contextmanager 332 | def _send_udp(to, packet): 333 | """ Send UDP message to group 334 | 335 | to -- (host, port) group to send the packet to 336 | packet -- message to send 337 | """ 338 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 339 | sock.sendto(packet.encode(), to) 340 | yield sock 341 | sock.close() 342 | 343 | def _unescape_xml(xml): 344 | """ Replace escaped xml symbols with real ones. 345 | """ 346 | return xml.replace('<', '<').replace('>', '>').replace('"', '"') 347 | 348 | def _send_tcp(to, payload): 349 | """ Send TCP message to group 350 | 351 | to -- (host, port) group to send to payload to 352 | payload -- message to send 353 | """ 354 | try: 355 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 356 | sock.settimeout(5) 357 | sock.connect(to) 358 | sock.sendall(payload.encode('utf-8')) 359 | 360 | data = sock.recv(2048) 361 | if py3: 362 | data = data.decode('utf-8') 363 | data = _xml2dict(_unescape_xml(data), True) 364 | 365 | errorDescription = _xpath(data, 's:Envelope/s:Body/s:Fault/detail/UPnPError/errorDescription') 366 | if errorDescription is not None: 367 | logging.error(errorDescription) 368 | except Exception as e: 369 | data = '' 370 | finally: 371 | sock.close() 372 | return data 373 | 374 | 375 | def _get_location_url(raw): 376 | """ Extract device description url from discovery response 377 | 378 | raw -- raw discovery response 379 | return -- location url string 380 | """ 381 | t = re.findall('\n(?i)location:\s*(.*)\r\s*', raw, re.M) 382 | if len(t) > 0: 383 | return t[0] 384 | return '' 385 | 386 | def _get_friendly_name(xml): 387 | """ Extract device name from description xml 388 | 389 | xml -- device description xml 390 | return -- device name 391 | """ 392 | name = _xpath(xml, 'root/device/friendlyName') 393 | return name if name is not None else 'Unknown' 394 | 395 | def _get_serve_ip(target_ip, target_port=80): 396 | """ Find ip address of network interface used to communicate with target 397 | 398 | target-ip -- ip address of target 399 | return -- ip address of interface connected to target 400 | """ 401 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 402 | s.connect((target_ip, target_port)) 403 | my_ip = s.getsockname()[0] 404 | s.close() 405 | return my_ip 406 | 407 | class DlnapDevice: 408 | """ Represents DLNA/UPnP device. 409 | """ 410 | 411 | def __init__(self, raw, ip): 412 | self.__logger = logging.getLogger(self.__class__.__name__) 413 | self.__logger.info('=> New DlnapDevice (ip = {}) initialization..'.format(ip)) 414 | 415 | self.ip = ip 416 | self.ssdp_version = 1 417 | 418 | self.port = None 419 | self.name = 'Unknown' 420 | self.control_url = None 421 | self.rendering_control_url = None 422 | self.has_av_transport = False 423 | 424 | try: 425 | self.__raw = raw.decode() 426 | self.location = _get_location_url(self.__raw) 427 | self.__logger.info('location: {}'.format(self.location)) 428 | 429 | self.port = _get_port(self.location) 430 | self.__logger.info('port: {}'.format(self.port)) 431 | 432 | raw_desc_xml = urlopen(self.location).read().decode() 433 | 434 | self.__desc_xml = _xml2dict(raw_desc_xml) 435 | self.__logger.debug('description xml: {}'.format(self.__desc_xml)) 436 | 437 | self.name = _get_friendly_name(self.__desc_xml) 438 | self.__logger.info('friendlyName: {}'.format(self.name)) 439 | 440 | self.control_url = _get_control_url(self.__desc_xml, URN_AVTransport) 441 | self.__logger.info('control_url: {}'.format(self.control_url)) 442 | 443 | self.rendering_control_url = _get_control_url(self.__desc_xml, URN_RenderingControl) 444 | self.__logger.info('rendering_control_url: {}'.format(self.rendering_control_url)) 445 | 446 | self.has_av_transport = self.control_url is not None 447 | self.__logger.info('=> Initialization completed'.format(ip)) 448 | except Exception as e: 449 | self.__logger.warning('DlnapDevice (ip = {}) init exception:\n{}'.format(ip, traceback.format_exc())) 450 | 451 | def __repr__(self): 452 | return '{} @ {}'.format(self.name, self.ip) 453 | 454 | def __eq__(self, d): 455 | return self.name == d.name and self.ip == d.ip 456 | 457 | def _payload_from_template(self, action, data, urn): 458 | """ Assembly payload from template. 459 | """ 460 | fields = '' 461 | for tag, value in data.items(): 462 | fields += '<{tag}>{value}'.format(tag=tag, value=value) 463 | 464 | payload = """ 465 | 466 | 467 | 468 | {fields} 469 | 470 | 471 | """.format(action=action, urn=urn, fields=fields) 472 | return payload 473 | 474 | def _create_packet(self, action, data): 475 | """ Create packet to send to device control url. 476 | 477 | action -- control action 478 | data -- dictionary with XML fields value 479 | """ 480 | if action in ["SetVolume", "SetMute", "GetVolume"]: 481 | url = self.rendering_control_url 482 | urn = URN_RenderingControl_Fmt.format(self.ssdp_version) 483 | else: 484 | url = self.control_url 485 | urn = URN_AVTransport_Fmt.format(self.ssdp_version) 486 | payload = self._payload_from_template(action=action, data=data, urn=urn) 487 | 488 | packet = "\r\n".join([ 489 | 'POST {} HTTP/1.1'.format(url), 490 | 'User-Agent: {}/{}'.format(__file__, __version__), 491 | 'Accept: */*', 492 | 'Content-Type: text/xml; charset="utf-8"', 493 | 'HOST: {}:{}'.format(self.ip, self.port), 494 | 'Content-Length: {}'.format(len(payload)), 495 | 'SOAPACTION: "{}#{}"'.format(urn, action), 496 | 'Connection: close', 497 | '', 498 | payload, 499 | ]) 500 | 501 | self.__logger.debug(packet) 502 | return packet 503 | 504 | def set_current_media(self, url, instance_id = 0): 505 | """ Set media to playback. 506 | 507 | url -- media url 508 | instance_id -- device instance id 509 | """ 510 | packet = self._create_packet('SetAVTransportURI', {'InstanceID':instance_id, 'CurrentURI':url, 'CurrentURIMetaData':'' }) 511 | _send_tcp((self.ip, self.port), packet) 512 | 513 | def play(self, instance_id = 0): 514 | """ Play media that was already set as current. 515 | 516 | instance_id -- device instance id 517 | """ 518 | packet = self._create_packet('Play', {'InstanceID': instance_id, 'Speed': 1}) 519 | _send_tcp((self.ip, self.port), packet) 520 | 521 | def pause(self, instance_id = 0): 522 | """ Pause media that is currently playing back. 523 | 524 | instance_id -- device instance id 525 | """ 526 | packet = self._create_packet('Pause', {'InstanceID': instance_id, 'Speed':1}) 527 | _send_tcp((self.ip, self.port), packet) 528 | 529 | def stop(self, instance_id = 0): 530 | """ Stop media that is currently playing back. 531 | 532 | instance_id -- device instance id 533 | """ 534 | packet = self._create_packet('Stop', {'InstanceID': instance_id, 'Speed': 1}) 535 | _send_tcp((self.ip, self.port), packet) 536 | 537 | 538 | def seek(self, position, instance_id = 0): 539 | """ 540 | Seek position 541 | """ 542 | packet = self._create_packet('Seek', {'InstanceID':instance_id, 'Unit':'REL_TIME', 'Target': position }) 543 | _send_tcp((self.ip, self.port), packet) 544 | 545 | 546 | def volume(self, volume=10, instance_id = 0): 547 | """ Stop media that is currently playing back. 548 | 549 | instance_id -- device instance id 550 | """ 551 | packet = self._create_packet('SetVolume', {'InstanceID': instance_id, 'DesiredVolume': volume, 'Channel': 'Master'}) 552 | 553 | _send_tcp((self.ip, self.port), packet) 554 | 555 | 556 | def get_volume(self, instance_id = 0): 557 | """ 558 | get volume 559 | """ 560 | packet = self._create_packet('GetVolume', {'InstanceID':instance_id, 'Channel': 'Master'}) 561 | _send_tcp((self.ip, self.port), packet) 562 | 563 | 564 | def mute(self, instance_id = 0): 565 | """ Stop media that is currently playing back. 566 | 567 | instance_id -- device instance id 568 | """ 569 | packet = self._create_packet('SetMute', {'InstanceID': instance_id, 'DesiredMute': '1', 'Channel': 'Master'}) 570 | _send_tcp((self.ip, self.port), packet) 571 | 572 | def unmute(self, instance_id = 0): 573 | """ Stop media that is currently playing back. 574 | 575 | instance_id -- device instance id 576 | """ 577 | packet = self._create_packet('SetMute', {'InstanceID': instance_id, 'DesiredMute': '0', 'Channel': 'Master'}) 578 | _send_tcp((self.ip, self.port), packet) 579 | 580 | def info(self, instance_id=0): 581 | """ Transport info. 582 | 583 | instance_id -- device instance id 584 | """ 585 | packet = self._create_packet('GetTransportInfo', {'InstanceID': instance_id}) 586 | return _send_tcp((self.ip, self.port), packet) 587 | 588 | def media_info(self, instance_id=0): 589 | """ Media info. 590 | 591 | instance_id -- device instance id 592 | """ 593 | packet = self._create_packet('GetMediaInfo', {'InstanceID': instance_id}) 594 | return _send_tcp((self.ip, self.port), packet) 595 | 596 | 597 | def position_info(self, instance_id=0): 598 | """ Position info. 599 | instance_id -- device instance id 600 | """ 601 | packet = self._create_packet('GetPositionInfo', {'InstanceID': instance_id}) 602 | return _send_tcp((self.ip, self.port), packet) 603 | 604 | 605 | def set_next(self, url): 606 | pass 607 | 608 | def next(self): 609 | pass 610 | 611 | 612 | def discover(name = '', ip = '', timeout = 1, st = SSDP_ALL, mx = 3, ssdp_version = 1): 613 | """ Discover UPnP devices in the local network. 614 | 615 | name -- name or part of the name to filter devices 616 | timeout -- timeout to perform discover 617 | st -- st field of discovery packet 618 | mx -- mx field of discovery packet 619 | return -- list of DlnapDevice 620 | """ 621 | st = st.format(ssdp_version) 622 | payload = "\r\n".join([ 623 | 'M-SEARCH * HTTP/1.1', 624 | 'User-Agent: {}/{}'.format(__file__, __version__), 625 | 'HOST: {}:{}'.format(*SSDP_GROUP), 626 | 'Accept: */*', 627 | 'MAN: "ssdp:discover"', 628 | 'ST: {}'.format(st), 629 | 'MX: {}'.format(mx), 630 | '', 631 | '']) 632 | devices = [] 633 | with _send_udp(SSDP_GROUP, payload) as sock: 634 | start = time.time() 635 | while True: 636 | if time.time() - start > timeout: 637 | # timed out 638 | break 639 | r, w, x = select.select([sock], [], [sock], 1) 640 | if sock in r: 641 | data, addr = sock.recvfrom(1024) 642 | if ip and addr[0] != ip: 643 | continue 644 | 645 | d = DlnapDevice(data, addr[0]) 646 | d.ssdp_version = ssdp_version 647 | if d not in devices: 648 | if not name or name is None or name.lower() in d.name.lower(): 649 | if not ip: 650 | devices.append(d) 651 | elif d.has_av_transport: 652 | # no need in further searching by ip 653 | devices.append(d) 654 | break 655 | 656 | elif sock in x: 657 | raise Exception('Getting response failed') 658 | else: 659 | # Nothing to read 660 | pass 661 | return devices 662 | 663 | # 664 | # Signal of Ctrl+C 665 | # ================================================================================================= 666 | def signal_handler(signal, frame): 667 | print(' Got Ctrl + C, exit now!') 668 | sys.exit(1) 669 | 670 | signal.signal(signal.SIGINT, signal_handler) 671 | 672 | if __name__ == '__main__': 673 | import getopt 674 | 675 | def usage(): 676 | print('{} [--ip ] [-d[evice] ] [--all] [-t[imeout] ] [--play ] [--pause] [--stop] [--proxy]'.format(__file__)) 677 | print(' --ip - ip address for faster access to the known device') 678 | print(' --device - discover devices with this name as substring') 679 | print(' --all - flag to discover all upnp devices, not only devices with AVTransport ability') 680 | print(' --play - set current url for play and start playback it. In case of url is empty - continue playing recent media.') 681 | print(' --pause - pause current playback') 682 | print(' --stop - stop current playback') 683 | print(' --mute - mute playback') 684 | print(' --unmute - unmute playback') 685 | print(' --volume - set current volume for playback') 686 | print(' --seek - set current position for playback') 687 | print(' --timeout - discover timeout') 688 | print(' --ssdp-version - discover devices by protocol version, default 1') 689 | print(' --proxy - use local proxy on proxy port') 690 | print(' --proxy-port - proxy port to listen incomming connections from devices, default 8000') 691 | print(' --help - this help') 692 | 693 | def version(): 694 | print(__version__) 695 | 696 | try: 697 | opts, args = getopt.getopt(sys.argv[1:], "hvd:t:i:", [ # information arguments 698 | 'help', 699 | 'version', 700 | 'log=', 701 | 702 | # device arguments 703 | 'device=', 704 | 'ip=', 705 | 706 | # action arguments 707 | 'play=', 708 | 'pause', 709 | 'stop', 710 | 'volume=', 711 | 'mute', 712 | 'unmute', 713 | 'seek=', 714 | 715 | 716 | # discover arguments 717 | 'list', 718 | 'all', 719 | 'timeout=', 720 | 'ssdp-version=', 721 | 722 | # transport info 723 | 'info', 724 | 'media-info', 725 | 726 | # download proxy 727 | 'proxy', 728 | 'proxy-port=']) 729 | except getopt.GetoptError: 730 | usage() 731 | sys.exit(1) 732 | 733 | device = '' 734 | url = '' 735 | vol = 10 736 | position = '00:00:00' 737 | timeout = 1 738 | action = '' 739 | logLevel = logging.WARN 740 | compatibleOnly = True 741 | ip = '' 742 | proxy = False 743 | proxy_port = 8000 744 | ssdp_version = 1 745 | for opt, arg in opts: 746 | if opt in ('-h', '--help'): 747 | usage() 748 | sys.exit(0) 749 | elif opt in ('-v', '--version'): 750 | version() 751 | sys.exit(0) 752 | elif opt in ('--log'): 753 | if arg.lower() == 'debug': 754 | logLevel = logging.DEBUG 755 | elif arg.lower() == 'info': 756 | logLevel = logging.INFO 757 | elif arg.lower() == 'warn': 758 | logLevel = logging.WARN 759 | elif opt in ('--all'): 760 | compatibleOnly = False 761 | elif opt in ('-d', '--device'): 762 | device = arg 763 | elif opt in ('-t', '--timeout'): 764 | timeout = float(arg) 765 | elif opt in ('--ssdp-version'): 766 | ssdp_version = int(arg) 767 | elif opt in ('-i', '--ip'): 768 | ip = arg 769 | compatibleOnly = False 770 | timeout = 10 771 | elif opt in ('--list'): 772 | action = 'list' 773 | elif opt in ('--play'): 774 | action = 'play' 775 | url = arg 776 | elif opt in ('--pause'): 777 | action = 'pause' 778 | elif opt in ('--stop'): 779 | action = 'stop' 780 | elif opt in ('--volume'): 781 | action = 'volume' 782 | vol = arg 783 | elif opt in ('--seek'): 784 | action = 'seek' 785 | position = arg 786 | elif opt in ('--mute'): 787 | action = 'mute' 788 | elif opt in ('--unmute'): 789 | action = 'unmute' 790 | elif opt in ('--info'): 791 | action = 'info' 792 | elif opt in ('--media-info'): 793 | action = 'media-info' 794 | elif opt in ('--proxy'): 795 | proxy = True 796 | elif opt in ('--proxy-port'): 797 | proxy_port = int(arg) 798 | 799 | logging.basicConfig(level=logLevel) 800 | 801 | st = URN_AVTransport_Fmt if compatibleOnly else SSDP_ALL 802 | allDevices = discover(name=device, ip=ip, timeout=timeout, st=st, ssdp_version=ssdp_version) 803 | if not allDevices: 804 | print('No compatible devices found.') 805 | sys.exit(1) 806 | 807 | if action in ('', 'list'): 808 | print('Discovered devices:') 809 | for d in allDevices: 810 | print(' {} {}'.format('[a]' if d.has_av_transport else '[x]', d)) 811 | sys.exit(0) 812 | 813 | d = allDevices[0] 814 | print(d) 815 | 816 | if url.lower().replace('https://', '').replace('www.', '').startswith('youtube.'): 817 | import subprocess 818 | process = subprocess.Popen(['youtube-dl', '-g', url], stdout = subprocess.PIPE) 819 | url, err = process.communicate() 820 | 821 | if url.lower().startswith('https://'): 822 | proxy = True 823 | 824 | if proxy: 825 | ip = _get_serve_ip(d.ip) 826 | t = threading.Thread(target=runProxy, kwargs={'ip' : ip, 'port' : proxy_port}) 827 | t.daemon = True 828 | t.start() 829 | time.sleep(2) 830 | 831 | if action == 'play': 832 | try: 833 | d.stop() 834 | url = 'http://{}:{}/{}'.format(ip, proxy_port, url) if proxy else url 835 | d.set_current_media(url=url) 836 | d.play() 837 | except Exception as e: 838 | print('Device is unable to play media.') 839 | logging.warn('Play exception:\n{}'.format(traceback.format_exc())) 840 | sys.exit(1) 841 | elif action == 'pause': 842 | d.pause() 843 | elif action == 'stop': 844 | d.stop() 845 | elif action == 'volume': 846 | d.volume(vol) 847 | elif action == 'seek': 848 | d.seek(position) 849 | elif action == 'mute': 850 | d.mute() 851 | elif action == 'unmute': 852 | d.unmute() 853 | elif action == 'info': 854 | print(d.info()) 855 | elif action == 'media-info': 856 | print(d.media_info()) 857 | 858 | if proxy: 859 | while running: 860 | time.sleep(30) 861 | --------------------------------------------------------------------------------