Saturday, September 24, 2011

AirFlick in ruby

My AppleTV is still one of my favorite devices at home. As I usually watch movies once or twice, I usually don't bother putting them in iTunes. Therefore, I mostly use Airflick.
AirFlick is just a beautiful software: solves my problem, easy to use, reliable...but not open source and command line-based.
Last time I was visiting rubyflow, I noticed the airplay gem, gave it a try and found it working pretty well.
I then decided to build my own 100% ruby Airflick like with airplay and goliath gems. I tested only on OS X Lion, but I guess it should work on whatever platform the airflick and goliath gems are available.

Before starting, you need to install ruby 1.9 (required for goliath) and the following gems:
  • gem install goliath
  • gem install airplay

Then you can save the following script in a file called goliath.rb

require 'goliath'

require 'eventmachine'

require 'airplay'
require 'singleton'

# Singleton that deals with the file
class FilmLoader
include Singleton

attr_reader :mapping
attr_reader :size

def ensure_mapping_extension_is_present
@@fastfilereader ||= (require 'fastfilereaderext')
end
private :ensure_mapping_extension_is_present

# Limit ourselves to MP4 and M4V files
def contentType
case File.extname(@filename).upcase
when ".MP4"
return "video/mp4"
when ".M4V"
return "video/x-m4v"
end
""
end

def extname
File.extname(@filename)
end

def isValid?
File.exist?(@filename) and contentType.size > 0
end

def init(filename)
ensure_mapping_extension_is_present

@filename = filename

if isValid? then
@size = File.size(@filename)
@mapping = EventMachine::FastFileReader::Mapper.new(@filename)
else
puts "#{@filename} does not exist or is not a proper type"
exit
end
end
end

# The last parameter is the film filename
FilmLoader.instance.init(ARGV[-1])

# Goliath Plugin that will send the request to appletv
class AirPlayLauncher
def initialize(port, config, status, logger)
@port = port
end

def ip
# TODO: should be improved, is it cross platform?
adds = Socket.ip_address_list
adds.select { |x| x.ipv4? and x.ip_address!="127.0.0.1"}[0].ip_address
end

def run
# Wait for 2 seconds before sending the request to appletv
EM.add_timer(2) do
airplay = Airplay::Client.new
url = "http://#{ip}:#{@port}/film#{FilmLoader.instance.extname}"
airplay.send_video(url)
end
end
end

class AppleTVServer < Goliath::API

plugin AirPlayLauncher

def sendChunk(env, first, len)
# 32000 is the chunksize in bytes
chunklen = [len, 32000].min

chunk = FilmLoader.instance.mapping.get_chunk( first, chunklen )
env.stream_send( chunk )

if(chunklen < len) then
EM.next_tick { sendChunk(env, first + chunklen, len - chunklen) }
else
env.stream_close
end
end

def response(env)
if FilmLoader.instance.mapping then

if env["HTTP_RANGE"] == nil then
hash = {"Content-Type" => FilmLoader.instance.contentType,
"Accept-Ranges" => "bytes",
"Content-Length" => FilmLoader.instance.size.to_s }
[200, hash, "OK"]
else
# TODO: deal with other types of range:
# => -(\d+)
# => (\d+)-
m = /bytes=(\d+)-(\d+)/.match env["HTTP_RANGE"]

if m then
first = m[1].to_i
second = m[2].to_i
len = second - first + 1

EM.next_tick { sendChunk(env, first, len) }

range = "bytes #{first}-#{second}"
range <<"/#{FilmLoader.instance.size}"
env.logger.info range

hash ={"Content-Type" => FilmLoader.instance.contentType,
"Content-Range" => range,
"Accept-Ranges" => "bytes",
"Content-Length" => len.to_s }

env.logger.info hash
[206, hash, Goliath::Response::STREAMING]
else
[416, {}, "Range error:"+env["HTTP_RANGE"]]
end
end

else
[504, {}, "Could not map file"]
end
end
end


Let's say you want to play /Users/me/Downloads/movie.mp4, you just run the following in the Terminal:

ruby goliath.rb /Users/me/Downloads/movie.mp4

After a couple of seconds, the movie will start playing on your TV.

How does it work? First FilmLoader singleton, deals with the file mapping. Mapping is important to be able to quickly provide parts of the file when the server requires it.AirplayLauncher is using airplay to tell the appleTV where to request the file. Then the last part is AppleTVServer , the goliath API that sends chunks of the file according to the appleTV HTTP range requests.

I'm not really happy with AirplayLauncher.ip because I'm not convinced it is reliable or cross-platform. Your suggestions are more than appreciated.

The code only deals with mp4 and m4v movies right now because they are natively read by the appleTV. Why is command line-based so great? Because I can ssh from my iPad to my macbook and launch a movie in a couple of seconds.

Hope you'll enjoy this piece of code. I believe it is again a proof of ruby elegance (the script is less than 150 lines) and the great quality of ruby environment. Goliath web server is a really easy to use and powerful server.

No comments: