diff --git a/README.md b/README.md index bc58b97..73a1370 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,21 @@ Easy! # or play to a specific device $ airplay --device 192.0.2.23:7000 http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4 + # if ffmpeg/ffprobe is installed, you can playback files in any format + $ airplay /path/to/some/old_xvid.avi + + # and from most video sites URLs directly + $ airplay https://www.youtube.com/watch?v=dQw4w9WgXcQ + $ airplay --help - usage: airplay [-h] [--position POSITION] [--device DEVICE] path + usage: airplay [-h] [--position POSITION] [--device DEVICE] [--force] + [--ffmpeg FFMPEG] [--ffprobe FFPROBE] [--tmpdir TMPDIR] + 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. + Playback a local or remote video file via AirPlay. If ffmpeg and ffprobe are + available, video will automatically be converted to work with your AirPlay + device if needed. Static builds of these tools are available at + https://ffmpeg.org/download.html positional arguments: path An absolute path or URL to a video file @@ -39,11 +48,18 @@ Easy! optional arguments: -h, --help show this help message and exit --position POSITION, --pos POSITION, -p POSITION - Where to being playback [0.0-1.0] + Where to being playback [0.0-1.0] (default: 0.0) --device DEVICE, --dev DEVICE, -d DEVICE Playback video to a specific device - [:()] - + [:()] (default: None) + --force, -f Force playback of path as given. Do not attempt + parsing or conversion (default: False) + --ffmpeg FFMPEG The ffmpeg binary to use for conversion (if needed) + (default: ffmpeg) + --ffprobe FFPROBE The ffprobe binary to use for parsing (if needed) + (default: ffprobe) + --tmpdir TMPDIR Use this temp directory when converting files + (default: None) ## I want to use this package in my own application @@ -93,9 +109,24 @@ Awesome! This package is compatible with Python >= 2.7 (including Python 3!) >>> ap.stop() True + # Use ffmpeg to see if a file is playable + >>> ap.can_play('/tmp/home_movie.mp4') + True + + >>> ap.can_play('/tmp/old_movie.avi') + False + + # use ffmpeg to convert a file to the correct format for the Airplay device + >>> ap.convert('/tmp/old_movie.avi') + ['/tmp/tmpnweUsp/airplay.m3u8', '/tmp/tmpnweUsp/airplay.ts'] + + # configure the encoder to use custom versions of ffmpeg + >>> from airplay import FFmpeg + >>> ap.encoder = FFmpeg(ffmpeg='/home/foo/bin/ffmpeg') + # 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' + ['http://192.0.2.114:51058/home_movie.mp4'] # Playback the generated URL >>> ap.play('http://192.0.2.114:51058/home_movie.mp4') @@ -266,6 +297,65 @@ A generator that yields events as they are emitted by the AirPlay device * **dict:** key/value pairs describing the event emitted by the AirPlay device + +### can_play(path) +Use the encoder to inspect the file and determine if the AirPlay device can play it + + >>> ap.can_play('/tmp/home_movie.mp4') + True + + >>> ap.can_play('/tmp/old_movie.avi') + False + + +#### Arguments +* **path (str):** An absoulte path or URL to a file to be checked. + +#### Returns +* **True:** The file can be played. +* **False:** The file cannot be played. + +#### Raises +* **airplay.MediaParseError:** path could not be parsed. +* **airplay.EncoderNotInstalledError** ffprobe is not installed or is an incorrect version. + + +### convert(paths, tmpdir=None) +Start a encoder process to convert path to a version that can be played on an AirPlay device. The output format is a HLS stream, with an index file, and a single transport stream + +These files can be passed to serve() to stream them to the AirPlay device. + + >>> ap.convert('/tmp/old_movie.avi') + ['/tmp/tmpnweUsp/airplay.m3u8', '/tmp/tmpnweUsp/airplay.ts'] + + >>> ap.serve(['/tmp/tmpnweUsp/airplay.m3u8', '/tmp/tmpnweUsp/airplay.ts']) + ['http://192.0.2.114:51058/airplay.m3u8', 'http://192.0.2.114:51058/airplay.ts'] + + >>> ap.play('http://192.0.2.114:51058/airplay.m3u8') + True + + +#### Arguments +* **paths (list):** A list of one or more input files or URLs which will be combined and converted +* **tmpdir (str):** A path to a directory to store the converted video. If not specified tempfile.mkdtemp() will be used + +### Returns +* **list (index, ts):** Absolute paths for the index and transport stream for the converted file + +### Raises +* **MediaParseError:** Unable to parse one of the input paths +* **EncoderNotInstalledError:** ffmpeg could not be executed or was not the correct version + +### Properties + +### .encoder + +This property can be set if custom ffmpeg / ffprobe paths are required. +By default, AirPlay expects to find 'ffmpeg' and 'ffprobe' in the PATH. + + >>> from airplay import FFmpeg + >>> ap.encoder = FFmpeg(ffmpeg='/home/foo/bin/ffmpeg', ffprobe='/some/path/to/ffprobe') + ## Need more information? The [source for the cli script](airplay/cli.py) is a good example of how to use this package. diff --git a/airplay/__init__.py b/airplay/__init__.py index a5b33d5..89779a9 100644 --- a/airplay/__init__.py +++ b/airplay/__init__.py @@ -1,2 +1,3 @@ from .airplay import AirPlay # NOQA from .http_server import RangeHTTPServer # NOQA +from .ffmpeg import FFmpeg, EncoderNotInstalledError, MediaParseError # NOQA diff --git a/airplay/airplay.py b/airplay/airplay.py index 72006cf..fe6da78 100644 --- a/airplay/airplay.py +++ b/airplay/airplay.py @@ -1,7 +1,9 @@ import atexit import email import os +import shutil import socket +import tempfile import time import warnings @@ -45,6 +47,7 @@ pass from .http_server import RangeHTTPServer +from .ffmpeg import FFmpeg class FakeSocket(): @@ -96,6 +99,8 @@ class AirPlay(object): """ RECV_SIZE = 8192 + _encoder = None + def __init__(self, host, port=7000, name=None, timeout=5): """Connect to an AirPlay device on `host`:`port` optionally named `name` @@ -118,9 +123,26 @@ def __init__(self, host, port=7000, name=None, timeout=5): self.control_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.control_socket.settimeout(timeout) self.control_socket.connect((host, port)) + + self.host = self.control_socket.getpeername()[0] except socket.error as exc: raise ValueError("Unable to connect to {0}:{1}: {2}".format(host, port, exc)) + def __repr__(self): + return '<{0} {1}({2}:{3})>'.format(self.__class__.__name__, self.name, self.host, self.port) + + @property + def encoder(self): + """Don't instantiate the encoder until we access it the first time""" + if not self._encoder: + self._encoder = FFmpeg() + return self._encoder + + @encoder.setter + def encoder(self, val): + """Allow manually setting the encoder""" + self._encoder = val + def _monitor_events(self, event_queue, control_queue): # pragma: no cover """Connect to `host`:`port` and use reverse HTTP to receive events. @@ -195,7 +217,6 @@ def _monitor_events(self, event_queue, control_queue): # pragma: no cover # send the event back to the parent process event_queue.put(req.event) - except KeyboardInterrupt: return except Exception as exc: @@ -408,29 +429,111 @@ def scrub(self, position=None): # convert the strings we get back to floats (which they should be) return {kk: float(vv) for (kk, vv) in response.items()} - def serve(self, path): + def serve(self, paths): """Start a HTTP server to serve local content to the AirPlay device Args: - path(str): An absoulte path to a local file to be served. + paths(list): A list of absolute paths to be served. Returns: - str: An absolute url to the `path` suitable for passing to play() + list(str): An absolute urls to the paths requested to serve + """ + if not isinstance(paths, list): + paths = [paths] + q = Queue() - self._http_server = Process(target=RangeHTTPServer.start, args=(path, self.host, q)) + self._http_server = Process(target=RangeHTTPServer.start, args=(paths, self.host, q)) self._http_server.start() atexit.register(lambda: self._http_server.terminate()) server_address = (self.control_socket.getsockname()[0], q.get(True)[1]) - return 'http://{0}:{1}/{2}'.format( + return ['http://{0}:{1}/{2}'.format( server_address[0], server_address[1], pathname2url(os.path.basename(path)) - ) + ) for path in paths] + + def convert(self, paths, tmpdir=None): + """Start a encoder process to convert `path` to a version that can be + played on an AirPlay device. + + Args: + paths (list): A list of one or more input files or URLs + which will be combined and converted + tmpdir (str): A path to a directory to store the converted video. + If not specified tempfile.mkdtemp() will be used + + Returns: + list (index, ts): Absolute paths for the index and transport stream + for the converted file + + Raises: + MediaParseError: Unable to parse one of the input paths + EncoderNotInstalledError: ffmpeg could not be executed or was not the correct version + """ + + work_dir = tempfile.mkdtemp(dir=tmpdir) + + index = os.path.join(work_dir, 'airplay.m3u8') + transport_stream = os.path.join(work_dir, 'airplay.ts') + + self._converter = Process(target=self.encoder.segment, args=(paths, work_dir)) + self._converter.start() + + def cleanup(work_dir): + try: + shutil.rmtree(work_dir) + except OSError: + pass + + atexit.register(cleanup, work_dir) + + # wait until the target file exists or the converter dies + + while not os.path.exists(index) and self._converter.is_alive(): + time.sleep(.1) + + return [index, transport_stream] + + def can_play(self, path): + """Use the encoder to inspect the file and see if we can play it + + Args: + path(str): An absoulte path or URL to a file to be checked. + + Returns: + True: The file can be played. + False: The file cannot be played. + + Raises: + airplay.MediaParseError: `path` could not be parsed. + + airplay.EncoderNotInstalledError: ffprobe is not installed or is + an incorrect version. + + """ + container, streams = self.encoder.probe(path) + + # container is mov/mp4 + if container != 'mov,mp4,m4a,3gp,3g2,mj2': + return False + + try: + # first track is h264 video + if streams[0][0] != 'video' or streams[0][1] != 'h264': + return False + + # some track is aac audio + if 'aac' not in [x[1] for x in streams if x[0] == 'audio']: + return False + except IndexError: + return False + + return True @classmethod def find(cls, timeout=10, fast=False): diff --git a/airplay/cli.py b/airplay/cli.py index 9d66b2e..8a54a2c 100644 --- a/airplay/cli.py +++ b/airplay/cli.py @@ -2,9 +2,12 @@ import os import time -from airplay import AirPlay +from .airplay import AirPlay + +from .ffmpeg import FFmpeg, MediaParseError, EncoderNotInstalledError import click +import youtube_dl def get_airplay_device(hostport): @@ -41,11 +44,34 @@ def humanize_seconds(secs): return "%02d:%02d:%02d" % (h, m, s) +def youtubedl(target, fmt='best[ext=mp4]/bestvideo+bestaudio'): + urls = [] + try: + ydl = youtube_dl.YoutubeDL({'format': fmt}) + info = ydl.extract_info(target, download=False) + + if 'entries' in info: + info = info['entries'][0] + + for fid in info['format_id'].split('+'): + for ff in info['formats']: + if ff['format_id'] == fid: + urls.append(ff['url']) + + except youtube_dl.utils.DownloadError: + pass + + return urls + + 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." + "If ffmpeg and ffprobe are available, video will automatically " + "be converted to work with your AirPlay device if needed. " + "Static builds of these tools are available at " + "https://ffmpeg.org/download.html", + formatter_class=argparse.ArgumentDefaultsHelpFormatter ) parser.add_argument( @@ -69,6 +95,30 @@ def main(): default=None, help='Playback video to a specific device [:()]' ) + parser.add_argument( + '--force', + '-f', + default=False, + action='store_true', + help='Force playback of path as given. Do not attempt parsing or conversion' + ) + + parser.add_argument( + '--ffmpeg', + default='ffmpeg', + help='The ffmpeg binary to use for conversion (if needed)' + ) + parser.add_argument( + '--ffprobe', + default='ffprobe', + help='The ffprobe binary to use for parsing (if needed)' + ) + + parser.add_argument( + '--tmpdir', + default=None, + help='Use this temp directory when converting files' + ) args = parser.parse_args() @@ -78,18 +128,55 @@ def main(): except (ValueError, RuntimeError) as exc: parser.error(exc) + # now figure out what we want to playback + target = args.path + + # if they told us to force it, skip all the checking + if not args.force: + + # bail if they gave us custom ffmpeg settings and they are fubar + try: + if args.ffmpeg != 'ffmpeg' or args.ffprobe != 'ffprobe': + ap.encoder = FFmpeg(ffmpeg=args.ffmpeg, ffprobe=args.ffprobe) + except EncoderNotInstalledError as exc: + parser.exit(exc) + + try: + # see if we can play the url they gave us + if not ap.can_play(target): + # if not, convert it + target = ap.convert(target, tmpdir=args.tmpdir) + except EncoderNotInstalledError: + # try to play anyway if the encoder isn't installed, it cant hurt + print("Encoder not installed, playback may not be successful.") + except MediaParseError: + # we have encoders installed, but can't understand the file + # see if it's a non-video url and youtubedl can do it for us + urls = youtubedl(target) + + # nothing back? youtubedl doesn't know how to deal with it + if len(urls) == 0: + parser.exit("Unknown input format. Use --force if you are sure your AirPlay device an play it.") + + # If we got a single file back, and we can play it, we don't need to do anything + if len(urls) == 1 and ap.can_play(urls[0]): + target = urls[0] + else: + # multiple urls we need to mux them + target = ap.convert(urls, tmpdir=args.tmpdir) + + # if the resovled playback target is local, then we need to spin up + # a server to deliver it to the AirPlay device + # (if it's a list of files, then it's from the encoder and local) + if isinstance(target, list) or os.path.exists(target): + target = ap.serve(target)[0] + duration = 0 position = 0 state = 'loading' - path = args.path - - # if the url is on our local disk, then we need to spin up a server to start it - if os.path.exists(path): - path = ap.serve(path) - # play what they asked - ap.play(path, args.position) + ap.play(target, args.position) # stay in this loop until we exit with click.progressbar(length=100, show_eta=False) as bar: diff --git a/airplay/ffmpeg.py b/airplay/ffmpeg.py new file mode 100644 index 0000000..00ae536 --- /dev/null +++ b/airplay/ffmpeg.py @@ -0,0 +1,225 @@ +import subprocess +import tempfile +import os +import shutil +import json + + +class EncoderNotInstalledError(EnvironmentError): + """Raised if we can't find ffmpeg or ffprobe""" + pass + + +class MediaParseError(ValueError): + """Raised if ffprobe or ffmpeg cannot parse an input file""" + pass + + +class FFmpeg(object): + """Use ffmpeg / ffprobe to convert video files so they are suitable for + playback on an AirPlay device + """ + def __init__(self, ffmpeg='ffmpeg', ffprobe='ffprobe'): + """Ensure ffmpeg and ffprobe are both executable and the correct versions + + Args: + ffmpeg (str): The path to an ffmpeg binary. Defaults to looking for 'ffmpeg' in your path. + ffprobe (str): The path to an ffprobe binary. Defaults to looking for 'ffprobe' in your path. + + Raises: + EncoderNotInstalledError: ffmpeg / ffprobe were not executable or not the correct version + """ + + self.ffmpeg = ffmpeg + self.ffprobe = ffprobe + + self._test() + + def _run(self, cmd, quiet=True): + """Execute cmd with stderr redirected, and common exceptions converted + + Args: + cmd (list): This is passed directly to subprocess.check_output() + quiet (bool): If True, stderr output is supressed, if False, it will be included. + + Returns: + The stdout produced by `cmd`, and possibly the stderr (if quiet is False) + + Raises: + EncoderNotInstalledError: The `cmd[0]` command could not be executed. + subprocess.CalledProcessError: An error occurred while executing `cmd` + """ + try: + if quiet: + stderr = open(os.devnull, 'wb') + else: + stderr = subprocess.STDOUT + + return subprocess.check_output(cmd, stderr=stderr) + except KeyboardInterrupt: + return + except OSError: + raise EncoderNotInstalledError("Cannot execute {0}".format(cmd[0])) + finally: + try: + stderr.close() + except (NameError, AttributeError): + pass + + def probe(self, path): + """Probe `path` to determine it's file format + + Args: + path (str): An absolute path to a file or URL to probe + + Returns: + tuple: The container format and a list of streams. Like: + (u'mov,mp4,m4a,3gp,3g2,mj2', [(u'video', u'h264'), (u'audio', u'aac')]) + + Raises: + MediaParseError: Unable to parse the file + EncoderNotInstalledError: ffprobe could not be executed. + + """ + probe_opts = [ + '-print_format', 'json', # output JSON + '-v', 'quiet', # suppress all non-JSON output + '-show_format', # container information + '-show_streams', # track information + path + ] + + try: + output = self._run([self.ffprobe] + probe_opts) + + try: + output = str(output, 'UTF-8') + except TypeError: + pass + + info = json.loads( + output + ) + except (ValueError, subprocess.CalledProcessError): + raise MediaParseError("Unknown input format: {0}".format(path)) + + streams = [] + for ss in info['streams']: + streams.append((ss['codec_type'], ss['codec_name'])) + + return info['format']['format_name'], streams + + def segment(self, + paths, + output_directory=None, + index='airplay.m3u8', + transport_stream='airplay.ts', + options=[]): + """Create an HLS index and transport stream for `paths` in `output_directory + + Args: + paths (list): A list of one or more input files or URLs which will be combined + into the HLS segment. + output_directory (str): A path to a directory to write the HLS segments into. + If not specified tempfile.mkdtemp() will be used + index (str): The name of the HLS index file to create. + transport_stream (str): The name of the transport stream file to create + options (list): Additional options that will be passed to the ffmpeg process + + Returns + tuple (index, transport_stream): Absoulte paths to the index and transport stream files + + Raises: + ValueError: An output directory was given but does not exist + MediaParseError: Unable to parse one of the input paths + EncoderNotInstalledError: ffmpeg could not be executed or was not the correct version + """ + + if not isinstance(paths, list): + paths = [paths] + + if output_directory is None: + output_directory = tempfile.mkdtemp() + else: + if not os.path.exists(output_directory): + raise ValueError('{0} does not exist!'.format(output_directory)) + + index = os.path.join(output_directory, index) + transport_stream = os.path.join(output_directory, transport_stream) + + inputs = [] + + # convert our paths from 'foo' to '-i foo' + for ii in paths: + # hack to allow specifying internal inputs to ffmpeg + # we only really support this for the _test function + if ii.startswith('LAVFI-'): + ff, ii = ii.split('-', 1) + + inputs += ['-f', ff.lower()] + ii = ii.lower() + + inputs += ['-i', ii] + + ffmpeg_opts = inputs + [ + '-hls_flags', 'single_file', # write all data to one .ts file + '-hls_list_size', '0', # infinite segment list size + '-hls_allow_cache', '1', # allow client caching + '-hls_segment_filename', transport_stream # where to write the video + ] + options + + try: + self._run([self.ffmpeg] + ffmpeg_opts + [index], quiet=False) + except subprocess.CalledProcessError as exc: + # ffmpeg always exits with code 1 if error happened + # this is our only hint it was a problem with the file + emsg = 'Invalid data found when processing input' + try: + emsg = bytes(emsg, 'UTF-8') + except TypeError: + pass + if emsg in exc.output: + raise MediaParseError("Unknown input format: {0}".format(paths)) + else: + raise EncoderNotInstalledError("{0} failed. It must be at least version 3.0.".format(self.ffmpeg)) + + return index, transport_stream + + def _test(self): + """Self test. Ensure the given ffmpeg and ffprobe binaries are executable and + produce the output we expect + + Returns: + True: Everything is good to go. + + Raises: + EncoderNotInstalledError: One of the provided binaries is not executable or + an incorrect version + """ + try: + # segment one frame of an internal source + work_dir = tempfile.mkdtemp() + index, transport_stream = self.segment( + ['LAVFI-TESTSRC', 'LAVFI-ANULLSRC'], + output_directory=work_dir, + options=['-vframes', '1'] + ) + + # make sure we can inspect it + container, streams = self.probe(transport_stream) + + # and that it's the format we expect + assert container == 'mpegts' + assert streams[0][1] == 'h264' + assert streams[1][1] == 'aac' + except MediaParseError: + raise EncoderNotInstalledError('ffmpeg/ffprobe must be at least version 3.0') + except AssertionError: + raise AssertionError( + 'Unexpected file format issues, please report this at ' + 'https://github.com/cnelson/python-airplay/issues{0}{1}{2}'.format(os.linesep, container, streams) + ) + finally: + shutil.rmtree(work_dir) + + return True diff --git a/airplay/http_server.py b/airplay/http_server.py index 5a4c875..8fecf29 100644 --- a/airplay/http_server.py +++ b/airplay/http_server.py @@ -41,12 +41,13 @@ class RangeHTTPServer(BaseHTTPRequestHandler): It supports *single* Range requests which is all (it seems) is required. """ @classmethod - def start(cls, filename, allowed_host=None, queue=None): + def start(cls, paths=[], allowed_host=None, queue=None): """Start a SocketServer.TCPServer using this class to handle requests Args: - filename(str): An absolute path to a single file to server - Access will only be granted to this file + paths(list): A list of abosolute paths to files to serve. + Only access to these files will be allowed. + Directories are not permitted. allowed_host(str, optional): If provided, only this host will be allowed to access the server @@ -54,11 +55,65 @@ def start(cls, filename, allowed_host=None, queue=None): queue(Queue.Queue, optional): If provided, the host/port the server binds to will be put() into this queue + Raises: + ValueError: There was an issue with the provided paths + """ - os.chdir(os.path.dirname(filename)) + + allowed_files = {} + + for fn in list(set(paths)): + fn = os.path.realpath(fn) + + if os.path.isdir(fn): + raise ValueError("Directories cannot be served. {0}".format(fn)) + + bn = os.path.basename(fn) + + if bn in allowed_files: + raise ValueError('Cannot serve two files with the same name in different directories') + # If you are reading this, we've clearly made an invalid assumption. Let's get you + # caught up: + + # We have a couple of critical requirements for this server: + + # Requirement 1 (obviously): Don't serve anything except the files the caller + # specifically allows. We really don't want to start a buggy server that ends up + # being responsible for a data exfil when all we are supposed to do is play video! + + # Requirement 2: Expose as little information to the AirPlay server as possible. + # In many cases we blindly connect to whomeever is announcing AirPlay services via + # Bonjour so we must assume the AirPlay device is an attacker. + + # Knowing this, we don't want to take the easy approach and have our URLs be: + # http://:/ where path is the abosolute path to the file we want + # want to serve. While we have code that protects from an attacker accessing any + # file via a request to http://host/etc/passwd (for example) we don't want to expose + # any information about our file system layout if we can help it. + + # My first thought to serve this was to send the AirPlay server hashes and then look + # up the the real path to the file and serve it. In psuedo-y code: + + # allowed_filenames[hash(fn)] = fn + # URL == http://:/ + + # However when we need to serve HLS segments this breaks as ffmpeg generates the + # index for us, and while we can control the filenames it uses, we can't get it to + # write the ts file to 'foo.ts', but write hash('foo.ts') to the index. + + # So, here we are with this janky solution which should have worked since we only + # planned on serving a single video file, or two HLS files with names that will be + # different. + + # Given that you are reading this, the above is now probably an invalid assumption + # So, do you have a better idea? Hit me up: + + # https://github.com/cnelson/python-airplay/issues + + allowed_files[bn] = fn httpd = SocketServer.TCPServer(('', 0), cls) - httpd.allowed_filename = os.path.realpath(filename) + httpd.allowed_filenames = allowed_files httpd.allowed_host = allowed_host if queue: @@ -184,22 +239,16 @@ def check_path(self, path): ValueError: The path could not be accessed (exception will say why) """ - # get full path to file requested - path = posixpath.normpath(unquote(path)) - path = os.path.join(os.getcwd(), path.lstrip('/')) - # if we have an allowed host, then only allow access from it if self.server.allowed_host and self.client_address[0] != self.server.allowed_host: self.send_error(400, "Bad Request") raise ValueError('Client is not allowed') - # don't do directory indexing - if os.path.isdir(path): - self.send_error(400, "Bad Request") - raise ValueError("Requested path is a directory") - - # if they try to request something else, don't serve it - if path != self.server.allowed_filename: + # get full path to file requested + path = posixpath.normpath(unquote(path)).lstrip('/') + try: + path = self.server.allowed_filenames[path] + except KeyError: self.send_error(400, "Bad Request") raise ValueError("Requested path was not in the allowed list") diff --git a/airplay/tests.py b/airplay/tests.py index 5d5f310..f8825e2 100644 --- a/airplay/tests.py +++ b/airplay/tests.py @@ -1,5 +1,6 @@ import email import os +import shutil import socket import tempfile import time @@ -24,6 +25,8 @@ from .airplay import FakeSocket, AirPlayEvent, AirPlay, RangeHTTPServer +from .ffmpeg import FFmpeg, EncoderNotInstalledError, MediaParseError + class TestFakeSocket(unittest.TestCase): def test_socket(self): @@ -414,14 +417,14 @@ def test_scrub_pos(self): class TestAirPlayDiscovery(unittest.TestCase): - @patch('airplay.airplay.socket.socket') + @patch('airplay.airplay.socket.socket', new_callable=lambda: MockSocket) @patch('airplay.airplay.ServiceBrowser', new_callable=lambda: FakeServiceBrowser) @patch('airplay.airplay.Zeroconf', new_callable=lambda: FakeZeroconf) def test_timeout(self, zc, sb, sock): """When fast=False, find() always waits for the timeout to expire""" sb.name = 'test-device.foo.bar' - sb.info = zc.info = Mock(address=socket.inet_aton('192.0.2.23'), port=916) + sb.info = zc.info = Mock(address=socket.inet_aton('127.0.0.1'), port=916) start = time.time() devices = AirPlay.find(timeout=2, fast=False) @@ -432,14 +435,14 @@ def test_timeout(self, zc, sb, sock): assert devices[0].host == socket.inet_ntoa(zc.info.address) assert devices[0].port == zc.info.port - @patch('airplay.airplay.socket.socket') + @patch('airplay.airplay.socket.socket', new_callable=lambda: MockSocket) @patch('airplay.airplay.ServiceBrowser', new_callable=lambda: FakeServiceBrowser) @patch('airplay.airplay.Zeroconf', new_callable=lambda: FakeZeroconf) def test_fast_results(self, zc, sb, sock): """When fast=True find() returns as soon as there is a result""" sb.name = 'test-short' - sb.info = zc.info = Mock(address=socket.inet_aton('192.0.2.23'), port=916) + sb.info = zc.info = Mock(address=socket.inet_aton('127.0.0.1'), port=916) start = time.time() devices = AirPlay.find(timeout=2, fast=True) @@ -450,7 +453,7 @@ def test_fast_results(self, zc, sb, sock): assert devices[0].host == socket.inet_ntoa(zc.info.address) assert devices[0].port == zc.info.port - @patch('airplay.airplay.socket.socket') + @patch('airplay.airplay.socket.socket', new_callable=lambda: MockSocket) @patch('airplay.airplay.ServiceBrowser', new_callable=lambda: FakeServiceBrowser) @patch('airplay.airplay.Zeroconf') def test_no_info(self, zc, sb, sock): @@ -465,18 +468,72 @@ def test_no_info(self, zc, sb, sock): assert len(devices) == 0 +class TestRangeHTTPServerStartUp(unittest.TestCase): + def test_no_directories(self): + """ValueError is raised if directory serving is attempted""" + + try: + tempdir = tempfile.mkdtemp() + + def go(): + RangeHTTPServer.start([tempdir]) + + self.assertRaises(ValueError, go) + finally: + os.rmdir(tempdir) + + def test_no_files_with_same_name(self): + """ValueError is raised if two files with same name are served""" + + try: + tempdir1 = tempfile.mkdtemp() + tempdir2 = tempfile.mkdtemp() + + tempfn1 = os.path.join(tempdir1, 'foo.txt') + tempfn2 = os.path.join(tempdir2, 'foo.txt') + + with open(tempfn1, 'w') as fh1: + with open(tempfn2, 'w') as fh2: + fh1.write('foo') + fh2.write('foo') + + def go(): + RangeHTTPServer.start([tempfn1, tempfn2]) + + self.assertRaises(ValueError, go) + finally: + os.remove(tempfn1) + os.remove(tempfn2) + + os.rmdir(tempdir1) + os.rmdir(tempdir2) + + class TestRangeHTTPServerACL(unittest.TestCase): def setUp(self): + # generate two test files self.data = b'abcdefghijklmnopqrstuvwxyz' * 1024 + fd, path = tempfile.mkstemp() os.write(fd, self.data) os.close(fd) - self.testfile = path + self.testone = path + self.pathone = '/' + os.path.basename(self.testone) - os.chdir(os.path.dirname(self.testfile)) + fd, path = tempfile.mkstemp() + os.write(fd, self.data) + os.close(fd) + self.testtwo = path + self.pathtwo = '/' + os.path.basename(self.testtwo) - self.path = '/' + os.path.basename(self.testfile) + fn1 = os.path.realpath(self.testone) + fn2 = os.path.realpath(self.testtwo) + + self.allowed_filenames = { + os.path.basename(fn1): fn1, + os.path.basename(fn2): fn2, + } self.server = Mock() @@ -491,7 +548,8 @@ def fake_request(self, path): def tearDown(self): try: - os.remove(self.testfile) + os.remove(self.testone) + os.remove(self.testtwo) except OSError: pass @@ -500,16 +558,7 @@ def test_allowed_host(self): self.server = Mock(allowed_host='192.0.2.99') - self.assertRaises(ValueError, self.fake_request, self.path) - - self.http.send_error.assert_called_with(400, 'Bad Request') - - def test_no_directories(self): - """ValueError is raised if directory access is attempted""" - - self.server = Mock(allowed_host='127.0.0.1') - - self.assertRaises(ValueError, self.fake_request, '/') + self.assertRaises(ValueError, self.fake_request, self.pathone) self.http.send_error.assert_called_with(400, 'Bad Request') @@ -517,13 +566,15 @@ def test_allowed_filename(self): """ValueError is raised if any other files are requested""" self.server = Mock( - allowed_filename=os.path.realpath(self.testfile), + allowed_filenames=self.allowed_filenames, allowed_host='127.0.0.1' ) - result = self.fake_request(self.path) + result = self.fake_request(self.pathone) + assert result[0] in self.allowed_filenames.values() - assert result[0] == self.server.allowed_filename + result = self.fake_request(self.pathtwo) + assert result[0] in self.allowed_filenames.values() self.assertRaises(ValueError, self.fake_request, '/../../../../../.././etc/passwd') self.http.send_error.assert_called_with(400, 'Bad Request') @@ -535,18 +586,18 @@ def test_file_open(self): """ValueError is raised if we cannot open or stat the file""" self.server = Mock( - allowed_filename=os.path.realpath(self.testfile), + allowed_filenames=self.allowed_filenames, allowed_host='127.0.0.1' ) # can't open file - os.chmod(self.testfile, 0000) - self.assertRaises(ValueError, self.fake_request, self.path) + os.chmod(self.testone, 0000) + self.assertRaises(ValueError, self.fake_request, self.pathone) self.http.send_error.assert_called_with(500, 'Internal Server Error') # file doesn't exist - os.remove(self.testfile) - self.assertRaises(ValueError, self.fake_request, self.path) + os.remove(self.testtwo) + self.assertRaises(ValueError, self.fake_request, self.pathtwo) self.http.send_error.assert_called_with(500, 'Internal Server Error') @@ -574,7 +625,7 @@ def no_check_path(self, *args, **kwargs): with patch('airplay.airplay.RangeHTTPServer.check_path', side_effect=no_check_path): self.ap = AirPlay('127.0.0.1', 916, 'test') - self.test_url = self.ap.serve(path) + self.test_url = self.ap.serve(path)[0] assert self.test_url.startswith('http://127.0.0.1') @@ -668,7 +719,7 @@ def setUp(self, mock): os.write(fd, self.data) os.close(fd) - self.test_url = self.ap.serve(path) + self.test_url = self.ap.serve(path)[0] assert self.test_url.startswith('http://127.0.0.1') @@ -757,6 +808,304 @@ def test_head(self): assert int(msg['content-length']) == len(self.data) +class TestAirPlayEncoder(unittest.TestCase): + @patch('airplay.airplay.socket.socket', new_callable=lambda: MockSocket) + def setUp(self, sockmock): + self.ap = AirPlay('127.0.0.1') + + @patch('airplay.airplay.FFmpeg') + def test_lazy_encoder(self, ffmock): + """If encoder is not specified, then FFmpeg is returned""" + + assert self.ap.encoder == ffmock() + assert self.ap.encoder == self.ap._encoder + + def test_lazy_encoder_manual(self): + """If encoder is specified, it is returned for each call made""" + + lol = Mock() + self.ap.encoder = lol + + assert self.ap.encoder == lol + assert self.ap.encoder == self.ap._encoder + + @patch('airplay.airplay.FFmpeg') + def test_can_play_good(self, ffmock): + """can_play returns true for mp4(h264, aac) files""" + + ffmock.probe.return_value = (u'mov,mp4,m4a,3gp,3g2,mj2', [(u'video', u'h264'), (u'audio', u'aac')]) + self.ap.encoder = ffmock + + assert self.ap.can_play('good.mp4') is True + + @patch('airplay.airplay.FFmpeg') + def test_can_play_bad_format(self, ffmock): + """any other format causes can_play to return false""" + + ffmock.probe.return_value = (u'avi', [(u'video', u'mpeg4'), (u'audio', u'mp3')]) + self.ap.encoder = ffmock + + assert self.ap.can_play('bad.mp4') is False + + @patch('airplay.airplay.FFmpeg') + def test_can_play_missing_track(self, ffmock): + """An empty container returns false""" + + ffmock.probe.return_value = (u'mov,mp4,m4a,3gp,3g2,mj2', []) + self.ap.encoder = ffmock + + assert self.ap.can_play('empty.mp4') is False + + @patch('airplay.airplay.FFmpeg') + def test_can_play_bad_track_order(self, ffmock): + """If video track is not the first track can_play returns false""" + ffmock.probe.return_value = (u'mov,mp4,m4a,3gp,3g2,mj2', [(u'audio', u'aac'), (u'video', u'h264')]) + self.ap.encoder = ffmock + + assert self.ap.can_play('weird_order.mp4') is False + + @patch('airplay.airplay.FFmpeg') + def test_can_play_multiple_audio_tracks(self, ffmock): + """Multiple audio tracks are ok, as long as one is aac""" + + ffmock.probe.return_value = (u'mov,mp4,m4a,3gp,3g2,mj2', [(u'video', u'h264'), (u'audio', u'ac3'), (u'audio', u'aac')]) # NOQA + self.ap.encoder = ffmock + + assert self.ap.can_play('multitrack.mp4') is True + + @patch('airplay.airplay.FFmpeg') + def test_convert_uses_our_directory(self, ffmock): + """convert uses the tempdir given to it""" + + work_dir = tempfile.mkdtemp() + + def segment(input, work_dir): + with open(os.path.join(work_dir, 'airplay.m3u8'), 'w') as fh: + fh.write('foo') + + ffmock = ffmock() + ffmock.segment = segment + + self.ap.encoder = ffmock + + index, transport_stream = self.ap.convert('foo.avi', work_dir) + + assert index.startswith(work_dir) is True + assert index.endswith('airplay.m3u8') is True + + assert transport_stream.startswith(work_dir) is True + assert transport_stream.endswith('airplay.ts') + +# actually test ffmpeg if we have it installed +try: + real_ffmpeg = FFmpeg() +except: + real_ffmpeg = None + + +class TestFFmpeg(unittest.TestCase): + def setUp(self): + self.work_dir = tempfile.mkdtemp() + + self.mock_ffp = self.write_exe( + 'good_ffprobe', + """#!/bin/sh\n echo '{"streams": [{"codec_name": "h264", "codec_type": "video"}, {"codec_name": "aac", "codec_type": "audio"}], "format": {"format_name": "mpegts"}}'""" # NOQA + ) + + def write_exe(self, filename, contents): + + fn = os.path.join(self.work_dir, filename) + + with open(fn, 'w') as fh: + fh.write(contents) + + os.chmod(fn, 0o0700) + + return fn + + def tearDown(self): + shutil.rmtree(self.work_dir) + + def test_bad_path_ffmpeg(self): + """if ffmpeg does not exist, EncoderNotInstalledError is raised""" + def go(): + FFmpeg(ffmpeg=os.path.join(self.work_dir, 'ffmpeg'), ffprobe='true') + + self.assertRaises(EncoderNotInstalledError, go) + + def test_bad_path_ffprobe(self): + """if probe does not exist, EncoderNotInstalledError is raised""" + def go(): + FFmpeg(ffmpeg='true', ffprobe=os.path.join(self.work_dir, 'ffprobe')) + + self.assertRaises(EncoderNotInstalledError, go) + + def test_old_ffmpeg(self): + """If ffmpeg returns 1 during the test, EncoderNotInstalledError is raised""" + def go(): + FFmpeg(ffmpeg='false', ffprobe='true') + + self.assertRaises(EncoderNotInstalledError, go) + + def test_old_ffprobe(self): + """If ffprobe returns 1 during the test, MediaParseError is raised""" + def go(): + FFmpeg(ffmpeg='true', ffprobe='false') + + self.assertRaises(EncoderNotInstalledError, go) + + def test_bad_encoder(self): + """If ffmpeg exists, but outputs unexpected info, we bail with details""" + + ffp = self.write_exe( + 'fake_ffprobe', + """#!/bin/sh\n echo '{"streams": [{"codec_name": "h264", "codec_type": "video"}, {"codec_name": "invalid", "codec_type": "audio"}], "format": {"format_name": "mpegts"}}'""" # NOQA + ) + + def go(): + FFmpeg(ffmpeg='true', ffprobe=ffp) + + self.assertRaises(AssertionError, go) + + def test_run_quiet(self): + """When _run is called with quiet=True no stderr is produced""" + + noisy = self.write_exe( + 'noisy.sh', + """#!/bin/sh\n>&2 echo stderr\necho stdout""" + ) + + ff = FFmpeg(ffmpeg='true', ffprobe=self.mock_ffp) + + try: + check = bytes('stderr', 'UTF-8') + except TypeError: + check = 'stderr' + + assert check not in ff._run([noisy]) + + def test_run_loud(self): + """When _run is called with quiet=False, stderr is produced""" + + noisy = self.write_exe( + 'noisy.sh', + """#!/bin/sh\n>&2 echo stderr\necho stdout""" + ) + + ff = FFmpeg(ffmpeg='true', ffprobe=self.mock_ffp) + + try: + check = bytes('stderr', 'UTF-8') + except TypeError: + check = 'stderr' + + assert check in ff._run([noisy], quiet=False) + + def test_ffprobe_bad_file(self): + """When ffprobe returns an error, or invalid JSON, MediaParseError is raised""" + + ff = FFmpeg(ffmpeg='true', ffprobe=self.mock_ffp) + + ff.ffprobe = self.write_exe( + 'badfile_ffprobe', + """#!/bin/sh\n exit 1""" + ) + + def go(): + ff.probe('some-bad-file.dat') + + self.assertRaises(MediaParseError, go) + + def test_ffprobe_good_file(self): + """When ffprobe returns 0 and valid JSON a simplified object is returned""" + + ff = FFmpeg(ffmpeg='true', ffprobe=self.mock_ffp) + + container, streams = ff.probe('some-file.mp4') + + assert container == 'mpegts' + assert streams[0][0] == 'video' + assert streams[0][1] == 'h264' + + @unittest.skipIf(real_ffmpeg is None, "ffmpeg not installed") + def test_segment_single_file(self): + """A single file can be segmented""" + + try: + index, stream = real_ffmpeg.segment('LAVFI-TESTSRC', options=['-vframes', '1']) + + container, streams = real_ffmpeg.probe(stream) + finally: + os.remove(index) + os.remove(stream) + + assert container == 'mpegts' + assert len(streams) == 1 + + @unittest.skipIf(real_ffmpeg is None, "ffmpeg not installed") + def test_segment_multiple_files(self): + """Multiple files can be segmented""" + + try: + index, stream = real_ffmpeg.segment( + ['LAVFI-TESTSRC', 'LAVFI-ANULLSRC'], + options=['-vframes', '1'] + ) + + container, streams = real_ffmpeg.probe(stream) + finally: + os.remove(index) + os.remove(stream) + + assert container == 'mpegts' + assert len(streams) == 2 + + @unittest.skipIf(real_ffmpeg is None, "ffmpeg not installed") + def test_segment_output_opts(self): + """If specified, we can control the output dir and file names used""" + index, stream = real_ffmpeg.segment( + ['LAVFI-TESTSRC', 'LAVFI-ANULLSRC'], + output_directory=self.work_dir, + index='lol.m3u8', + transport_stream='haha.ts', + options=['-vframes', '1'] + ) + + container, streams = real_ffmpeg.probe(stream) + + assert container == 'mpegts' + assert len(streams) == 2 + + assert index.startswith(self.work_dir) + assert index.endswith('lol.m3u8') + assert stream.startswith(self.work_dir) + assert stream.endswith('haha.ts') + + @unittest.skipIf(real_ffmpeg is None, "ffmpeg not installed") + def test_segment_invalid_input(self): + """If an invalid input is provided, MediaParseError is raised""" + def go(): + index, stream = real_ffmpeg.segment( + ['LAVFI-TESTSRC', '/dev/null'], + output_directory=self.work_dir, + options=['-vframes', '1'] + ) + + self.assertRaises(MediaParseError, go) + + def test_segment_invalid_output_dir(self): + """If an invalid output directory is provided, ValueError is raised""" + + def go(): + index, stream = real_ffmpeg.segment( + 'LAVFI-TESTSRC', + output_directory=os.path.join(self.work_dir, 'non-existant'), + options=['-vframes', '1'] + ) + + self.assertRaises(ValueError, go) + + class FakeZeroconf(object): def __init__(self, info=None): self.info = info @@ -820,7 +1169,7 @@ def settimeout(self, *args, **kwargs): pass def getpeername(self, *args, **kwargs): - return ('192.0.2.23', 9160) + return ('127.0.0.1', 9160) def getsockname(self, *args, **kwargs): return ('127.0.0.1', 9160) diff --git a/setup.py b/setup.py index fa9585e..90bb27e 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='airplay', - version='0.0.5', + version='0.0.6', description='A python client for AirPlay video', @@ -33,6 +33,7 @@ install_requires=[ 'zeroconf', 'click', + 'youtube-dl' ], tests_require=[