diff --git a/nanodlna/cli.py b/nanodlna/cli.py index 7667932..776534d 100755 --- a/nanodlna/cli.py +++ b/nanodlna/cli.py @@ -6,6 +6,7 @@ import json import os import sys +import signal import datetime import tempfile @@ -52,34 +53,15 @@ def list_devices(args): set_logs(args) logging.info("Scanning devices...") - my_devices = devices.get_devices(args.timeout) + my_devices = devices.get_devices(args.timeout, args.local_host) logging.info("Number of devices found: {}".format(len(my_devices))) for i, device in enumerate(my_devices, 1): print("Device {0}:\n{1}\n\n".format(i, json.dumps(device, indent=4))) -def play(args): - - set_logs(args) - - logging.info("Starting to play") - - # Get video and subtitle file names - - files = {"file_video": args.file_video} - - if args.use_subtitle: - - if not args.file_subtitle: - args.file_subtitle = get_subtitle(args.file_video) - - if args.file_subtitle: - files["file_subtitle"] = args.file_subtitle - - logging.info("Media files: {}".format(json.dumps(files))) +def find_device(args): - # Select device to play logging.info("Selecting device to play") device = None @@ -88,7 +70,7 @@ def play(args): logging.info("Select device by URL") device = devices.register_device(args.device_url) else: - my_devices = devices.get_devices(args.timeout) + my_devices = devices.get_devices(args.timeout, args.local_host) if len(my_devices) > 0: if args.device_query: @@ -100,6 +82,30 @@ def play(args): logging.info("Select first device") device = my_devices[0] + return device + + +def play(args): + + set_logs(args) + + logging.info("Starting to play") + + # Get video and subtitle file names + + files = {"file_video": args.file_video} + + if args.use_subtitle: + + if not args.file_subtitle: + args.file_subtitle = get_subtitle(args.file_video) + + if args.file_subtitle: + files["file_subtitle"] = args.file_subtitle + + logging.info("Media files: {}".format(json.dumps(files))) + + device = find_device(args) if not device: sys.exit("No devices found.") @@ -117,16 +123,64 @@ def play(args): logging.info("Streaming server ready") + # Register handler if interrupt signal is received + signal.signal(signal.SIGINT, build_handler_stop(device)) + # Play the video through DLNA protocol logging.info("Sending play command") dlna.play(files_urls, device) +def build_handler_stop(device): + def signal_handler(sig, frame): + + logging.info("Interrupt signal detected") + + logging.info("Sending stop command to render device") + dlna.stop(device) + + logging.info("Stopping streaming server") + streaming.stop_server() + + sys.exit( + "Interrupt signal detected. " + "Sent stop command to render device and " + "stopped streaming. " + "nano-dlna will exit now!" + ) + return signal_handler + + +def pause(args): + + set_logs(args) + + logging.info("Selecting device to pause") + device = find_device(args) + + # Pause through DLNA protocol + logging.info("Sending pause command") + dlna.pause(device) + + +def stop(args): + + set_logs(args) + + logging.info("Selecting device to stop") + device = find_device(args) + + # Stop through DLNA protocol + logging.info("Sending stop command") + dlna.stop(device) + + def run(): parser = argparse.ArgumentParser( description="A minimal UPnP/DLNA media streamer.") parser.set_defaults(func=lambda args: parser.print_help()) + parser.add_argument("-H", "--host", dest="local_host") parser.add_argument("-t", "--timeout", type=float, default=5) parser.add_argument("-b", "--debug", dest="debug_activated", action="store_true") @@ -137,7 +191,6 @@ def run(): p_play = subparsers.add_parser('play') p_play.add_argument("-d", "--device", dest="device_url") - p_play.add_argument("-H", "--host", dest="local_host") p_play.add_argument("-q", "--query-device", dest="device_query") p_play.add_argument("-s", "--subtitle", dest="file_subtitle") p_play.add_argument("-n", "--no-subtitle", @@ -145,6 +198,16 @@ def run(): p_play.add_argument("file_video") p_play.set_defaults(func=play) + p_pause = subparsers.add_parser('pause') + p_pause.add_argument("-d", "--device", dest="device_url") + p_pause.add_argument("-q", "--query-device", dest="device_query") + p_pause.set_defaults(func=pause) + + p_stop = subparsers.add_parser('stop') + p_stop.add_argument("-d", "--device", dest="device_url") + p_stop.add_argument("-q", "--query-device", dest="device_query") + p_stop.set_defaults(func=stop) + args = parser.parse_args() args.func(args) diff --git a/nanodlna/devices.py b/nanodlna/devices.py index 0c2bc85..c7547ea 100644 --- a/nanodlna/devices.py +++ b/nanodlna/devices.py @@ -3,6 +3,7 @@ import re import socket +import struct import sys import xml.etree.ElementTree as ET @@ -25,37 +26,67 @@ "MAN: \"ssdp:discover\"", "MX: 10", "ST: ssdp:all", "", ""] SSDP_BROADCAST_MSG = "\r\n".join(SSDP_BROADCAST_PARAMS) -UPNP_DEFAULT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" +UPNP_DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaRenderer:1" +UPNP_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" + + +def get_xml_field_text(xml_root, query): + result = None + if xml_root: + node = xml_root.find(query) + result = node.text if node is not None else None + return result def register_device(location_url): - xml = urllibreq.urlopen(location_url).read().decode("UTF-8") - xml = re.sub(" xmlns=\"[^\"]+\"", "", xml, count=1) + xml_raw = urllibreq.urlopen(location_url).read().decode("UTF-8") + logging.debug( + "Device to be registered: {}".format( + json.dumps({ + "location_url": location_url, + "xml_raw": xml_raw + }) + ) + ) + + xml = re.sub(r"""\s(xmlns="[^"]+"|xmlns='[^']+')""", '', xml_raw, count=1) info = ET.fromstring(xml) location = urllibparse.urlparse(location_url) hostname = location.hostname - friendly_name = info.find("./device/friendlyName").text - - try: - path = info.find( - "./device/serviceList/service/" - "[serviceType='{0}']/controlURL".format( - UPNP_DEFAULT_SERVICE_TYPE + device_root = info.find("./device") + if not device_root: + device_root = info.find( + "./device/deviceList/device/" + "[deviceType='{0}']".format( + UPNP_DEVICE_TYPE ) - ).text - action_url = urllibparse.urljoin(location_url, path) - except AttributeError: + ) + + friendly_name = get_xml_field_text(device_root, "./friendlyName") + manufacturer = get_xml_field_text(device_root, "./manufacturer") + action_url_path = get_xml_field_text( + device_root, + "./serviceList/service/" + "[serviceType='{0}']/controlURL".format( + UPNP_SERVICE_TYPE + ) + ) + + if action_url_path is not None: + action_url = urllibparse.urljoin(location_url, action_url_path) + else: action_url = None device = { "location": location_url, "hostname": hostname, + "manufacturer": manufacturer, "friendly_name": friendly_name, "action_url": action_url, - "st": UPNP_DEFAULT_SERVICE_TYPE + "st": UPNP_SERVICE_TYPE } logging.debug( @@ -70,12 +101,31 @@ def register_device(location_url): return device -def get_devices(timeout=3.0): +def remove_duplicates(devices): + seen = set() + result_devices = [] + for device in devices: + device_str = str(device) + if device_str not in seen: + result_devices.append(device) + seen.add(device_str) + return result_devices + + +def get_devices(timeout=3.0, host=None): + + if not host: + host = "0.0.0.0" + logging.debug("Searching for devices on {}".format(host)) logging.debug("Configuring broadcast message") s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 4) - s.bind(("", SSDP_BROADCAST_PORT + 10)) + + # OpenBSD needs the ttl for the IP_MULTICAST_TTL as an unsigned char + ttl = struct.pack("B", 4) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + + s.bind((host, 0)) logging.debug("Sending broadcast message") s.sendto(SSDP_BROADCAST_MSG.encode("UTF-8"), (SSDP_BROADCAST_ADDR, @@ -109,9 +159,19 @@ def get_devices(timeout=3.0): except Exception: pass - devices_urls = [dev["location"] - for dev in devices if "AVTransport" in dev["st"]] - devices = [register_device(location_url) for location_url in devices_urls] + devices_urls = [ + dev["location"] + for dev in devices + if "st" in dev and + "AVTransport" in dev["st"] + ] + + devices = [ + register_device(location_url) + for location_url in devices_urls + ] + + devices = remove_duplicates(devices) return devices @@ -120,7 +180,7 @@ def get_devices(timeout=3.0): timeout = int(sys.argv[1]) if len(sys.argv) >= 2 else 5 - devices = get_devices(timeout) + devices = get_devices(timeout, "0.0.0.0") for i, device in enumerate(devices, 1): print("Device {0}:\n{1}\n\n".format(i, json.dumps(device, indent=4))) diff --git a/nanodlna/dlna.py b/nanodlna/dlna.py index a6f2111..109db6d 100644 --- a/nanodlna/dlna.py +++ b/nanodlna/dlna.py @@ -28,7 +28,9 @@ def send_dlna_action(device, data, action): action_data = pkgutil.get_data( "nanodlna", "templates/action-{0}.xml".format(action)).decode("UTF-8") - action_data = action_data.format(**data).encode("UTF-8") + if data: + action_data = action_data.format(**data) + action_data = action_data.encode("UTF-8") headers = { "Content-Type": "text/xml; charset=\"utf-8\"", @@ -95,3 +97,21 @@ def play(files_urls, device): send_dlna_action(device, video_data, "SetAVTransportURI") logging.debug("Playing video") send_dlna_action(device, video_data, "Play") + + +def pause(device): + logging.debug("Pausing device: {}".format( + json.dumps({ + "device": device + }) + )) + send_dlna_action(device, None, "Pause") + + +def stop(device): + logging.debug("Stopping device: {}".format( + json.dumps({ + "device": device + }) + )) + send_dlna_action(device, None, "Stop") diff --git a/nanodlna/streaming.py b/nanodlna/streaming.py index b9447c0..169c20b 100644 --- a/nanodlna/streaming.py +++ b/nanodlna/streaming.py @@ -88,6 +88,10 @@ def start_server(files, serve_ip, serve_port=9000): return files_urls +def stop_server(): + reactor.stop() + + def get_serve_ip(target_ip, target_port=80): logging.debug("Identifying server IP") s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) diff --git a/nanodlna/templates/action-Pause.xml b/nanodlna/templates/action-Pause.xml new file mode 100644 index 0000000..838fca7 --- /dev/null +++ b/nanodlna/templates/action-Pause.xml @@ -0,0 +1,8 @@ + + + + + 0 + + + diff --git a/nanodlna/templates/action-Stop.xml b/nanodlna/templates/action-Stop.xml new file mode 100644 index 0000000..7026ce1 --- /dev/null +++ b/nanodlna/templates/action-Stop.xml @@ -0,0 +1,8 @@ + + + + + 0 + + + diff --git a/setup.py b/setup.py index 0f96f74..b36faa5 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( name='nanodlna', - version='0.2.1', + version='0.3.0', description='A minimal UPnP/DLNA media streamer', long_description="""nano-dlna is a command line tool that allows you to play a local video file in your TV (or any other DLNA compatible device)""",