diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5bb791 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +dlnap/testdlna.py +*.xml \ No newline at end of file diff --git a/README.md b/README.md index d221ad5..96e0060 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,29 @@ Simple network player for DLNA/UPnP devices allows you discover devices and play ## TODO - [ ] Fix '&' bug -- [ ] Set next media +- [x] Set next media - [x] Volume control -- [ ] Position control +- [x] Position control - [ ] Add support to play media from local machine, e.g --play /home/username/media/music.mp3 for py3 -- [ ] Try it on Windows -- [ ] Add AVTransport:2 and further support +- [x] Try it on Windows +- [x] Add AVTransport:2 and further support - [ ] Play on multiple devices -- [x] Integrate [local download proxy](https://github.com/cherezov/red) +- [x] Integrate [local download proxy](#proxy) - [x] Stop/Pause playback -- [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)) +- [x] Investigate if it is possible to play images/videos on DLNA/UPnP powered TV (possible via [download proxy](#proxy)) ## Supported devices/software - [x] Yamaha RX577 - - [x] Samsung Smart TV (UE40ES5507) via [proxy](https://github.com/cherezov/dlnap#proxy) + - [x] Samsung Smart TV (UE40ES5507) via [proxy](#proxy) - [x] Marantz MR611 - [x] [Kodi](https://kodi.tv/) - [ ] [Volumio2](https://github.com/volumio/Volumio2) (?) + - [x] Pioneer MRX-3 via [proxy](#proxy) and timeout >= 2 * _please email me if it works or doesn't work with your device_ + +## Prepare +### Install dependencies + pip install -r requirement.txt ## Usage ### Overview @@ -40,6 +45,8 @@ __Commands:__ ```--play ``` set current url for play and start playback it. In case of empty url - continue playing recent media ```--pause``` pause current playback ```--stop``` stop current playback +```--set-next ``` set the next media url to be played (gapless) +```--next``` play the next media __Features:__ ```--all``` flag to discover all upnp devices, not only devices with AVTransport ability ```--proxy``` use sync local download proxy, default is ip of current machine diff --git a/dlnap/dlnap.py b/dlnap/dlnap.py index 90010d9..704e3cb 100644 --- a/dlnap/dlnap.py +++ b/dlnap/dlnap.py @@ -24,6 +24,7 @@ __version__ = "0.14" +import os import re import sys import time @@ -32,23 +33,25 @@ import logging import traceback import mimetypes +import shutil +import threading from contextlib import contextmanager +import xmltodict # xmltodict==0.11.0 -import os py3 = sys.version_info[0] == 3 + if py3: from urllib.request import urlopen + from urllib.request import Request from http.server import HTTPServer from http.server import BaseHTTPRequestHandler else: from urllib2 import urlopen + from urllib2 import Request from BaseHTTPServer import BaseHTTPRequestHandler from BaseHTTPServer import HTTPServer -import shutil -import threading - SSDP_GROUP = ("239.255.255.250", 1900) URN_AVTransport = "urn:schemas-upnp-org:service:AVTransport:1" URN_AVTransport_Fmt = "urn:schemas-upnp-org:service:AVTransport:{}" @@ -58,310 +61,168 @@ SSDP_ALL = "ssdp:all" -# ================================================================================================= -# XML to DICT -# -def _get_tag_value(x, i = 0): - """ Get the nearest to 'i' position xml tag name. - - x -- xml string - i -- position to start searching tag from - return -- (tag, value) pair. - e.g - - value4 - - result is ('d', 'value4') - """ - x = x.strip() - value = '' - tag = '' - - # skip tag - if x[i:].startswith('' - if x[i:].startswith('': - if x[i] == ' ': - in_attr = True - if not in_attr: - tag += x[i] - i += 1 - return (tag.strip(), '', x[i+1:]) - - # not an xml, treat like a value - if not x[i:].startswith('<'): - return ('', x[i:], '') - - i += 1 # < - - # read first open tag - in_attr = False - while i < len(x) and x[i] != '>': - # get rid of attributes - if x[i] == ' ': - in_attr = True - if not in_attr: - tag += x[i] - i += 1 - - i += 1 # > - - while i < len(x): - value += x[i] - if x[i] == '>' and value.endswith(''): - # Note: will not work with xml like - close_tag_len = len(tag) + 2 # /> - value = value[:-close_tag_len] - break - i += 1 - return (tag.strip(), value[:-1], x[i+1:]) - -def _xml2dict(s, ignoreUntilXML = False): - - """ Convert xml to dictionary. - - - - value1 - value2 - - - value4 - - value - - - => - - { 'a': - { - 'b': [ {'bb':value1}, {'bb':value2} ], - 'c': [], - 'd': - { - 'e': [value4] - }, - 'g': [value] - } - } - """ - if ignoreUntilXML: - s = ''.join(re.findall(".*?(<.*)", s, re.M)) - - d = {} - while s: - tag, value, s = _get_tag_value(s) - value = value.strip() - isXml, dummy, dummy2 = _get_tag_value(value) - if tag not in d: - d[tag] = [] - if not isXml: - if not value: - continue - d[tag].append(value.strip()) - else: - if tag not in d: - d[tag] = [] - d[tag].append(_xml2dict(value)) - return d - -s = """ - hello - this is a bad - strings - - - - value1 - value2 value3 - - - value4 - - value - -""" - -def _xpath(d, path): - """ Return value from xml dictionary at path. - - d -- xml dictionary - path -- string path like root/device/serviceList/service@serviceType=URN_AVTransport/controlURL - return -- value at path or None if path not found - """ - - for p in path.split('/'): - tag_attr = p.split('@') - tag = tag_attr[0] - if tag not in d: - return None - - attr = tag_attr[1] if len(tag_attr) > 1 else '' - if attr: - a, aval = attr.split('=') - for s in d[tag]: - if s[a] == [aval]: - d = s - break - else: - d = d[tag][0] - return d -# -# XML to DICT # ================================================================================================= # PROXY # running = False + + class DownloadProxy(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass + + def log_request(self, code='-', size='-'): + pass + + def response_success(self): + url = self.path[1:] # replace '/' + + if os.path.exists(url): + f = open(url) + content_type = mimetypes.guess_type(url)[0] + else: + f = urlopen(url=url) - def log_message(self, format, *args): - pass - - def log_request(self, code='-', size='-'): - pass - - def response_success(self): - url = self.path[1:] # replace '/' - - if os.path.exists(url): - f = open(url) - content_type = mimetypes.guess_type(url)[0] - else: - f = urlopen(url=url) - - if py3: - content_type = f.getheader("Content-Type") - else: - content_type = f.info().getheaders("Content-Type")[0] - - self.send_response(200, "ok") - self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS') - self.send_header("Access-Control-Allow-Headers", "X-Requested-With") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.send_header("Content-Type", content_type) - self.end_headers() - - def do_OPTIONS(self): - self.response_success() - - def do_HEAD(self): - self.response_success() - - def do_GET(self): - global running - url = self.path[1:] # replace '/' - - content_type = '' - if os.path.exists(url): - f = open(url) - content_type = mimetypes.guess_type(url)[0] - size = os.path.getsize(url) - elif not url or not url.startswith('http'): - self.response_success() - return - else: - f = urlopen(url=url) - - try: - if not content_type: if py3: - content_type = f.getheader("Content-Type") - size = f.getheader("Content-Length") + content_type = f.getheader("Content-Type") else: - content_type = f.info().getheaders("Content-Type")[0] - size = f.info().getheaders("Content-Length")[0] - - self.send_response(200) - self.send_header('Access-Control-Allow-Origin', '*') - self.send_header("Content-Type", content_type) - self.send_header("Content-Disposition", 'attachment; filename="{}"'.format(os.path.basename(url))) - self.send_header("Content-Length", str(size)) - self.end_headers() - shutil.copyfileobj(f, self.wfile) - finally: - running = False - f.close() - -def runProxy(ip = '', port = 8000): - global running - running = True - DownloadProxy.protocol_version = "HTTP/1.0" - httpd = HTTPServer((ip, port), DownloadProxy) - while running: - httpd.handle_request() + content_type = f.info().getheaders("Content-Type")[0] + + self.send_response(200, "ok") + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS') + self.send_header("Access-Control-Allow-Headers", "X-Requested-With") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Content-Type", content_type) + self.end_headers() + + def do_OPTIONS(self): + self.response_success() + + def do_HEAD(self): + self.response_success() + + def do_GET(self): + global running + url = self.path[1:] # replace '/' + + content_type = '' + if os.path.exists(url): + f = open(url) + content_type = mimetypes.guess_type(url)[0] + size = os.path.getsize(url) + elif not url or not url.startswith('http'): + self.response_success() + return + else: + f = urlopen(url=url) + + try: + if not content_type: + if py3: + content_type = f.getheader("Content-Type") + size = f.getheader("Content-Length") + else: + content_type = f.info().getheaders("Content-Type")[0] + size = f.info().getheaders("Content-Length")[0] + + self.send_response(200) + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header("Content-Type", content_type) + self.send_header( + "Content-Disposition", + 'attachment; filename="{}"'.format(os.path.basename(url))) + self.send_header("Content-Length", str(size)) + self.end_headers() + shutil.copyfileobj(f, self.wfile) + finally: + running = False + f.close() + + +def runProxy(ip='', port=8000): + global running + + running = True + DownloadProxy.protocol_version = "HTTP/1.0" + httpd = HTTPServer((ip, port), DownloadProxy) + + while running: + httpd.handle_request() + # # PROXY # ================================================================================================= + def _get_port(location): - """ Extract port number from url. + """ Extract port number from url. - location -- string like http://anyurl:port/whatever/path - return -- port number - """ - port = re.findall('http://.*?:(\d+).*', location) - return int(port[0]) if port else 80 + location -- string like http://anyurl:port/whatever/path + return -- port number + """ + port = re.findall('http://.*?:(\d+).*', location) + return int(port[0]) if port else 80 -def _get_control_url(xml, urn): - """ Extract AVTransport contol url from device description xml - xml -- device description xml - return -- control url or empty string if wasn't found - """ - return _xpath(xml, 'root/device/serviceList/service@serviceType={}/controlURL'.format(urn)) +def _get_primary_ip(): + """ Return the primary ip of the machine + + by Jamieson Becker at StackOverflow + https://stackoverflow.com/questions/166506/finding-local-ip-addresses-using-pythons-stdlib#28950776 + + return -- the machine ip + """ + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + try: + # doesn't even have to be reachable + s.connect(('10.255.255.255', 1)) + + ip = s.getsockname()[0] + except: + ip = '127.0.0.1' + finally: + s.close() + + return ip + + +def _get_control_urls(xml): + """ Extract AVTransport contol url from device description xml + + xml -- device description xml + return -- control url or empty string if wasn't found + """ + try: + return { + i['serviceType']: i['controlURL'] + for i in xml['root']['device']['serviceList']['service'] + } + except: + return + @contextmanager def _send_udp(to, packet): - """ Send UDP message to group - - to -- (host, port) group to send the packet to - packet -- message to send - """ - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.sendto(packet.encode(), to) - yield sock - sock.close() + """ Send UDP message to group -def _unescape_xml(xml): - """ Replace escaped xml symbols with real ones. - """ - return xml.replace('<', '<').replace('>', '>').replace('"', '"') + to -- (host, port) group to send the packet to + packet -- message to send + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.sendto(packet.encode(), to) + yield sock + sock.close() -def _send_tcp(to, payload): - """ Send TCP message to group - to -- (host, port) group to send to payload to - payload -- message to send +def _unescape_xml(xml): + """ Replace escaped xml symbols with real ones. """ - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect(to) - sock.sendall(payload.encode('utf-8')) - - data = sock.recv(2048) - if py3: - data = data.decode('utf-8') - data = _xml2dict(_unescape_xml(data), True) - - errorDescription = _xpath(data, 's:Envelope/s:Body/s:Fault/detail/UPnPError/errorDescription') - if errorDescription is not None: - logging.error(errorDescription) - except Exception as e: - data = '' - finally: - sock.close() - return data + return xml.decode().replace('<', '<').replace('>', '>').replace( + '"', '"') def _get_location_url(raw): @@ -375,455 +236,710 @@ def _get_location_url(raw): return t[0] return '' + def _get_friendly_name(xml): - """ Extract device name from description xml + """ Extract device name from description xml + + xml -- device description xml + return -- device name + """ + try: + return xml['root']['device']['friendlyName'] + except Exception as e: + return 'Unknown' + + +def _get_device_type(xml): + """ Extract device type from description xml + + xml -- device description xml + return -- device name + """ + try: + return xml['root']['device']['deviceType'] + except Exception as e: + return 'Unknown' + + +def _get_device_type_number(device_type): + """ Extract the device type version from the device type name + + device_type -- device type name (string) + return -- device type number (int) + """ + try: + return int(device_type.rsplit(':', 1)[-1]) + except: + return None - xml -- device description xml - return -- device name - """ - name = _xpath(xml, 'root/device/friendlyName') - return name if name is not None else 'Unknown' class DlnapDevice: - """ Represents DLNA/UPnP device. - """ + """ Represents DLNA/UPnP device. + """ + + def __init__(self, raw, ip): + self.__logger = logging.getLogger(self.__class__.__name__) + self.__logger.info( + '=> New DlnapDevice (ip = {}) initialization..'.format(ip)) - def __init__(self, raw, ip): - self.__logger = logging.getLogger(self.__class__.__name__) - self.__logger.info('=> New DlnapDevice (ip = {}) initialization..'.format(ip)) + self.ip = ip + self.ssdp_version = 1 - self.ip = ip - self.ssdp_version = 1 + self.port = None + self.name = 'Unknown' + self.device_type = None + self.device_type_version = None + self.control_url = None + self.rendering_control_url = None + self.has_av_transport = False - self.port = None - self.name = 'Unknown' - self.control_url = None - self.rendering_control_url = None - self.has_av_transport = False + try: + self.__raw = raw.decode() + self.location = _get_location_url(self.__raw) + self.__logger.info('location: {}'.format(self.location)) - try: - self.__raw = raw.decode() - self.location = _get_location_url(self.__raw) - self.__logger.info('location: {}'.format(self.location)) + self.port = _get_port(self.location) + self.__logger.info('port: {}'.format(self.port)) - self.port = _get_port(self.location) - self.__logger.info('port: {}'.format(self.port)) + raw_desc_xml = urlopen(self.location, timeout=5).read().decode() - raw_desc_xml = urlopen(self.location).read().decode() + desc_dict = xmltodict.parse(raw_desc_xml) - self.__desc_xml = _xml2dict(raw_desc_xml) - self.__logger.debug('description xml: {}'.format(self.__desc_xml)) + self.__logger.debug('description xml: {}'.format(desc_dict)) - self.name = _get_friendly_name(self.__desc_xml) - self.__logger.info('friendlyName: {}'.format(self.name)) + self.name = _get_friendly_name(desc_dict) + self.__logger.info('friendlyName: {}'.format(self.name)) - self.control_url = _get_control_url(self.__desc_xml, URN_AVTransport) - self.__logger.info('control_url: {}'.format(self.control_url)) + self.device_type = _get_device_type(desc_dict) - self.rendering_control_url = _get_control_url(self.__desc_xml, URN_RenderingControl) - self.__logger.info('rendering_control_url: {}'.format(self.rendering_control_url)) + self.device_type_version = _get_device_type_number( + self.device_type) - self.has_av_transport = self.control_url is not None - self.__logger.info('=> Initialization completed'.format(ip)) - except Exception as e: - self.__logger.warning('DlnapDevice (ip = {}) init exception:\n{}'.format(ip, traceback.format_exc())) + services_url = _get_control_urls(desc_dict) - def __repr__(self): - return '{} @ {}'.format(self.name, self.ip) + self.control_url = services_url.get( + URN_AVTransport_Fmt.format(self.device_type_version), None) - def __eq__(self, d): - return self.name == d.name and self.ip == d.ip + self.__logger.info('control_url: {}'.format(self.control_url)) - def _payload_from_template(self, action, data, urn): - """ Assembly payload from template. - """ - fields = '' - for tag, value in data.items(): - fields += '<{tag}>{value}'.format(tag=tag, value=value) + self.rendering_control_url = services_url.get( + URN_RenderingControl_Fmt.format(self.device_type_version), + None) - payload = """ + self.__logger.info( + 'rendering_control_url: {}'.format(self.rendering_control_url)) + + self.has_av_transport = self.control_url is not None + + self.__logger.info('=> Initialization completed'.format(ip)) + except Exception as e: + self.__logger.warning('DlnapDevice (ip = {}) init exception:\n{}'. + format(ip, traceback.format_exc())) + + def __repr__(self): + return '{} @ {}'.format(self.name, self.ip) + + def __eq__(self, d): + return self.name == d.name and self.ip == d.ip + + @staticmethod + def _payload_from_template(action, data, urn): + """ Assembly payload from template. + """ + fields = '' + for tag, value in data.items(): + fields += '<{tag}>{value}'.format(tag=tag, value=value) + payload = """ {fields} - """.format(action=action, urn=urn, fields=fields) - return payload - - def _create_packet(self, action, data): - """ Create packet to send to device control url. - - action -- control action - data -- dictionary with XML fields value - """ - if action in ["SetVolume", "SetMute", "GetVolume"]: - url = self.rendering_control_url - urn = URN_RenderingControl_Fmt.format(self.ssdp_version) - else: - url = self.control_url - urn = URN_AVTransport_Fmt.format(self.ssdp_version) - payload = self._payload_from_template(action=action, data=data, urn=urn) - - packet = "\r\n".join([ - 'POST {} HTTP/1.1'.format(url), - 'User-Agent: {}/{}'.format(__file__, __version__), - 'Accept: */*', - 'Content-Type: text/xml; charset="utf-8"', - 'HOST: {}:{}'.format(self.ip, self.port), - 'Content-Length: {}'.format(len(payload)), - 'SOAPACTION: "{}#{}"'.format(urn, action), - 'Connection: close', - '', - payload, - ]) - - self.__logger.debug(packet) - return packet - - def set_current_media(self, url, instance_id = 0): - """ Set media to playback. - - url -- media url - instance_id -- device instance id - """ - packet = self._create_packet('SetAVTransportURI', {'InstanceID':instance_id, 'CurrentURI':url, 'CurrentURIMetaData':'' }) - _send_tcp((self.ip, self.port), packet) - - def play(self, instance_id = 0): - """ Play media that was already set as current. - - instance_id -- device instance id - """ - packet = self._create_packet('Play', {'InstanceID': instance_id, 'Speed': 1}) - _send_tcp((self.ip, self.port), packet) - - def pause(self, instance_id = 0): - """ Pause media that is currently playing back. - - instance_id -- device instance id - """ - packet = self._create_packet('Pause', {'InstanceID': instance_id, 'Speed':1}) - _send_tcp((self.ip, self.port), packet) - - def stop(self, instance_id = 0): - """ Stop media that is currently playing back. - - instance_id -- device instance id - """ - packet = self._create_packet('Stop', {'InstanceID': instance_id, 'Speed': 1}) - _send_tcp((self.ip, self.port), packet) - - - def seek(self, position, instance_id = 0): - """ - Seek position - """ - packet = self._create_packet('Seek', {'InstanceID':instance_id, 'Unit':'REL_TIME', 'Target': position }) - _send_tcp((self.ip, self.port), packet) - - - def volume(self, volume=10, instance_id = 0): - """ Stop media that is currently playing back. - - instance_id -- device instance id - """ - packet = self._create_packet('SetVolume', {'InstanceID': instance_id, 'DesiredVolume': volume, 'Channel': 'Master'}) - - _send_tcp((self.ip, self.port), packet) - - - def get_volume(self, instance_id = 0): - """ - get volume - """ - packet = self._create_packet('GetVolume', {'InstanceID':instance_id, 'Channel': 'Master'}) - _send_tcp((self.ip, self.port), packet) - - - def mute(self, instance_id = 0): - """ Stop media that is currently playing back. - - instance_id -- device instance id - """ - packet = self._create_packet('SetMute', {'InstanceID': instance_id, 'DesiredMute': '1', 'Channel': 'Master'}) - _send_tcp((self.ip, self.port), packet) - - def unmute(self, instance_id = 0): - """ Stop media that is currently playing back. - - instance_id -- device instance id - """ - packet = self._create_packet('SetMute', {'InstanceID': instance_id, 'DesiredMute': '0', 'Channel': 'Master'}) - _send_tcp((self.ip, self.port), packet) - - def info(self, instance_id=0): - """ Transport info. - - instance_id -- device instance id - """ - packet = self._create_packet('GetTransportInfo', {'InstanceID': instance_id}) - return _send_tcp((self.ip, self.port), packet) + """.format( + action=action, urn=urn, fields=fields) + return payload - def media_info(self, instance_id=0): - """ Media info. + def _soap_request(self, action, data): + """ Send SOAP Request to DMR devices - instance_id -- device instance id - """ - packet = self._create_packet('GetMediaInfo', {'InstanceID': instance_id}) - return _send_tcp((self.ip, self.port), packet) + action -- control action + data -- dictionary with XML fields value + """ + if not self.control_url: + return None + if action in ["SetVolume", "SetMute", "GetVolume"]: + url = self.rendering_control_url - def position_info(self, instance_id=0): - """ Position info. - instance_id -- device instance id - """ - packet = self._create_packet('GetPositionInfo', {'InstanceID': instance_id}) - return _send_tcp((self.ip, self.port), packet) + urn = URN_RenderingControl_Fmt.format(self.device_type_version) + else: + url = self.control_url + urn = URN_AVTransport_Fmt.format(self.device_type_version) - def set_next(self, url): - pass + soap_url = 'http://{}:{}{}'.format(self.ip, self.port, url) - def next(self): - pass + headers = { + 'Content-type': 'text/xml', + 'SOAPACTION': '"{}#{}"'.format(urn, action), + 'charset': 'utf-8', + 'User-Agent': '{}/{}'.format(__file__, __version__) + } + self.__logger.debug(headers) -def discover(name = '', ip = '', timeout = 1, st = SSDP_ALL, mx = 3, ssdp_version = 1): - """ Discover UPnP devices in the local network. + payload = self._payload_from_template( + action=action, data=data, urn=urn) + + self.__logger.debug(payload) + + try: + req = Request(soap_url, data=payload.encode(), headers=headers) + + res = urlopen(req, timeout=5) + + if res.code == 200: + data = res.read() + + self.__logger.debug(data.decode()) + + response = xmltodict.parse(_unescape_xml(data)) + + try: + error_description = response['s:Envelope']['s:Body'][ + 's:Fault']['detail']['UPnPError']['errorDescription'] + + logging.error(error_description) + + return None + except: + return response + except Exception as e: + logging.error(e) + + def set_current_media(self, url, instance_id=0): + """ Set media to playback. + + url -- media url + instance_id -- device instance id + """ + response = self._soap_request('SetAVTransportURI', { + 'InstanceID': instance_id, + 'CurrentURI': url, + 'CurrentURIMetaData': '' + }) + + try: + response['s:Envelope']['s:Body']['u:SetAVTransportURIResponse'] + + return True + except: + # Unexpected response + return False + + def play(self, instance_id=0, speed=1): + """ Play media that was already set as current. + + instance_id -- device instance id + """ + response = self._soap_request( + 'Play', {'InstanceID': instance_id, + 'Speed': speed}) + + try: + response['s:Envelope']['s:Body']['u:PlayResponse'] + return True + except: + # Unexpected response + return False + + def pause(self, instance_id=0): + """ Pause media that is currently playing back. + + instance_id -- device instance id + """ + response = self._soap_request('Pause', + {'InstanceID': instance_id, + 'Speed': 1}) + try: + response['s:Envelope']['s:Body']['u:PauseResponse'] + + return True + except: + # Unexpected response + return False + + def stop(self, instance_id=0): + """ Stop media that is currently playing back. + + instance_id -- device instance id + """ + response = self._soap_request('Stop', + {'InstanceID': instance_id, + 'Speed': 1}) + + try: + response['s:Envelope']['s:Body']['u:StopResponse'] + + return True + except: + # Unexpected response + return False + + def seek(self, position, instance_id=0): + """ + Seek position + """ + response = self._soap_request('Seek', { + 'InstanceID': instance_id, + 'Unit': 'REL_TIME', + 'Target': position + }) + + try: + response['s:Envelope']['s:Body']['u:SeekResponse'] + + return True + except: + # Unexpected response + return False + + def volume(self, volume=10, instance_id=0): + """ Stop media that is currently playing back. + + instance_id -- device instance id + """ + response = self._soap_request('SetVolume', { + 'InstanceID': instance_id, + 'DesiredVolume': volume, + 'Channel': 'Master' + }) + + try: + response['s:Envelope']['s:Body']['u:SetVolumeResponse'] + + return True + except: + # Unexpected response + return False + + def get_volume(self, instance_id=0): + """ + get volume + """ + response = self._soap_request( + 'GetVolume', {'InstanceID': instance_id, + 'Channel': 'Master'}) + + if response: + return response['s:Envelope']['s:Body']['u:GetVolumeResponse'][ + 'CurrentVolume'] + + def mute(self, instance_id=0): + """ Stop media that is currently playing back. + + instance_id -- device instance id + """ + response = self._soap_request('SetMute', { + 'InstanceID': instance_id, + 'DesiredMute': '1', + 'Channel': 'Master' + }) + + try: + response['s:Envelope']['s:Body']['u:SetMuteResponse'] + + return True + except: + # Unexpected response + return False + + def unmute(self, instance_id=0): + """ Stop media that is currently playing back. + + instance_id -- device instance id + """ + response = self._soap_request('SetMute', { + 'InstanceID': instance_id, + 'DesiredMute': '0', + 'Channel': 'Master' + }) + + try: + response['s:Envelope']['s:Body']['u:SetMuteResponse'] + + return True + except: + # Unexpected response + return False + + def info(self, instance_id=0): + """ Transport info. + + instance_id -- device instance id + """ + response = self._soap_request('GetTransportInfo', + {'InstanceID': instance_id}) + + if response: + return dict( + response['s:Envelope']['s:Body']['u:GetTransportInfoResponse']) + else: + return None + + def media_info(self, instance_id=0): + """ Media info. + + instance_id -- device instance id + """ + response = self._soap_request('GetMediaInfo', + {'InstanceID': instance_id}) + + if response: + return dict( + response['s:Envelope']['s:Body']['u:GetMediaInfoResponse']) + else: + return None + + def position_info(self, instance_id=0): + """ Position info. + instance_id -- device instance id + """ + response = self._soap_request('GetPositionInfo', + {'InstanceID': instance_id}) + + if response: + return dict( + response['s:Envelope']['s:Body']['u:GetPositionInfoResponse']) + else: + return None + + def set_next(self, url, instance_id=0): + """ Set next media to playback. + + url -- media url + instance_id -- device instance id + """ + response = self._soap_request('SetNextAVTransportURI', { + 'InstanceID': instance_id, + 'NextURI': url, + 'NextURIMetaData': '' + }) + + try: + response['s:Envelope']['s:Body']['u:SetNextAVTransportURIResponse'] + + return True + except: + # Unexpected response + return False + + def next(self, instance_id=0): + """ Play media that was already set as next. + + instance_id -- device instance id + """ + response = self._soap_request('Next', {'InstanceID': instance_id}) + + try: + response['s:Envelope']['s:Body']['u:NextResponse'] + + return True + except: + # Unexpected response + return False + + +def discover(name='', ip='', timeout=1, st=SSDP_ALL, mx=3, ssdp_version=1): + """ Discover UPnP devices in the local network. + + name -- name or part of the name to filter devices + timeout -- timeout to perform discover + st -- st field of discovery packet + mx -- mx field of discovery packet + return -- list of DlnapDevice + """ + st = st.format(ssdp_version) + + payload = "\r\n".join([ + 'M-SEARCH * HTTP/1.1', 'User-Agent: {}/{}'.format( + __file__, __version__), 'HOST: {}:{}'.format(*SSDP_GROUP), + 'Accept: */*', 'MAN: "ssdp:discover"', 'ST: {}'.format(st), + 'MX: {}'.format(mx), '', '' + ]) + + devices = [] + + with _send_udp(SSDP_GROUP, payload) as sock: + start = time.time() + + while True: + if time.time() - start > timeout: + # timed out + break + + r, w, x = select.select([sock], [], [sock], 1) + + if sock in r: + data, addr = sock.recvfrom(1024) + + if ip and addr[0] != ip: + continue + + d = DlnapDevice(data, addr[0]) + d.ssdp_version = ssdp_version + + if d not in devices: + if not name or name is None or name.lower( + ) in d.name.lower(): + if not ip: + devices.append(d) + elif d.has_av_transport: + # no need in further searching by ip + devices.append(d) + + break + elif sock in x: + raise Exception('Getting response failed') + else: + # Nothing to read + pass + + return devices - name -- name or part of the name to filter devices - timeout -- timeout to perform discover - st -- st field of discovery packet - mx -- mx field of discovery packet - return -- list of DlnapDevice - """ - st = st.format(ssdp_version) - payload = "\r\n".join([ - 'M-SEARCH * HTTP/1.1', - 'User-Agent: {}/{}'.format(__file__, __version__), - 'HOST: {}:{}'.format(*SSDP_GROUP), - 'Accept: */*', - 'MAN: "ssdp:discover"', - 'ST: {}'.format(st), - 'MX: {}'.format(mx), - '', - '']) - devices = [] - with _send_udp(SSDP_GROUP, payload) as sock: - start = time.time() - while True: - if time.time() - start > timeout: - # timed out - break - r, w, x = select.select([sock], [], [sock], 1) - if sock in r: - data, addr = sock.recvfrom(1024) - if ip and addr[0] != ip: - continue - - d = DlnapDevice(data, addr[0]) - d.ssdp_version = ssdp_version - if d not in devices: - if not name or name is None or name.lower() in d.name.lower(): - if not ip: - devices.append(d) - elif d.has_av_transport: - # no need in further searching by ip - devices.append(d) - break - - elif sock in x: - raise Exception('Getting response failed') - else: - # Nothing to read - pass - return devices if __name__ == '__main__': - import getopt - - def usage(): - print('{} [--ip ] [-d[evice] ] [--all] [-t[imeout] ] [--play ] [--pause] [--stop] [--proxy]'.format(__file__)) - print(' --ip - ip address for faster access to the known device') - print(' --device - discover devices with this name as substring') - print(' --all - flag to discover all upnp devices, not only devices with AVTransport ability') - print(' --play - set current url for play and start playback it. In case of url is empty - continue playing recent media.') - print(' --pause - pause current playback') - print(' --stop - stop current playback') - print(' --mute - mute playback') - print(' --unmute - unmute playback') - print(' --volume - set current volume for playback') - print(' --seek - set current position for playback') - print(' --timeout - discover timeout') - print(' --ssdp-version - discover devices by protocol version, default 1') - print(' --proxy - use local proxy on proxy port') - print(' --proxy-port - proxy port to listen incomming connections from devices, default 8000') - print(' --help - this help') - - def version(): - print(__version__) - - try: - opts, args = getopt.getopt(sys.argv[1:], "hvd:t:i:", [ # information arguments - 'help', - 'version', - 'log=', - - # device arguments - 'device=', - 'ip=', - - # action arguments - 'play=', - 'pause', - 'stop', - 'volume=', - 'mute', - 'unmute', - 'seek=', - - - # discover arguments - 'list', - 'all', - 'timeout=', - 'ssdp-version=', - - # transport info - 'info', - 'media-info', - - # download proxy - 'proxy', - 'proxy-port=']) - except getopt.GetoptError: - usage() - sys.exit(1) - - device = '' - url = '' - vol = 10 - position = '00:00:00' - timeout = 1 - action = '' - logLevel = logging.WARN - compatibleOnly = True - ip = '' - proxy = False - proxy_port = 8000 - ssdp_version = 1 - for opt, arg in opts: - if opt in ('-h', '--help'): - usage() - sys.exit(0) - elif opt in ('-v', '--version'): - version() - sys.exit(0) - elif opt in ('--log'): - if arg.lower() == 'debug': - logLevel = logging.DEBUG - elif arg.lower() == 'info': - logLevel = logging.INFO - elif arg.lower() == 'warn': - logLevel = logging.WARN - elif opt in ('--all'): - compatibleOnly = False - elif opt in ('-d', '--device'): - device = arg - elif opt in ('-t', '--timeout'): - timeout = float(arg) - elif opt in ('--ssdp-version'): - ssdp_version = int(arg) - elif opt in ('-i', '--ip'): - ip = arg - compatibleOnly = False - timeout = 10 - elif opt in ('--list'): - action = 'list' - elif opt in ('--play'): - action = 'play' - url = arg - elif opt in ('--pause'): - action = 'pause' - elif opt in ('--stop'): - action = 'stop' - elif opt in ('--volume'): - action = 'volume' - vol = arg - elif opt in ('--seek'): - action = 'seek' - position = arg - elif opt in ('--mute'): - action = 'mute' - elif opt in ('--unmute'): - action = 'unmute' - elif opt in ('--info'): - action = 'info' - elif opt in ('--media-info'): - action = 'media-info' - elif opt in ('--proxy'): - proxy = True - elif opt in ('--proxy-port'): - proxy_port = int(arg) - - logging.basicConfig(level=logLevel) - - st = URN_AVTransport_Fmt if compatibleOnly else SSDP_ALL - allDevices = discover(name=device, ip=ip, timeout=timeout, st=st, ssdp_version=ssdp_version) - if not allDevices: - print('No compatible devices found.') - sys.exit(1) - - if action in ('', 'list'): - print('Discovered devices:') - for d in allDevices: - print(' {} {}'.format('[a]' if d.has_av_transport else '[x]', d)) - sys.exit(0) - - d = allDevices[0] - print(d) - - if url.lower().replace('https://', '').replace('www.', '').startswith('youtube.'): - import subprocess - process = subprocess.Popen(['youtube-dl', '-g', url], stdout = subprocess.PIPE) - url, err = process.communicate() - - if url.lower().startswith('https://'): - proxy = True - - if proxy: - ip = socket.gethostbyname(socket.gethostname()) - t = threading.Thread(target=runProxy, kwargs={'ip' : ip, 'port' : proxy_port}) - t.start() - time.sleep(2) - - if action == 'play': - try: - d.stop() - url = 'http://{}:{}/{}'.format(ip, proxy_port, url) if proxy else url - d.set_current_media(url=url) - d.play() - except Exception as e: - print('Device is unable to play media.') - logging.warn('Play exception:\n{}'.format(traceback.format_exc())) - sys.exit(1) - elif action == 'pause': - d.pause() - elif action == 'stop': - d.stop() - elif action == 'volume': - d.volume(vol) - elif action == 'seek': - d.seek(position) - elif action == 'mute': - d.mute() - elif action == 'unmute': - d.unmute() - elif action == 'info': - print(d.info()) - elif action == 'media-info': - print(d.media_info()) - - if proxy: - t.join() + import getopt + + def usage(): + print( + '{} [--ip ] [-d[evice] ] [--all] [-t[imeout] ] [--play ] [--pause] [--stop] [--proxy]'. + format(__file__)) + print( + ' --ip - ip address for faster access to the known device' + ) + print( + ' --device - discover devices with this name as substring' + ) + print( + ' --all - flag to discover all upnp devices, not only devices with AVTransport ability' + ) + print( + ' --play - set current url for play and start playback it. In case of url is empty - continue playing recent media.' + ) + print(' --pause - pause current playback') + print(' --stop - stop current playback') + print( + ' --set-next - set next url to play after the current one') + print(' --next - stop the current url and play the next one') + print(' --mute - mute playback') + print(' --unmute - unmute playback') + print(' --volume - set current volume for playback') + print( + ' --seek - set current position for playback' + ) + print(' --timeout - discover timeout') + print( + ' --ssdp-version - discover devices by protocol version, default 1' + ) + print(' --proxy - use local proxy on proxy port') + print( + ' --proxy-port - proxy port to listen incomming connections from devices, default 8000' + ) + print(' --help - this help') + + def version(): + print(__version__) + + try: + opts, args = getopt.getopt(sys.argv[1:], "hvd:t:i:", [ # information arguments + 'help', + 'version', + 'log=', + + # device arguments + 'device=', + 'ip=', + + # action arguments + 'play=', + 'pause', + 'stop', + 'volume=', + 'mute', + 'unmute', + 'seek=', + + + # discover arguments + 'list', + 'all', + 'timeout=', + 'ssdp-version=', + + # transport info + 'info', + 'media-info', + + # download proxy + 'proxy', + 'proxy-port=']) + except getopt.GetoptError: + usage() + sys.exit(1) + + device = '' + url = '' + vol = 10 + position = '00:00:00' + timeout = 1 + action = '' + logLevel = logging.WARN + compatibleOnly = True + ip = '' + proxy = False + proxy_port = 8000 + ssdp_version = 1 + for opt, arg in opts: + if opt in ('-h', '--help'): + usage() + sys.exit(0) + elif opt in ('-v', '--version'): + version() + sys.exit(0) + elif opt in ('--log'): + if arg.lower() == 'debug': + logLevel = logging.DEBUG + elif arg.lower() == 'info': + logLevel = logging.INFO + elif arg.lower() == 'warn': + logLevel = logging.WARN + elif opt in ('--all'): + compatibleOnly = False + elif opt in ('-d', '--device'): + device = arg + elif opt in ('-t', '--timeout'): + timeout = float(arg) + elif opt in ('--ssdp-version'): + ssdp_version = int(arg) + elif opt in ('-i', '--ip'): + ip = arg + compatibleOnly = False + timeout = 10 + elif opt in ('--list'): + action = 'list' + elif opt in ('--play'): + action = 'play' + url = arg + elif opt in ('--pause'): + action = 'pause' + elif opt in ('--stop'): + action = 'stop' + elif opt in ('--set-next'): + action = 'set-next' + url = arg + elif opt in ('--next'): + action = 'next' + elif opt in ('--volume'): + action = 'volume' + vol = arg + elif opt in ('--seek'): + action = 'seek' + position = arg + elif opt in ('--mute'): + action = 'mute' + elif opt in ('--unmute'): + action = 'unmute' + elif opt in ('--info'): + action = 'info' + elif opt in ('--media-info'): + action = 'media-info' + elif opt in ('--proxy'): + proxy = True + elif opt in ('--proxy-port'): + proxy_port = int(arg) + + logging.basicConfig(level=logLevel) + + st = URN_AVTransport_Fmt if compatibleOnly else SSDP_ALL + + allDevices = discover( + name=device, ip=ip, timeout=timeout, st=st, ssdp_version=ssdp_version) + + if not allDevices: + print('No compatible devices found.') + + sys.exit(1) + + if action in ('', 'list'): + print('Discovered devices:') + + for d in allDevices: + print(' {} {}'.format('[a]' if d.has_av_transport else '[x]', d)) + + sys.exit(0) + + d = allDevices[0] + + if url.lower().replace('https://', '').replace('www.', + '').startswith('youtube.'): + import subprocess + process = subprocess.Popen( + ['youtube-dl', '-g', url], stdout=subprocess.PIPE) + url, err = process.communicate() + + if url.lower().startswith('https://'): + proxy = True + + if proxy: + ip = _get_primary_ip() + + t = threading.Thread( + target=runProxy, kwargs={'ip': ip, + 'port': proxy_port}) + t.start() + time.sleep(2) + + if action == 'play': + try: + d.stop() + url = 'http://{}:{}/{}'.format(ip, proxy_port, + url) if proxy else url + d.set_current_media(url=url) + d.play() + except Exception as e: + print('Device is unable to play media.') + logging.warn('Play exception:\n{}'.format(traceback.format_exc())) + sys.exit(1) + elif action == 'pause': + d.pause() + elif action == 'stop': + d.stop() + elif action == 'set-next': + try: + url = 'http://{}:{}/{}'.format(ip, proxy_port, + url) if proxy else url + d.set_next(url=url) + except Exception as e: + print('Device is unable to enqueue next media.') + logging.warn( + 'Set_next exception:\n{}'.format(traceback.format_exc())) + sys.exit(1) + elif action == 'next': + d.next() + elif action == 'volume': + d.volume(vol) + elif action == 'seek': + d.seek(position) + elif action == 'mute': + d.mute() + elif action == 'unmute': + d.unmute() + elif action == 'info': + print(d.info()) + elif action == 'media-info': + print(d.media_info()) + + if proxy: + t.join() diff --git a/dlnap/setup.py b/dlnap/setup.py new file mode 100644 index 0000000..895808c --- /dev/null +++ b/dlnap/setup.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +from setuptools import setup + +setup(name='dlnap', + version='0.14', + description='Python over the network media player to playback on DLNA UPnP devices', + author='cherezov', + author_email='cherezov.pavel@gmail.com', + url='https://github.com/cherezov/dlnap', + license='MIT', + platforms=['all'], + classifiers=[ + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.5', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: Implementation :: Jython', + 'Programming Language :: Python :: Implementation :: PyPy', + ], + py_modules=['dlnap'], + install_requires=[ + 'xmltodict>=0.11.0', + ], +) diff --git a/requirement.txt b/requirement.txt new file mode 100644 index 0000000..c811390 --- /dev/null +++ b/requirement.txt @@ -0,0 +1 @@ +xmltodict==0.11.0 \ No newline at end of file