From a83f20601d561b94a6f7bb2e79d05d2f0c4addc6 Mon Sep 17 00:00:00 2001 From: cnelson Date: Sat, 27 Feb 2016 10:09:12 -0800 Subject: [PATCH 01/11] updated RangeHTTPServer to support serving multiple files --- airplay/airplay.py | 2 +- airplay/http_server.py | 81 +++++++++++++++++++++++++++++-------- airplay/tests.py | 92 ++++++++++++++++++++++++++++++++---------- 3 files changed, 136 insertions(+), 39 deletions(-) diff --git a/airplay/airplay.py b/airplay/airplay.py index 72006cf..b54286a 100644 --- a/airplay/airplay.py +++ b/airplay/airplay.py @@ -419,7 +419,7 @@ def serve(self, path): """ q = Queue() - self._http_server = Process(target=RangeHTTPServer.start, args=(path, self.host, q)) + self._http_server = Process(target=RangeHTTPServer.start, args=([path], self.host, q)) self._http_server.start() atexit.register(lambda: self._http_server.terminate()) 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..a7a7420 100644 --- a/airplay/tests.py +++ b/airplay/tests.py @@ -465,18 +465,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) + + fd, path = tempfile.mkstemp() + os.write(fd, self.data) + os.close(fd) + self.testtwo = path + self.pathtwo = '/' + os.path.basename(self.testtwo) - os.chdir(os.path.dirname(self.testfile)) + fn1 = os.path.realpath(self.testone) + fn2 = os.path.realpath(self.testtwo) - self.path = '/' + os.path.basename(self.testfile) + self.allowed_filenames = { + os.path.basename(fn1): fn1, + os.path.basename(fn2): fn2, + } self.server = Mock() @@ -491,7 +545,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 +555,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 +563,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 +583,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') From 8f096d7a460ec829a7a842548e0e3162525ded79 Mon Sep 17 00:00:00 2001 From: cnelson Date: Wed, 2 Mar 2016 11:13:07 -0800 Subject: [PATCH 02/11] Rough draft of an ffmpeg controller --- airplay/ffmpeg.py | 223 ++++++++++++++++++++++++++++++++++++++++++++++ airplay/tests.py | 93 +++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 airplay/ffmpeg.py diff --git a/airplay/ffmpeg.py b/airplay/ffmpeg.py new file mode 100644 index 0000000..47afd6e --- /dev/null +++ b/airplay/ffmpeg.py @@ -0,0 +1,223 @@ +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 OSError: + raise EncoderNotInstalledError("Cannot execute {0}".format(cmd[0])) + finally: + try: + DEVNULL.close() + except NameError: + 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} 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/tests.py b/airplay/tests.py index a7a7420..8de6fae 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): @@ -805,6 +808,96 @@ def test_head(self): assert int(msg['content-length']) == len(self.data) +class TestFFmpeg(unittest.TestCase): + def setUp(self): + self.work_dir = tempfile.mkdtemp() + + 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""" + + # TODO: make this work on windows + FAKE_FFPROBE = """#!/bin/sh\n echo '{"streams": [{"codec_name": "h264", "codec_type": "video"}, {"codec_name": "invalid", "codec_type": "audio"}], "format": {"format_name": "mpegts"}}'""" # NOQA + + ffp = os.path.join(self.work_dir, 'fake_ffprobe') + + with open(ffp, 'w') as fh: + fh.write(FAKE_FFPROBE) + + os.chmod(ffp, 0o0700) + + 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""" + pass + + def test_run_loud(self): + """When _run is called with quiet=Flse, stderr is produced""" + pass + + def test_ffprobe_bad_file(self): + """When ffprobe returns an error, or invalid JSON, MediaParseError is raised""" + pass + + def test_ffprobe_good_file(self): + """When ffprobe returns 0 and valid JSON a simplified object is returned""" + pass + + def test_segment_single_file(self): + """A single file can be segmented""" + pass + + def test_segment_multiple_files(self): + """Multiple files can be segmented""" + pass + + def test_segment_output_opts(self): + """If specified, we can control the output dir and file names used""" + pass + + def test_segment_invalid_input(self): + """If an invalid input is provided, MediaParseError is raised""" + pass + + def test_segment_invalid_output_dir(self): + """If an invalid output directory is provided, ValueError is raised""" + pass + + class FakeZeroconf(object): def __init__(self, info=None): self.info = info From 60c3ed973cfbb9d97e9c5d058c66a916c830eaba Mon Sep 17 00:00:00 2001 From: cnelson Date: Fri, 4 Mar 2016 08:32:27 -0800 Subject: [PATCH 03/11] integrated FFmpeg controller with the AirPlay class, and the cli interface --- airplay/__init__.py | 1 + airplay/airplay.py | 105 +++++++++++++++++++++++++++++++++++++++++--- airplay/cli.py | 55 +++++++++++++++++++---- airplay/ffmpeg.py | 4 +- airplay/tests.py | 4 +- 5 files changed, 150 insertions(+), 19 deletions(-) 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 b54286a..f67a141 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` @@ -121,6 +126,18 @@ def __init__(self, host, port=7000, name=None, timeout=5): except socket.error as exc: raise ValueError("Unable to connect to {0}:{1}: {2}".format(host, port, exc)) + @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 +212,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 +424,104 @@ 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. + + 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: + path (str): A path to a file suitable for passing to serve() + + 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() + + atexit.register(shutil.rmtree, 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..e9b68d0 100644 --- a/airplay/cli.py +++ b/airplay/cli.py @@ -2,7 +2,7 @@ import os import time -from airplay import AirPlay +from airplay import AirPlay, FFmpeg, MediaParseError, EncoderNotInstalledError import click @@ -45,7 +45,8 @@ 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." + "so the file must already be suitable for the AirPlay device.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter ) parser.add_argument( @@ -69,6 +70,24 @@ 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)' + ) args = parser.parse_args() @@ -78,18 +97,36 @@ def main(): except (ValueError, RuntimeError) as exc: parser.error(exc) - duration = 0 - position = 0 - state = 'loading' + target = args.path + + if not args.force: + # if they gave us custom paths to an encoder, then die if they + # are wrong + 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) - path = args.path + try: + if not ap.can_play(target): + target = ap.convert(target) + target = list(target) + except EncoderNotInstalledError: + print("Encoder not installed, skipping conversion") + except MediaParseError: + parser.exit("Unkonwn input format. Use --force if you are sure your AirPlay server can play it") # 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) + if isinstance(target, list) or os.path.exists(target): + target = ap.serve(target)[0] + + duration = 0 + position = 0 + state = 'loading' # 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 index 47afd6e..e446831 100644 --- a/airplay/ffmpeg.py +++ b/airplay/ffmpeg.py @@ -56,6 +56,8 @@ def _run(self, cmd, quiet=True): stderr = subprocess.STDOUT return subprocess.check_output(cmd, stderr=stderr) + except KeyboardInterrupt: + return except OSError: raise EncoderNotInstalledError("Cannot execute {0}".format(cmd[0])) finally: @@ -179,7 +181,7 @@ def segment(self, if emsg in exc.output: raise MediaParseError("Unknown input format: {0}".format(paths)) else: - raise EncoderNotInstalledError("{0} must be at least version 3.0.".format(self.ffmpeg)) + raise EncoderNotInstalledError("{0} failed. It must be at least version 3.0.".format(self.ffmpeg)) return index, transport_stream diff --git a/airplay/tests.py b/airplay/tests.py index 8de6fae..69f6282 100644 --- a/airplay/tests.py +++ b/airplay/tests.py @@ -625,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') @@ -719,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') From 074b16ca018fce3e1b2cb3fffbc24eec44734b7b Mon Sep 17 00:00:00 2001 From: cnelson Date: Fri, 4 Mar 2016 11:12:08 -0800 Subject: [PATCH 04/11] added tests for encoding support --- airplay/ffmpeg.py | 4 +- airplay/tests.py | 246 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 229 insertions(+), 21 deletions(-) diff --git a/airplay/ffmpeg.py b/airplay/ffmpeg.py index e446831..00ae536 100644 --- a/airplay/ffmpeg.py +++ b/airplay/ffmpeg.py @@ -62,8 +62,8 @@ def _run(self, cmd, quiet=True): raise EncoderNotInstalledError("Cannot execute {0}".format(cmd[0])) finally: try: - DEVNULL.close() - except NameError: + stderr.close() + except (NameError, AttributeError): pass def probe(self, path): diff --git a/airplay/tests.py b/airplay/tests.py index 69f6282..01de692 100644 --- a/airplay/tests.py +++ b/airplay/tests.py @@ -808,10 +808,121 @@ 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) @@ -846,15 +957,10 @@ def go(): def test_bad_encoder(self): """If ffmpeg exists, but outputs unexpected info, we bail with details""" - # TODO: make this work on windows - FAKE_FFPROBE = """#!/bin/sh\n echo '{"streams": [{"codec_name": "h264", "codec_type": "video"}, {"codec_name": "invalid", "codec_type": "audio"}], "format": {"format_name": "mpegts"}}'""" # NOQA - - ffp = os.path.join(self.work_dir, 'fake_ffprobe') - - with open(ffp, 'w') as fh: - fh.write(FAKE_FFPROBE) - - os.chmod(ffp, 0o0700) + 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) @@ -863,39 +969,141 @@ def go(): def test_run_quiet(self): """When _run is called with quiet=True no stderr is produced""" - pass + + 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=Flse, stderr is produced""" - pass + """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""" - pass + + 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""" - pass + 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""" - pass + 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""" - pass + 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""" - pass + 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""" - pass + 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""" - pass + + 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): From 8cfc89b397eeea4ec01b9770e758d042d2c5d0b3 Mon Sep 17 00:00:00 2001 From: cnelson Date: Fri, 4 Mar 2016 11:15:23 -0800 Subject: [PATCH 05/11] added ability to set tempdir from cli interface --- airplay/cli.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/airplay/cli.py b/airplay/cli.py index e9b68d0..9d1d09a 100644 --- a/airplay/cli.py +++ b/airplay/cli.py @@ -2,7 +2,9 @@ import os import time -from airplay import AirPlay, FFmpeg, MediaParseError, EncoderNotInstalledError +from .airplay import AirPlay + +from .ffmpeg import FFmpeg, MediaParseError, EncoderNotInstalledError import click @@ -89,6 +91,12 @@ def main(): 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() # connect to the AirPlay device we want to control @@ -110,7 +118,7 @@ def main(): try: if not ap.can_play(target): - target = ap.convert(target) + target = ap.convert(target, tmpdir=args.tmpdir) target = list(target) except EncoderNotInstalledError: print("Encoder not installed, skipping conversion") From 64c93d7737f3a0d518df493ea6266a30d6a09dc2 Mon Sep 17 00:00:00 2001 From: cnelson Date: Sun, 6 Mar 2016 10:06:08 -0800 Subject: [PATCH 06/11] fixed a bug with serve() when a hostname was given for the AirPlay device --- airplay/airplay.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/airplay/airplay.py b/airplay/airplay.py index f67a141..3b8e95b 100644 --- a/airplay/airplay.py +++ b/airplay/airplay.py @@ -123,6 +123,8 @@ 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)) @@ -463,7 +465,8 @@ def convert(self, paths, tmpdir=None): If not specified tempfile.mkdtemp() will be used Returns: - path (str): A path to a file suitable for passing to serve() + 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 @@ -485,7 +488,7 @@ def convert(self, paths, tmpdir=None): while not os.path.exists(index) and self._converter.is_alive(): time.sleep(.1) - return index, transport_stream + return [index, transport_stream] def can_play(self, path): """Use the encoder to inspect the file and see if we can play it From 75d421cd00bd11080266db1713b0e7bd217b7258 Mon Sep 17 00:00:00 2001 From: cnelson Date: Sun, 6 Mar 2016 10:08:22 -0800 Subject: [PATCH 07/11] added support for playing back video sites with youtubedl --- airplay/cli.py | 51 +++++++++++++++++++++++++++++++++++++++++++------- setup.py | 3 ++- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/airplay/cli.py b/airplay/cli.py index 9d1d09a..a304638 100644 --- a/airplay/cli.py +++ b/airplay/cli.py @@ -7,6 +7,7 @@ from .ffmpeg import FFmpeg, MediaParseError, EncoderNotInstalledError import click +import youtube_dl def get_airplay_device(hostport): @@ -43,6 +44,23 @@ def humanize_seconds(secs): return "%02d:%02d:%02d" % (h, m, s) +def youtubedl(target, fmt='bestvideo+bestaudio/best'): + urls = [] + try: + ydl = youtube_dl.YoutubeDL({'format': fmt}) + info = ydl.extract_info(target, download=False) + + 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. " @@ -105,11 +123,13 @@ 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: - # if they gave us custom paths to an encoder, then die if they - # are wrong + + # 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) @@ -117,15 +137,32 @@ def main(): 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) - target = list(target) except EncoderNotInstalledError: - print("Encoder not installed, skipping conversion") + # try to play anyway if the encoder isn't installed, it cant hurt + print("Encoder not installed, playback may not be successful.") except MediaParseError: - parser.exit("Unkonwn input format. Use --force if you are sure your AirPlay server can play it") - - # if the url is on our local disk, then we need to spin up a server to start it + # 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(args.target) + + # nothing back? youtubedl doesn't know how to deal with it + if len(urls) == 0: + parser.exit("Unkonwn input format. Use --force if you are sure your AirPlay device an play it.") + + # If we got a single file back, only convert if we can't play it + if len(urls) == 1 and not ap.can_play(urls[0]): + target = ap.convert(urls[0], tmpdir=args.tmpdir) + 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] 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=[ From 9b9f51ccb10a0dbbac62cee234d5a68b1b8d56df Mon Sep 17 00:00:00 2001 From: cnelson Date: Sun, 6 Mar 2016 10:26:06 -0800 Subject: [PATCH 08/11] fixed tests that depended on AirPlay().host not being modified --- airplay/tests.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/airplay/tests.py b/airplay/tests.py index 01de692..f8825e2 100644 --- a/airplay/tests.py +++ b/airplay/tests.py @@ -417,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) @@ -435,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) @@ -453,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): @@ -1169,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) From f0cc0bcac47899f3912c7013529142ccb9c05c9a Mon Sep 17 00:00:00 2001 From: cnelson Date: Sun, 6 Mar 2016 14:06:19 -0800 Subject: [PATCH 09/11] updated documentation with the FFMpeg related methods --- README.md | 106 +++++++++++++++++++++++++++++++++++++++++---- airplay/airplay.py | 10 ++--- airplay/cli.py | 8 ++-- 3 files changed, 108 insertions(+), 16 deletions(-) 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/airplay.py b/airplay/airplay.py index 3b8e95b..9ad0b56 100644 --- a/airplay/airplay.py +++ b/airplay/airplay.py @@ -458,11 +458,11 @@ def convert(self, paths, tmpdir=None): """Start a encoder process to convert `path` to a version that can be played on an AirPlay device. - 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 + 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 diff --git a/airplay/cli.py b/airplay/cli.py index a304638..145da87 100644 --- a/airplay/cli.py +++ b/airplay/cli.py @@ -64,8 +64,10 @@ def youtubedl(target, fmt='bestvideo+bestaudio/best'): 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 ) @@ -147,7 +149,7 @@ def main(): 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(args.target) + urls = youtubedl(target) # nothing back? youtubedl doesn't know how to deal with it if len(urls) == 0: From 73a0d9000e40db7cf1e8544357ea0351e232b75e Mon Sep 17 00:00:00 2001 From: cnelson Date: Sun, 6 Mar 2016 17:01:20 -0800 Subject: [PATCH 10/11] ask youtube-dl for a streamable file rather than best by default --- airplay/airplay.py | 8 +++++++- airplay/cli.py | 13 ++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/airplay/airplay.py b/airplay/airplay.py index 9ad0b56..8b218ff 100644 --- a/airplay/airplay.py +++ b/airplay/airplay.py @@ -481,7 +481,13 @@ def convert(self, paths, tmpdir=None): self._converter = Process(target=self.encoder.segment, args=(paths, work_dir)) self._converter.start() - atexit.register(shutil.rmtree, work_dir) + 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 diff --git a/airplay/cli.py b/airplay/cli.py index 145da87..8a54a2c 100644 --- a/airplay/cli.py +++ b/airplay/cli.py @@ -44,12 +44,15 @@ def humanize_seconds(secs): return "%02d:%02d:%02d" % (h, m, s) -def youtubedl(target, fmt='bestvideo+bestaudio/best'): +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: @@ -153,11 +156,11 @@ def main(): # nothing back? youtubedl doesn't know how to deal with it if len(urls) == 0: - parser.exit("Unkonwn input format. Use --force if you are sure your AirPlay device an play it.") + parser.exit("Unknown input format. Use --force if you are sure your AirPlay device an play it.") - # If we got a single file back, only convert if we can't play it - if len(urls) == 1 and not ap.can_play(urls[0]): - target = ap.convert(urls[0], tmpdir=args.tmpdir) + # 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) From 2dd077aeebb6bbca14bed9d5dbf6a6f8dd5825b3 Mon Sep 17 00:00:00 2001 From: cnelson Date: Sun, 6 Mar 2016 17:41:57 -0800 Subject: [PATCH 11/11] added a nicer __repr__ for AirPlay objects --- airplay/airplay.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/airplay/airplay.py b/airplay/airplay.py index 8b218ff..fe6da78 100644 --- a/airplay/airplay.py +++ b/airplay/airplay.py @@ -128,6 +128,9 @@ def __init__(self, host, port=7000, name=None, timeout=5): 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"""