airvideo 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (6) hide show
  1. data/LICENSE +20 -0
  2. data/README.rdoc +22 -0
  3. data/Rakefile +20 -0
  4. data/VERSION +1 -0
  5. data/lib/airvideo.rb +360 -0
  6. metadata +72 -0
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 JP Hastings-Spital
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,22 @@
1
+ = AirVideo for Ruby
2
+ Have you ever thought to yourself: I have AirVideo (http://www.inmethod.com/air-video/index.html) running on my computer and I enjoy it so much
3
+ that not only have I bought the iPhone and iPad apps, I'd also love to be able to watch my video on my laptop too?
4
+
5
+ Me too! So I reverse engineered their communication protocol and came up with this. It's a little hacky (and it's certainly not been tested outside of Mac OS X 10.6)
6
+ but it will give you the Streamable and Playable URLs of the videos on your AirVideo server from Ruby.
7
+
8
+ == Usage
9
+ I'd like to be able to write a shiny GUI for all this, but alas, I am crap at the GUI. So as it stands you'll need to do this:
10
+
11
+ my_vids = AirVideo::Client.new('me.dyndns.org',45631,'01234_PASSWORD_DIGEST_SEE_BELOW_5678')
12
+ # => <AirVideo Connection: me.dyndns.org:45631>
13
+ my_vids.ls
14
+
15
+ == Passwords
16
+ If you've ever played with storing passwords you'll know about cryptographic hashing and using salts.
17
+
18
+ Essentially when you store a password, you don't store the actual password, you store the result of a cryptographic algorithm acting on the password and a string unique to your project (your salt). This means that people can't find out what the passwords are and won't be given access unless they know both the password *and* the salt.
19
+
20
+ As it currently stands I don't know the salt for the AirVideo system. I think it's an SHA1 algorithm, but there's no real way to know unless I know the salt or reverse engineer their server or iPhone app. That's a little over my head, so instead if you want to use a password on your AirVideo server you'll need to hunt down your password digest.
21
+
22
+ This gist: http://gist.github.com/412133 will tell you what your passwordDigest is for you. You'll need the pcap libraries and gem installed, as well as read access to the device you're serving AirVideo from.
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "airvideo"
8
+ gem.summary = %Q{Allows communication with an AirVideo server}
9
+ gem.description = %Q{Communicate with an AirVideo server, even through a proxy: Retrieve the streaming URLs for your videos.}
10
+ gem.email = "jphastings@gmail.com"
11
+ gem.homepage = "http://github.com/jphastings/AirVideo"
12
+ gem.authors = ["JP Hastings-Spital"]
13
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
14
+ end
15
+ Jeweler::GemcutterTasks.new
16
+ rescue LoadError
17
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
18
+ end
19
+
20
+ task :default => :build
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
data/lib/airvideo.rb ADDED
@@ -0,0 +1,360 @@
1
+ require 'net/http'
2
+ require 'stringio'
3
+
4
+ # == TODO
5
+ # * Potential bug: can you cd into a file?
6
+
7
+ module AirVideo
8
+ # The AirVideo Client. At this stage it can emulate the iPhone app in all major features.
9
+ #
10
+ # Minor features, such as requesting a specific streams when using the Live Conversion feature aren't yet supported, but it's just a question
11
+ # of figuring out what the server is expecting.
12
+ #
13
+ # Setting #max_height and #max_width *should* be working with the Live Conversion, but for some reason it's not quite happening. Defaults are 640x480
14
+ class Client
15
+ attr_accessor :max_hieght, :max_width
16
+
17
+ # Specify where your AirVideo Server lives. If your HTTP_PROXY environment variable is set, it will be honoured.
18
+ #
19
+ # At the moment I'm expecting ENV['HTTP_PROXY'] to have the form 'sub.domain.com:8080', I throw an http:// and bung it into URI.parse for convenience.
20
+ #
21
+ # I haven't currently worked out what the AirVideo salt is (or even confirmed that it is SHA1) so you'll need to snoop your own passwordDigest and pass it into the last term, or not use one for now.
22
+ def initialize(server,port = 45631,password=nil)
23
+ if ENV['HTTP_PROXY'].nil?
24
+ @http = Net::HTTP
25
+ else
26
+ proxy = URI.parse("http://"+ENV['HTTP_PROXY'])
27
+ @http = Net::HTTP::Proxy(proxy.host, proxy.port)
28
+ end
29
+ @endpoint = URI.parse "http://#{server}:#{port}/service"
30
+ @passworddigest = password
31
+
32
+ @req = Net::HTTP::Post.new(@endpoint.path)
33
+ @req['User-Agent'] = 'AirVideo/2.2.4 CFNetwork/459 Darwin/10.0.0d3'
34
+ @req['Accept'] = '*/*'
35
+ @req['Accept-Language'] = 'en-us'
36
+ @req['Accept-Encoding'] = 'gzip, deflate'
37
+
38
+ @current_dir = "/"
39
+
40
+ @max_width = 640
41
+ @max_height = 480
42
+ end
43
+
44
+ # Lists the folders and videos in the current directory as an Array of AirVideo::FileObject and AirVideo::FolderObject objects.
45
+ def ls(dir = ".")
46
+ dir = File.expand_path(dir,@current_dir)[1..-1]
47
+ dir = nil if dir == ""
48
+ #begin
49
+ request("browseService","getItems",[dir])['result']['items'].collect do |hash|
50
+ case hash.name
51
+ when "air.video.DiskRootFolder", "air.video.ITunesRootFolder","air.video.Folder"
52
+ FolderObject.new(self,hash['name'],hash['itemId'])
53
+ when "air.video.VideoItem","air.video.ITunesVideoItem"
54
+ FileObject.new(self,hash['name'],hash['itemId'],hash['detail'] || {})
55
+ else
56
+ raise NotImplementedError, "Unknown: #{hash.name}"
57
+ end
58
+ end
59
+ #rescue NoMethodError
60
+ # raise RuntimeError, "This folder does not exist"
61
+ #end
62
+ end
63
+
64
+ # Changes to the given directory. Will accept an AirVideo::FolderObject or a string.
65
+ # Returns the AirVideo::Client instance, so you can string commands:
66
+ #
67
+ # AirVideo::Client.new('127.0.0.1').ls[0].cd.ls
68
+ #
69
+ # NB. This will *not* check to see if the folder actually exists!
70
+ def cd(dir)
71
+ dir = dir.location if dir.is_a? FolderObject
72
+ @current_dir = File.expand_path(dir,@current_dir)
73
+ self
74
+ end
75
+
76
+ # Returns the streaming video URL for the given AirVideo::FileObject.
77
+ def get_url(fileobj,liveconvert = false)
78
+ raise NoMethodError, "Please pass a FileObject" if not fileobj.is_a? FileObject
79
+ begin
80
+ if liveconvert
81
+ request("livePlaybackService","initLivePlayback",[conversion_settings(fileobj)])['result']['contentURL']
82
+ else
83
+ request("playbackService","initPlayback",[fileobj.location[1..-1]])['result']['contentURL']
84
+ end
85
+ rescue NoMethodError
86
+ raise RuntimeError, "This video does not exist"
87
+ end
88
+ end
89
+
90
+ # Returns the path to the current directory
91
+ def pwd
92
+ @current_dir
93
+ end
94
+ alias :getcwd :pwd
95
+
96
+ def inspect
97
+ "<AirVideo Connection: #{@endpoint.host}:#{@endpoint.port}>"
98
+ end
99
+
100
+ private
101
+ def conversion_settings(fileobj)
102
+ video = {}
103
+ fileobj.details['streams'].each do |stream|
104
+ if stream['streamType'] == 0
105
+ video = stream
106
+ break
107
+ end
108
+ end
109
+ scaling = [video['width'] / @max_width, video['height'] / @max_height]
110
+ if scaling.max > 1.0
111
+ video['width'] = video['width'] / scaling.max
112
+ video['height'] = video['height'] / scaling.max
113
+ end
114
+
115
+ # TODO: fill these in correctly
116
+ AvMap::Hash.new("air.video.ConversionRequest", {
117
+ "itemId" => fileobj.location[1..-1],
118
+ "audioStream"=>1,
119
+ "allowedBitrates"=> BitrateList["512", "768", "1536", "1024", "384", "1280", "256"],
120
+ "audioBoost"=>0.0,
121
+ "cropRight"=>0,
122
+ "cropLeft"=>0,
123
+ "resolutionWidth"=>video['width'],
124
+ "videoStream"=>0,
125
+ "cropBottom"=>0,
126
+ "cropTop"=>0,
127
+ "quality"=>0.699999988079071,
128
+ "subtitleInfo"=>nil,
129
+ "offset"=>0.0,
130
+ "resolutionHeight"=>video['height']
131
+ })
132
+ end
133
+
134
+ def request(service,method,params)
135
+ avrequest = {
136
+ "requestURL" => @endpoint.to_s,
137
+ "clientVersion" =>221,
138
+ "serviceName" => service,
139
+ "methodName" => method,
140
+ # TODO: Figure out what this is!
141
+ "clientIdentifier" => "89eae483355719f119d698e8d11e8b356525ecfb",
142
+ "parameters" =>params
143
+ }
144
+ avrequest['passwordDigest'] = @passworddigest if not @passworddigest.nil?
145
+
146
+ @req.body = AvMap::Hash.new("air.connect.Request", avrequest).to_avmap
147
+
148
+ @http.start(@endpoint.host,@endpoint.port) do |http|
149
+ res = http.request(@req)
150
+ AvMap.parse StringIO.new(res.body)
151
+ end
152
+ end
153
+
154
+ # Represents a folder as listed by the AirVideo server.
155
+ #
156
+ # Has helper functions like #cd which will move the current directory of the originating AirVideo::Client instance to this folder.
157
+ class FolderObject
158
+ attr_reader :name, :location
159
+
160
+ # Shouldn't be used outside of the AirVideo module
161
+ def initialize(server,name,location)
162
+ @server = server
163
+ @name = name
164
+ @location = "/"+location
165
+ end
166
+
167
+ # A helper method that will move the current directory of the AirVideo::Client instance to this FolderObject.
168
+ def cd
169
+ @server.cd(self)
170
+ end
171
+
172
+ def inspect
173
+ "<Folder: #{(name.nil?) ? "/Unknown/" : name}>"
174
+ end
175
+ end
176
+
177
+ # Represents a video file as listed by the AirVideo server.
178
+ #
179
+ # Has helper functions like #url and #live_url which give the video playback URLs of this video, as produced by the originating AirVideo::Client instance's AirVideo::Client.get_url method.
180
+ class FileObject
181
+ attr_reader :name, :location, :details
182
+
183
+ def initialize(server,name,location,detail = {})
184
+ @server = server
185
+ @name = name
186
+ @location = "/"+location
187
+ @details = detail
188
+ end
189
+
190
+ # Gives the URL for direct video playback
191
+ def url
192
+ @server.get_url(self,false)
193
+ end
194
+
195
+ # Gives the URL for live conversion video playback
196
+ def live_url
197
+ @server.get_url(self,true)
198
+ end
199
+
200
+ def inspect
201
+ "<Video: #{name}>"
202
+ end
203
+ end
204
+ end
205
+
206
+ # A two-way parser for AirVideo's communication protocol.
207
+ #
208
+ # s = "Hello World!".to_avmap
209
+ # # => "s\000\000\000\012Hello World!"
210
+ # p AvMap.parse(s)
211
+ # # => "Hello World!"
212
+ #
213
+ module AvMap
214
+ # Expects an IO object. I use either a file IO or a StringIO object here.
215
+ def self.parse(stream)
216
+ @input = stream
217
+ self.read_identifier
218
+ end
219
+
220
+ private
221
+ def self.read_identifier(depth = 0)
222
+ begin
223
+ ident = @input.read(1)
224
+ case ident
225
+ when "o" # Hash
226
+ unknown = @input.read(4).unpack("N")[0]
227
+ hash = Hash.new(@input.read(@input.read(4).unpack("N")[0]), {})
228
+ unknown = @input.read(4).unpack("N")[0]
229
+ num_els = @input.read(4).unpack("N")[0]
230
+ #$stderr.puts "#{" "*depth}Hash: #{arr_name} // #{num_els} times"
231
+ 1.upto(num_els) do |iter|
232
+ hash_item = @input.read(@input.read(4).unpack("N")[0])
233
+ #$stderr.puts "#{" "*depth}-#{arr_name}:#{iter} - #{hash_item}"
234
+ hash[hash_item] = self.read_identifier(depth + 1)
235
+ end
236
+ hash
237
+ when "s" # String
238
+ #$stderr.puts "#{" "*depth}String"
239
+ unknown = @input.read(4).unpack("N")[0]
240
+ @input.read(@input.read(4).unpack("N")[0])
241
+ when "i" # Integer?
242
+ #$stderr.puts "#{" "*depth}Integer"
243
+ @input.read(4).unpack("N")[0]
244
+ when "a","e" # Array
245
+ #$stderr.puts "#{" "*depth}Array"
246
+ unknown = @input.read(4).unpack("N")[0]
247
+ num_els = @input.read(4).unpack("N")[0]
248
+ arr = []
249
+ 1.upto(num_els) do |iter|
250
+ arr.push self.read_identifier(depth + 1)
251
+ end
252
+ arr
253
+ when "n" # nil
254
+ #$stderr.puts "#{" "*depth}Nil"
255
+ nil
256
+ when "f" # Float?
257
+ @input.read(8).unpack('G')[0]
258
+ when "l" # Big Integer
259
+ @input.read(8).unpack("NN").reverse.inject([0,0]){|res,el| [res[0] + (el << (32 * res[1])),res[1] + 1]}[0]
260
+ when "r" # Integer?
261
+ #$stderr.puts "#{" "*depth}R?"
262
+ @input.read(4).unpack("N")[0]
263
+ when "x" # Binary Data
264
+ #$stderr.puts "#{" "*depth}R?"
265
+ unknown = @input.read(4).unpack("N")[0]
266
+ BinaryData.new @input.read(@input.read(4).unpack("N")[0])
267
+ else
268
+ raise NotImplementedError, "I don't know what to do with the '#{ident}' identifier"
269
+ end
270
+ rescue Exception => e
271
+ puts e.message
272
+ puts "Error : #{@input.tell}"
273
+ p e.backtrace
274
+ Process.exit
275
+ end
276
+ end
277
+
278
+ # Just hack in an addition to the Hash object, we need to be able to give each hash a name to make everything a little simpler.
279
+ class Hash < Hash
280
+ attr_accessor :name
281
+
282
+ # Create a new Hash with a name. Yay!
283
+ def initialize(key,hash)
284
+ super()
285
+ @name = key
286
+ merge! hash
287
+ self
288
+ end
289
+
290
+ def inspect
291
+ @name+super
292
+ end
293
+ end
294
+
295
+ # A simple container for Binary Data. With AirVideo this is used to hold thumbnail JPG data.
296
+ class BinaryData
297
+ attr_reader :data
298
+
299
+ # Not to be used outside of the AvMap module
300
+ def initialize(data)
301
+ @data = data
302
+ end
303
+
304
+ # Writes the data to the given filename
305
+ #
306
+ # TODO: LibMagic to detect what the extension should be?
307
+ def write_to(filename)
308
+ open(filename,"w") do |f|
309
+ f.write @data
310
+ end
311
+ end
312
+
313
+ def inspect
314
+ "<Data: #{data.length} bytes>"
315
+ end
316
+ end
317
+
318
+ # In order to demand that an array be classified with the 'e' header, rather than the 'a' header, use a BitrateList array.
319
+ #
320
+ # I'm not certain that this is the only place the 'e' arrays are used, but they're definitely used for Bitrate Lists. So this is called the BitrateList. Yup.
321
+ class BitrateList < Array; end
322
+ end
323
+ end
324
+
325
+ # Add the #to_avmap method for ease of use in the Client
326
+ class Object
327
+ # Will convert an object into an AirVideo map, if the object and it's contents are supported
328
+ def to_avmap(reset_counter = true)
329
+ $to_avmap_counter = 0 if reset_counter
330
+
331
+ case self
332
+ when Array
333
+ letter = (self.is_a? AirVideo::AvMap::BitrateList) ? "e" : "a"
334
+ self.push nil if self.length == 0 # Must have at least one entry in the hash, I think
335
+ "#{letter}#{[($to_avmap_counter += 1) - 1].pack("N")}#{[self.length].pack("N")}"+self.collect do |item|
336
+ item.to_avmap(false)
337
+ end.join
338
+ when AirVideo::AvMap::Hash
339
+ version = case self.name
340
+ when "air.video.ConversionRequest"
341
+ 221
342
+ else
343
+ 1
344
+ end
345
+ "o#{[($to_avmap_counter += 1) - 1].pack("N")}#{[self.name.length].pack("N")}#{self.name}#{[version].pack("N")}#{[self.length].pack("N")}"+self.to_a.collect do |key,val|
346
+ "#{[key.length].pack("N")}#{key}"+val.to_avmap(false)
347
+ end.join
348
+ when String
349
+ "s#{[($to_avmap_counter += 1) - 1].pack("N")}#{[self.length].pack("N")}#{self}"
350
+ when NilClass
351
+ "n"
352
+ when Integer
353
+ "i#{[self].pack('N')}"
354
+ when Float # unsure of this is what this is meant to be
355
+ "f#{[self].pack('G')}"
356
+ else
357
+ raise NotImplementedError, "Can't turn a #{self.class} into an avmap"
358
+ end
359
+ end
360
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airvideo
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - JP Hastings-Spital
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-05-24 00:00:00 +01:00
19
+ default_executable:
20
+ dependencies: []
21
+
22
+ description: "Communicate with an AirVideo server, even through a proxy: Retrieve the streaming URLs for your videos."
23
+ email: jphastings@gmail.com
24
+ executables: []
25
+
26
+ extensions: []
27
+
28
+ extra_rdoc_files:
29
+ - LICENSE
30
+ - README.rdoc
31
+ files:
32
+ - LICENSE
33
+ - README.rdoc
34
+ - Rakefile
35
+ - VERSION
36
+ - lib/airvideo.rb
37
+ has_rdoc: true
38
+ homepage: http://github.com/jphastings/AirVideo
39
+ licenses: []
40
+
41
+ post_install_message:
42
+ rdoc_options:
43
+ - --charset=UTF-8
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ none: false
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ hash: 3
52
+ segments:
53
+ - 0
54
+ version: "0"
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ none: false
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ hash: 3
61
+ segments:
62
+ - 0
63
+ version: "0"
64
+ requirements: []
65
+
66
+ rubyforge_project:
67
+ rubygems_version: 1.3.7
68
+ signing_key:
69
+ specification_version: 3
70
+ summary: Allows communication with an AirVideo server
71
+ test_files: []
72
+