Monday, December 16, 2013

Weekend Diversion - an HTTP interface to mplayer using Python and Flask

Author's note: I'm currently collaborating with a friend over at AutisTech.org on finding or creating a video playback solution with very simple remote control for his daughter. This post is based on some early exploration I did toward this. Ultimately, we'd like to have a simplified mobile device interface to XBMC or something similar. If you are interested in contributing your expertise, please check out the projects page over at his website!

Update 1/25/2014: More details on the video player concept have been posted here.

Once again I was looking to do some coding for fun over the weekend. A friend and I had recently discussed ways to control media playback on a Linux-based system from a remote device. Looking for an excuse to practice writing Python code (I come from a Perl background) and to learn Flask (a lightweight in-application webserver for Python), I decided to roll my own simple HTTP interface to control mplayer, a popular, command-line media player for Linux.

Read on for the details of my approach and some mildly-questionable Python code. (If you're looking for more refined implementations of this idea with varying feature sets, check out here and here.)

Controlling mplayer through a pipe

Other folks have discussed in-depth how to remotely control mplayer, so I will provide a relatively quick overview here. As documented here, mplayer supports a "slave" mode, wherein it listens for commands from various inputs, including from a file. For example, the following command line starts mplayer in slave mode reading commands from the file /tmp/mptest:

mplayer -idle -really-quiet -slave -input file=/tmp/mptest

The -slave option tells mplayer to enter slave mode, and -input file=xxx tells mplayer specifically to read commands from the file xxx. By default, mplayer only stays running while it has media to play. If we want mplayer to listen for commands before any movie has started playing and after that movie ends, we can use the -idle option to tell it to remain running even when no media is actively playing. Finally, we can use the -really-quiet option to suppress most extraneous mplayer output; this is nice to have when debugging.

To command mplayer, we might first create the file /tmp/mptest and write in it a sequence of commands. For instance, the command text "loadfile myfile.avi" tells mplayer to immediately start playing myfile.avi (it will interrupt any currently-playing media). Then we can run the mplayer command line above and it will read this file and run those commands. Alternatively, we can create /tmp/mptest as a named pipe, which is a special kind of file that can be used to link the output of one program to the input of another program. It's like a regular pipe (|) except that we don't have to chain the programs together on a single command line, and we can start and stop the programs independent of one another. To create a named pipe, we run the following at the command line:

mkfifo /tmp/mptest

To test this out, first create the pipe, then run the above mplayer command line in one terminal. In a separate terminal, run the following command, where /path/to/file.avi is the path to some video (or audio) file on your computer. It must either be a full path or a relative path with respect to current working directory of the terminal running mplayer:

echo "loadfile /path/to/file.avi" > /tmp/mptest

The media should start playing immediately. Note that both > (create or replace file) and >> (create or append to existing file) write or append to the pipe we created, so we can use them interchangeably. Since we used -idle when starting mplayer, it will continue to listen for commands once the media file completes playing.

Using Flask to send mplayer commands

Any other process running on the same computer can now send mplayer commands through this pipe. One way to allow other devices to send commands to mplayer is to provide an HTTP interface. Flask is a simple framework for designing web services using Python, making it a good candidate for this task. The following program is a slight extension of the "hello world" example on the Flask website (its website happens to be written entirely using Flask!):

from flask import Flask, abort, redirect, url_for
app = Flask(__name__)

@app.route('/')
def hello_world():
    return "Hello there, are you looking to play some media?"

if __name__ == '__main__':
    app.run(host='0.0.0.0')

I'll assume everyone has some basic familiarity with Python (if not, there are some great tutorials out there; I ended up referencing this one quite a bit). The first line imports various parts of the Flask library. Flask is always required for a Flask application; the other three will be explained in a bit. The next line, and the two lines at the bottom, are pretty boilerplate; the host='0.0.0.0' tells Flask to accept HTTP requests from any other computer. Note that its default TCP port is 5000.

The interesting bit is the block in the middle above. Rather than have separate files for each unique URL on the server, Flask allows URLs to be specified like functions. The above example specifies that requests to the default URL ('/'), corresponding to http://myservername:5000/ (or whatever your computer is named), are "routed to" the function hello_world(). Whatever that function returns is what the server sends back to the requesting client. In this case it's plain text, but usually this would be HTML or some other file contents.

Suppose we want to extend this application with another URL that commands mplayer to play our /path/to/file.avi from above. The following code block can be added to create the URL http://myservername:5000/play:

@app.route('/play')
def media_play():
    with open("/tmp/mptest", "a") as mpipe:
        mpipe.write("loadfile /path/to/file.avi\n")
    return "Playing file..."

Whatever code is inside the with open(... block is able to access the opened file; in this case, /tmp/mptest is opened in "a" (append) mode (again, either append or write mode should work for a named pipe). The file handle we create, mpipe, is an object with member function write(), which writes whatever argument we give it to that file handle.

This is neat, but it would be more interesting to provide a way to command mplayer to play an arbitrary file. Suppose /path/to/ contained several *.avi files; the following code block would extend the application with the URL http://myservername:5000/play/X where X is one of those files (without the ".avi" on the end):

@app.route('/play/<name>')
def media_play(name):
    with open("/tmp/mptest", "a") as mpipe:
        mpipe.write("loadfile /path/to/" + name + ".avi\n")
    return "Playing file..."

The <...> syntax in the URL specifies a parameter; that is, Flask will look for any URL starting with http://myservername:5000/play/ and then some string, and provide that string to media_play() as its parameter name. Note that this code doesn't check to make sure that this file actually exists, though adding such a check is easy (mplayer is resilient to bad filenames, however, so providing a non-existent file shouldn't cause it to crash).

Suppose instead that we have a pre-built playlist and want to select indices out of that playlist to play back. Let's suppose this playlist is represented by a variable called MEDIA_LIST as a list of dictionaries, such that MEDIA_FILE[3]['url'] refers to the path to the media file at index 3 in the playlist. We'll come back to this playlist definition below, but assume for now that each dictionary contains three keys, 'url', 'title' (the title of the media file), and 'img' (the path to an image for the media).

The following block defines http://myservername:5000/play/X, which commands mplayer to play the media file at index X in the playlist:

@app.route('/play/<int:idx>')
def media_play(idx):
    if idx > 0 and idx < len(MEDIA_LIST):
        with open("/tmp/mptest", "a") as mpipe:
            mpipe.write("loadfile " + str(MEDIA_LIST[idx]['url']) + "\n")
        return "Playing " + str(MEDIA_LIST[idx]['title']) + "..."
    else:
        abort(404)

Syntax <int:idx> tells Flask that the parameter idx is an integer, not a string (which is the default). We'll assume that the playlist entries have indices ranging from 1 to N (N being the length of the list minus 1 since Python starts counting at 0). Therefore, we can check that the provided idx is valid before issuing a command to mplayer. If this check succeeds, the function retrieves the media file and concatenates it into a command written to the pipe. If this check fails, we use the abort part of the Flask library to send back an HTTP "file not found" (404) error, which essentially tells the requesting client that the URL doesn't exist.

And there you have it: an HTTP interface to mplayer using Python and Flask. "Wait," you ask, "how did that playlist get created? And, can we provide some other functionality to make this interface better?" Well, dear reader, read on...

Adding a basic playlist

We can create a very simple playlist as a text file. Assuming all entries in the playlist require the same data, we might create a file with each media entry on its own line and each field separated by some delimiter. Since part of this project involves the use of pipes and the pipe character isn't commonly found in file paths or media titles, let's pick "|" for our delimiter. Our playlist file, playlist.txt, would look like this:

Our First Movie|/media/first_movie.avi|/media/first_movie.gif
A Great Sequel|/media/new/sequel.mov|/media/images/seq.jpg
...

To read it in, we can write a simple parser that opens this file, reads it in line by line, and splits each line apart into fields (note that in this example the file must be in the same directory as our Flask application):

def media_init():
    a = [0]
    with open("playlist.txt", "r") as playlist:
        for line in playlist:
            ls = line.rstrip("\n").split("|")
            if (len(ls) == 3):
              d = {'title': ls[0], 'url': ls[1], 'img': ls[2]}
              a.append(d)
    return a

We chose the convention earlier of indexing entries from 1 to N, so we use a = [0] to occupy the 0th index of list a so that media entries are appended starting at index 1. The line for line in playlist: does the work of breaking the file into lines for us. rstrip() strips matching characters (in this case the newline) off the right end of a string, and split() breaks a string into a list of all the sub-strings separated by the delimiter. The line d = {'title': ls[0] ... builds a dictionary, mapping 'title' to the 0th index of list ls. This dictionary is then appended to list a, which media_init() ultimately returns as the complete playlist. We can build this playlist when the application first starts by adding this line at the top level (outside any function):

MEDIA_LIST = media_init()

This last part is important: once app.run() is called, it appears to be difficult to create or alter global variables. This is probably because Flask only evaluates functions when an HTTP request is received, and doesn't maintain much (if any) state between requests. There are likely better ways to do this, and I would be happy to see modified solutions in the comments!

Providing cover art upon request

Now MEDIA_LIST is populated and can be referenced by subsequent calls to media_play(). We can also define a function that provides the playlist, complete with indices, to the requester. In the full code below, URL http://myservername:5000/list, corresponding to function media_list(), does just that.

As one last feature, perhaps we would also like the interface to provide the requester with the cover art for any media entry in the playlist. There are a few different ways to do this, but we'll touch on just one of the easier ones.

Flask has a standard spot for static website content: the subdirectory static/ of wherever the Flask application lives. For example, if our Flask application is in /home/mike/, then /home/mike/static/ may hold static content. Flask provides various ways of interacting with this content, but it knows that by default, requests for http://myservername:5000/static/X for any X should point to the contents of that directory.

We can leverage this and HTTP redirects to deliver images. HTTP allows a server to respond to a request with a "redirect," telling the client to send a new request to a different URL instead. For example, if a request comes in for http://myservername:5000/image, then the code

@app.route('/image')
def media_image():
    return redirect(url_for('static', filename='mymovie.jpg'))

will send a redirect to http://myservername:5000/static/mymovie.jpg. This code uses the redirect part of Flask to send the redirect message, and the url_for part of Flask to compute the URL to return. To redirect to the image URL of a playlist entry, we can modify this code to:

@app.route('/image/<int:idx>')
def media_image(idx):
    if idx > 0 and idx < len(MEDIA_LIST):
        return redirect(MEDIA_LIST[idx]['img'])
    else:
        abort(404)

Since we're returning an HTTP redirect anyway, we may as well have the ability to send back a URL to a different web server. The full code below extends this function to allow playlist entries to specify full URLs as well as file names in static/.

Putting it together

To summarize, we've built an HTTP interface to mplayer using Python and Flask. The command interface uses mplayer's "slave mode" with a named pipe to channel commands written from the Python code. Our HTTP interface also allows for a configurable playlist, which is read in as a text file when the program starts, and includes functions to provide the requester with that playlist and with cover art for each media entry. The full code is below; to set this up on your own, follow these steps:

  1. Make sure Python and Flask are installed on your Linux-type system (on a Debian or Ubuntu system, the command sudo apt-get install python-flask should do it)
  2. Save the code below as something like mediapipe.py
  3. Create a file in the same directory called playlist.txt and create entries (lines) with a title, file path (URLs work too!), and image path (or URL) separated by a pipe character ("|")
  4. Place any local image files in subdirectory static/
  5. Create a named pipe such as /tmp/mptest (if you choose a different name, change the definition of MEDIA_PIPE in the code)
  6. Run the mplayer command given above, starting it in slave mode
  7. Run the Python/Flask code: python mediapipe.py (you should see lines for each media entry that is added to the playlist, then a line like "* Running on http://0.0.0.0:5000/")
  8. Open a browser window and go to http://myservername:5000/ (replace "myservername" with the name or IP of your computer) to see the hello world message!

The full HTTP interface is as follows:

  • http://myservername:5000/list - returns the playlist indices and titles, tab-separated
  • http://myservername:5000/play/X - command mplayer to play index X in the playlist
  • http://myservername:5000/image/X - redirect to the cover art for index X in the playlist

As always, please use this freely but at your own risk; it may contains bugs! And, happy hacking!

Download: mplayer-flask.py

from flask import Flask, abort, redirect, url_for
app = Flask(__name__)

def media_init():
    a = [0]
    with open("playlist.txt", "r") as playlist:
        for line in playlist:
            ls = line.rstrip("\n").split("|")
            if (len(ls) == 3):  # TODO add better error checking
              print "Adding title \"" + ls[0] + "\""
              d = {'title': ls[0], 'url': ls[1], 'img': ls[2]}
              a.append(d)
    return a

MEDIA_LIST = media_init()
MEDIA_PIPE = "/tmp/mptest"

@app.route('/')
def hello_world():
    return "Hello there, are you looking to play some media?"

@app.route('/list')
def media_list():
    s = ""
    for i in range(1,len(MEDIA_LIST)):
        s += str(i) + "\t" + str(MEDIA_LIST[i]['title']) + "\n"
    return s

@app.route('/image/<int:idx>')
def media_image(idx):
    if idx > 0 and idx < len(MEDIA_LIST):
        if (MEDIA_LIST[idx]['img'].find("http", 0) == 0):
            return redirect(MEDIA_LIST[idx]['img']) 
        else:
            return redirect(url_for('static', filename=MEDIA_LIST[idx]['img'])) 
    else:
        abort(404)

@app.route('/play/<int:idx>')
def media_play(idx):
    if idx > 0 and idx < len(MEDIA_LIST):
        with open(MEDIA_PIPE, "a") as mpipe:
            mpipe.write("loadfile " + str(MEDIA_LIST[idx]['url']) + "\n")
        return "Playing " + str(MEDIA_LIST[idx]['title']) + "..."
    else:
        abort(404)

if __name__ == '__main__':
    app.run(host='0.0.0.0')

1 comment:

  1. Hi Mike, I was searching a sample for my weekend ! This is exactly what i want !! Thank you Regards, shbha

    ReplyDelete