diff --git a/README.md b/README.md index bc58b97..30eb74b 100644 --- a/README.md +++ b/README.md @@ -14,35 +14,41 @@ This package is not on PyPI (yet) so install from this repo: Easy! + # discover airplay devices + $ airplay discover + + # send a local photo + $ airplay photo ~/image.jpg + # send a internet photo and display for 5 seconds + $ airplay photo http://www.beihaiting.com/uploads/allimg/150401/10723-150401193I54I.jpg -t 5 + + # screen cast(without audio yet) for 120 seconds + # for linux like debian/ubuntu: sudo apt-get install scrot + $ airplay screen -t 120 + # play a remote video file - $ airplay http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 - + $ airplay video http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 + # play a remote video file, but start it half way through - $ airplay -p 0.5 http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 - + $ airplay video -p 0.5 http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 + # play a local video file - $ airplay /path/to/some/local/file.mp4 - + $ airplay video /path/to/some/local/file.mp4 + # or play to a specific device - $ airplay --device 192.0.2.23:7000 http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 - + $ airplay video --device 192.0.2.23:7000 http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 + $ airplay --help - usage: airplay [-h] [--position POSITION] [--device DEVICE] path - - Playback a local or remote video file via AirPlay. This does not do any on- - the-fly transcoding (yet), so the file must already be suitable for the - AirPlay device. - - positional arguments: - path An absolute path or URL to a video file - - optional arguments: - -h, --help show this help message and exit - --position POSITION, --pos POSITION, -p POSITION - Where to being playback [0.0-1.0] - --device DEVICE, --dev DEVICE, -d DEVICE - Playback video to a specific device - [:()] + Usage: airplay [OPTIONS] COMMAND [ARGS]... + + Options: + --help Show this message and exit. + + Commands: + discover Discover AirPlay devices in connected network + photo AirPlay a photo + screen AirPlay screen cast(without voice yet) + video AirPlay a video @@ -52,55 +58,55 @@ Awesome! This package is compatible with Python >= 2.7 (including Python 3!) # Import the AirPlay class >>> from airplay import AirPlay - + # If you have zeroconf installed, the find() classmethod will locate devices for you >>> AirPlay.find(fast=True) [] - + # or you can manually specify a host/ip and optionally a port >>> ap = AirPlay('192.0.2.23') >>> ap = AirPlay('192.0.2.3', 7000) - + # Query the device >>> ap.server_info() {'protovers': '1.0', 'deviceid': 'FF:FF:FF:FF:FF:FF', 'features': 955001077751, 'srcvers': '268.1', 'vv': 2, 'osBuildVersion': '13U717', 'model': 'AppleTV5,3', 'macAddress': 'FF:FF:FF:FF:FF:FF'} - + # Play a video >>> ap.play('http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4') True - + # Get detailed playback information >>> ap.playback_info() {'duration': 60.095, 'playbackLikelyToKeepUp': True, 'readyToPlayMs': 0, 'rate': 1.0, 'playbackBufferEmpty': True, 'playbackBufferFull': False, 'loadedTimeRanges': [{'start': 0.0, 'duration': 60.095}], 'seekableTimeRanges': [{'start': 0.0, 'duration': 60.095}], 'readyToPlay': 1, 'position': 4.144803403} - + # Get just the playhead position >>> ap.scrub() {'duration': 60.095001, 'position': 12.465443} - + # Seek to an absolute position >>> ap.scrub(30) {'duration': 60.095001, 'position': 30.0} - + # Pause playback >>> ap.rate(0.0) True - + # Resume playback >>> ap.rate(1.0) True - + # Stop playback completely >>> ap.stop() True - + # Start a webserver to stream a local file to an AirPlay device >>> ap.serve('/tmp/home_movie.mp4') 'http://192.0.2.114:51058/home_movie.mp4' - + # Playback the generated URL >>> ap.play('http://192.0.2.114:51058/home_movie.mp4') True - + # Read events from a generator as the device emits them >>> for event in ap.events(): ... print(event) @@ -144,7 +150,7 @@ Discover AirPlay devices using Zeroconf/Bonjour #### Arguments * **timeout (int):** The number of seconds to wait for responses. If fast is False, then this function will always block for this number of seconds. - + * **fast (bool):** If True, do not wait for timeout to expire return as soon as we've found at least one AirPlay device. #### Returns @@ -218,7 +224,7 @@ Return the current playback position, optionally seek to a specific position >>> ap.scrub() {'duration': 60.095001, 'position': 12.465443} - + >>> ap.scrub(30) {'duration': 60.095001, 'position': 30.0} diff --git a/airplay/airplay.py b/airplay/airplay.py index 72006cf..b07a645 100644 --- a/airplay/airplay.py +++ b/airplay/airplay.py @@ -4,6 +4,7 @@ import socket import time import warnings +import traceback from multiprocessing import Process, Queue @@ -84,7 +85,13 @@ def do_POST(self): raise RuntimeError('Received an event with a zero length body.') # parse XML plist - self.event = plist_loads(self.rfile.read(content_length)) + xml = self.rfile.read(content_length) + if len(xml) < content_length: + # sometimes the content is not complete... + self.event = plist_loads(xml+"") + else: + self.event = plist_loads(xml) + class AirPlay(object): @@ -180,7 +187,7 @@ def _monitor_events(self, event_queue, control_queue): # pragma: no cover # parse it try: req = AirPlayEvent(FakeSocket(raw_request), event_socket.getpeername(), None) - except RuntimeError as exc: + except Exception as exc: raise RuntimeError( "Unexpected request from AirPlay while processing events\n" "Error: {0}\nReceived:\n{1}".format(exc, raw_request) @@ -190,7 +197,8 @@ def _monitor_events(self, event_queue, control_queue): # pragma: no cover event_socket.send(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") # skip non-video events - if req.event.get('category', None) != 'video': + if not hasattr(req, 'event') or req.event is None or \ + req.event.get('category', None) != 'video': continue # send the event back to the parent process @@ -199,6 +207,7 @@ def _monitor_events(self, event_queue, control_queue): # pragma: no cover except KeyboardInterrupt: return except Exception as exc: + traceback.print_exc() event_queue.put(exc) return @@ -245,12 +254,13 @@ def events(self, block=True): except Empty: return - def _command(self, uri, method='GET', body='', **kwargs): + def _command(self, uri, method='GET', header={}, body='', **kwargs): """Makes an HTTP request through to an AirPlay server Args: uri(string): The URI to request method(string): The HTTP verb to use when requesting `uri`, defaults to GET + header(dict): The header to be send with the request body(string): If provided, will be sent witout alteration as the request body. Content-Length header will be set to len(`body`) **kwargs: If provided, Will be converted to a query string and appended to `uri` @@ -265,8 +275,12 @@ def _command(self, uri, method='GET', body='', **kwargs): # generate the request if len(kwargs): uri = uri + '?' + urlencode(kwargs) - - request = method + " " + uri + " HTTP/1.1\r\nContent-Length: " + str(len(body)) + "\r\n\r\n" + body + # request = method + " " + uri + " HTTP/1.1\r\nContent-Length: " + str(len(body)) + "\r\n\r\n" + body + header['Content-Length'] = str(len(body)) + sheader = "" + for key, value in header.items(): + sheader += key + ": " + value + "\r\n" + request = method + " " + uri + " HTTP/1.1\r\n" + sheader + "\r\n" + body try: request = bytes(request, 'UTF-8') @@ -342,7 +356,30 @@ def play(self, url, position=0.0): return self._command( '/play', 'POST', - "Content-Location: {0}\nStart-Position: {1}\n\n".format(url, float(position)) + body="Content-Location: {0}\nStart-Position: {1}\n\n".format(url, float(position)) + ) + + def photo(self, img): + """Start a photo display + + Args: + img(bytes): image data + + Returns: + bool: The request was accepted. + + """ + header = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Connection": "keep-alive", + "Content-Type": "application/octet-stream" + } + return self._command( + '/photo', + 'PUT', + header, + img ) def rate(self, rate): diff --git a/airplay/cli.py b/airplay/cli.py index 9d66b2e..da64096 100644 --- a/airplay/cli.py +++ b/airplay/cli.py @@ -1,6 +1,11 @@ -import argparse import os +import os.path +import platform import time +import sys +import traceback +import urllib +import tempfile from airplay import AirPlay @@ -21,7 +26,7 @@ def get_airplay_device(hostport): devices = AirPlay.find(fast=True) if len(devices) == 0: - raise RuntimeError('No AirPlay devices were found. Use --device to manually specify an device.') + return None elif len(devices) == 1: return devices[0] elif len(devices) > 1: @@ -41,55 +46,120 @@ def humanize_seconds(secs): return "%02d:%02d:%02d" % (h, m, s) +@click.group() def main(): - parser = argparse.ArgumentParser( - description="Playback a local or remote video file via AirPlay. " - "This does not do any on-the-fly transcoding (yet), " - "so the file must already be suitable for the AirPlay device." - ) - - parser.add_argument( - 'path', - help='An absolute path or URL to a video file' - ) - - parser.add_argument( - '--position', - '--pos', - '-p', - default=0.0, - type=float, - help='Where to being playback [0.0-1.0]' - ) - - parser.add_argument( - '--device', - '--dev', - '-d', - default=None, - help='Playback video to a specific device [:()]' - ) - - args = parser.parse_args() - - # connect to the AirPlay device we want to control + pass + + +@main.command() +def discover(): + """Discover AirPlay devices in connected network""" + click.echo("AirPlay device discover start...") + devices = AirPlay.find(fast=True) + if not devices or len(devices) == 0: + click.echo("There's no AirPlay device in your network.") + else: + click.echo("AirPlay device discover complete!") + for dd in devices: + click.echo("\t %s\t %s:%s\n" % (dd.name, dd.host, dd.port)) + + +@main.command() +@click.option('-t', '--time', 'duration', metavar='