airvideo-ng 0.0.9
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.rdoc +41 -0
- data/Rakefile +20 -0
- data/VERSION +1 -0
- data/lib/airvideo.rb +642 -0
- metadata +67 -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,41 @@
|
|
1
|
+
= AirVideo for Ruby
|
2
|
+
Have you ever thought to yourself: I have AirVideo[http://www.inmethod.com/air-video] 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
|
+
== Care & Share
|
9
|
+
I know you know this, but the guys at InMethod don't charge for their server. Purchases of their iPhone and iPad apps are how they get rewarded for their (epic, as I'm sure you'll agree) efforts.
|
10
|
+
|
11
|
+
Please, buy their apps if you haven't already, and send them an email or a forum post saying how much you love their software. If you're a member of InMethod, come to Nottingham in the UK - I'll buy you a pint.
|
12
|
+
|
13
|
+
== Usage
|
14
|
+
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:
|
15
|
+
|
16
|
+
my_vids = AirVideo::Client.new('me.dyndns.org',45631,'YOUR PASSWORD')
|
17
|
+
# => <AirVideo Connection: me.dyndns.org:45631>
|
18
|
+
my_vids.ls
|
19
|
+
# => [<Folder: TV Shows>, <Folder: Movies>, <Folder: Music Videos>]
|
20
|
+
my_vids.ls[2].cd
|
21
|
+
|
22
|
+
# Bear in mind that the AirVideo::Client instance keeps track of where you are, like a console.
|
23
|
+
my_vids.ls
|
24
|
+
# => [<Video: Star Guitar>, <Video: A Glorious Dawn>, <Video: Stylo (Featuring Mos Def & Bobby Womack)>]
|
25
|
+
|
26
|
+
sagan = my_vids.ls[1]
|
27
|
+
# => <Video: A Glorious Dawn>
|
28
|
+
# Now you can select a video and get the streaming URL
|
29
|
+
sagan.url
|
30
|
+
# => "http://me.dyndns.org:45631/path_to_your.m4v"
|
31
|
+
|
32
|
+
# You can also specify (basic, for now) details as to how you want that file live transcoded to you!
|
33
|
+
my_vids.max_width = 640
|
34
|
+
my_vids.max_height = 480
|
35
|
+
sagan.live_url
|
36
|
+
# => "http://me.dyndns.org:45631/path_to_your_live_converting_resized.m4v"
|
37
|
+
|
38
|
+
# On a mac you can do this, but I'm sure you handsome folk can figure out how to do something similar on other OSes.
|
39
|
+
`open -a "QuickTime Player" "#{sagan.url}"`
|
40
|
+
|
41
|
+
If you have the ENV['HTTP_PROXY'] variable set (to something like 'myproxy.com:8080') then everything will be piped through there too.
|
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-ng"
|
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 = "regis.despres+rubygem@gmail.com"
|
11
|
+
gem.homepage = "http://github.com/kalw/AirVideo"
|
12
|
+
gem.authors = ["Kalw forked from JP Hastings"]
|
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.9
|
data/lib/airvideo.rb
ADDED
@@ -0,0 +1,642 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'stringio'
|
3
|
+
require 'digest/sha1'
|
4
|
+
|
5
|
+
# == TODO
|
6
|
+
# * Potential bug: can you cd into a file?
|
7
|
+
# * Caching for details?
|
8
|
+
# - Active-record?
|
9
|
+
# - In-memory by default
|
10
|
+
|
11
|
+
module AirVideo
|
12
|
+
# The AirVideo Client. At this stage it can emulate the iPhone app in all major features.
|
13
|
+
#
|
14
|
+
# Minor features, such as requesting a specific streams when using the Live Conversion feature aren't yet supported, but it's just a question
|
15
|
+
# of figuring out what the server is expecting.
|
16
|
+
#
|
17
|
+
# 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
|
18
|
+
class Client
|
19
|
+
attr_accessor :max_height, :max_width
|
20
|
+
attr_reader :proxy
|
21
|
+
|
22
|
+
# Specify where your AirVideo Server lives. If your HTTP_PROXY environment variable is set, it will be honoured.
|
23
|
+
#
|
24
|
+
# 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.
|
25
|
+
def initialize(server,port = 45631,password=nil)
|
26
|
+
set_proxy # Set to environment proxy settings, if applicable
|
27
|
+
@endpoint = URI.parse "http://#{server}:#{port}/service"
|
28
|
+
@passworddigest = Digest::SHA1.hexdigest("S@17" + password + "@1r").upcase if !password.nil?
|
29
|
+
@req = Net::HTTP::Post.new(@endpoint.path)
|
30
|
+
@req['User-Agent'] = 'AirVideo/2.4.1 CFNetwork/485.10.2 Darwin/10.3.1'
|
31
|
+
@req['Accept'] = '*/*'
|
32
|
+
@req['Accept-Language'] = 'en-us'
|
33
|
+
@req['Accept-Encoding'] = 'gzip, deflate'
|
34
|
+
@req['Connection'] = 'keep-alive'
|
35
|
+
|
36
|
+
@current_dir = "/"
|
37
|
+
|
38
|
+
@max_width = 640
|
39
|
+
@max_height = 480
|
40
|
+
end
|
41
|
+
|
42
|
+
# Potentially confusing:
|
43
|
+
# * Sending 'server:port' will use that address as an HTTP proxy
|
44
|
+
# * An empty string (or something not recognisable as a URL with http:// put infront of it) will try to use the ENV['HTTP_PROXY'] variable
|
45
|
+
# * Sending nil or any object that can't be parsed to a string will remove the proxy
|
46
|
+
#
|
47
|
+
# NB. You can access the @proxy URI object externally, but changing it will *not* automatically call set_proxy
|
48
|
+
def set_proxy(proxy_server_and_port = "")
|
49
|
+
begin
|
50
|
+
@proxy = URI.parse("http://"+((proxy_server_and_port.empty?) ? ENV['HTTP_PROXY'] : string_proxy))
|
51
|
+
@http = Net::HTTP::Proxy(@proxy.host, @proxy.port)
|
52
|
+
rescue
|
53
|
+
@proxy = nil
|
54
|
+
@http = Net::HTTP
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
# Lists the folders and videos in the current directory as an Array of AirVideo::VideoObject and AirVideo::FolderObject objects.
|
60
|
+
def ls(dir = ".")
|
61
|
+
dir = dir.location if dir.is_a? FolderObject
|
62
|
+
dir = File.expand_path(dir,@current_dir)[1..-1]
|
63
|
+
dir = nil if dir == ""
|
64
|
+
|
65
|
+
begin
|
66
|
+
request("browseService","getItems",[browse_settings(dir)])['result']['items'].collect do |hash|
|
67
|
+
#print "browsServere:getItems:\n"
|
68
|
+
#hash.each {|key, value| puts "key = #{key}, value = #{value}\n" }
|
69
|
+
|
70
|
+
case hash.name
|
71
|
+
when "air.video.DiskRootFolder", "air.video.ITunesRootFolder","air.video.Folder"
|
72
|
+
FolderObject.new(self,hash['name'],hash['itemId'])
|
73
|
+
when "air.video.VideoItem","air.video.ITunesVideoItem"
|
74
|
+
VideoObject.new(self,hash['name'],hash['itemId'],hash['detail'] || nil)
|
75
|
+
else
|
76
|
+
raise NotImplementedError, "Unknown: #{hash.name}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
rescue NoMethodError
|
80
|
+
raise RuntimeError, "This folder does not exist dir = #{dir}\n"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def browse_settings(dir)
|
85
|
+
AvMap::Hash.new("air.video.BrowseRequest",{
|
86
|
+
"folderId"=>dir,
|
87
|
+
"sortField"=>0,
|
88
|
+
"sortDirection"=>0,
|
89
|
+
"filterOriginalItems"=>0,
|
90
|
+
"metaData"=>nil,
|
91
|
+
"preloadDetails"=>0
|
92
|
+
})
|
93
|
+
end
|
94
|
+
|
95
|
+
# Changes to the given directory. Will accept an AirVideo::FolderObject or a string.
|
96
|
+
# Returns the AirVideo::Client instance, so you can string commands:
|
97
|
+
#
|
98
|
+
# AirVideo::Client.new('127.0.0.1').ls[0].cd.ls
|
99
|
+
#
|
100
|
+
# NB. This will *not* check to see if the folder actually exists!
|
101
|
+
def cd(dir)
|
102
|
+
dir = dir.location if dir.is_a? FolderObject
|
103
|
+
@current_dir = File.expand_path(dir,@current_dir)
|
104
|
+
self
|
105
|
+
end
|
106
|
+
|
107
|
+
|
108
|
+
def close_Playback(playback,service,method)
|
109
|
+
results = request(service.to_s,method.to_s,[playback.to_s])['result']
|
110
|
+
return results
|
111
|
+
end
|
112
|
+
|
113
|
+
# Returns the streaming video URL for the given AirVideo::VideoObject.
|
114
|
+
def get_url(videoobj,liveconvert = false)
|
115
|
+
raise NoMethodError, "Please pass a VideoObject" if not videoobj.is_a? VideoObject
|
116
|
+
|
117
|
+
begin
|
118
|
+
if liveconvert
|
119
|
+
details = videoobj.details
|
120
|
+
cs = conversion_settings(videoobj)
|
121
|
+
results = request("livePlaybackService","initLivePlayback",[cs])['result']['contentURL']
|
122
|
+
|
123
|
+
return results
|
124
|
+
else
|
125
|
+
request("playbackService","initPlayback",[videoobj.location[1..-1]])['result']['contentURL']
|
126
|
+
end
|
127
|
+
rescue NoMethodError
|
128
|
+
raise RuntimeError, "This video does not exist"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
|
133
|
+
def getConversionLocation()
|
134
|
+
res = convertVideoRequest("conversionService","getConversionLocations",nil)
|
135
|
+
return res
|
136
|
+
end
|
137
|
+
|
138
|
+
|
139
|
+
def convertVideoRequest(service,method,params)
|
140
|
+
#print "PARAMS:\n"
|
141
|
+
#params[0].each {|key, value| print " #{key} = #{value}\n"}
|
142
|
+
|
143
|
+
avrequest = {
|
144
|
+
"requestURL" => @endpoint.to_s,
|
145
|
+
"clientVersion" =>240,
|
146
|
+
"serviceName" => service,
|
147
|
+
"methodName" => method,
|
148
|
+
"clientIdentifier" => "5e8ddc669b2098b0e3dcae5aa1d19338e517544f",
|
149
|
+
"parameters" =>params
|
150
|
+
}
|
151
|
+
avrequest['passwordDigest'] = @passworddigest if not @passworddigest.nil?
|
152
|
+
|
153
|
+
print "AVREQUEST:\n"
|
154
|
+
avrequest.each {|key, value| print " #{key} = #{value}\n"}
|
155
|
+
|
156
|
+
@req.body = AvMap::Hash.new("air.connect.Request", avrequest).to_avmap
|
157
|
+
|
158
|
+
print "@REQ.BODY:\n"
|
159
|
+
@req.body.each {|key, value| print " #{key} = #{value}\n"}
|
160
|
+
|
161
|
+
@http.start(@endpoint.host,@endpoint.port) do |http|
|
162
|
+
res = http.request(@req)
|
163
|
+
parse = AvMap.parse StringIO.new(res.body)
|
164
|
+
return parse
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def convertVideo(videoobj)
|
169
|
+
raise NoMethodError, "Please pass a VideoObject" if not videoobj.is_a? VideoObject
|
170
|
+
|
171
|
+
begin
|
172
|
+
# don't remove call to details, if you do convertVideo will ot work
|
173
|
+
details = videoobj.details
|
174
|
+
location = getConversionLocation()
|
175
|
+
print "location = #{location}\n"
|
176
|
+
cs = conversion_settings(videoobj)
|
177
|
+
|
178
|
+
|
179
|
+
metaData = AvMap::Hash.new("metaData",{
|
180
|
+
"device"=>"iPhone",
|
181
|
+
"clientVersion"=>"2.4.1"
|
182
|
+
})
|
183
|
+
|
184
|
+
cs['metaData'] = metaData
|
185
|
+
|
186
|
+
print "metaData = ",cs['metaData'].each {|key, value| print "#{key} = #{value}\n"},"\n"
|
187
|
+
print "cs = #{cs}\n"
|
188
|
+
|
189
|
+
#sleep 2
|
190
|
+
result = convertVideoRequest("conversionService","convertItem",[cs])
|
191
|
+
print "result = #{result}\n"
|
192
|
+
return result
|
193
|
+
rescue NoMethodError
|
194
|
+
raise RuntimeError, "Not able to convert video"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def get_pin(p)
|
199
|
+
begin
|
200
|
+
pin = Array.new()
|
201
|
+
pin.push(p)
|
202
|
+
pin_request("trackerService","getServerState",pin)['result']
|
203
|
+
rescue NoMethodError
|
204
|
+
raise NoMethodError, "Could not get pin for server\n"
|
205
|
+
rescue => ex
|
206
|
+
raise ex,"Some sort of error\n"
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def get_details(items)
|
211
|
+
items = [items] if !items.is_a? Array
|
212
|
+
items.collect! do |item|
|
213
|
+
case item
|
214
|
+
when VideoObject
|
215
|
+
item.location[1..-1]
|
216
|
+
when String
|
217
|
+
item
|
218
|
+
end
|
219
|
+
end.compact!
|
220
|
+
|
221
|
+
request("browseService","getItemsWithDetail",[items])['result'][0]
|
222
|
+
end
|
223
|
+
|
224
|
+
# Searches the current directory for items matching the given regular expression
|
225
|
+
def search(re_string,dir=".")
|
226
|
+
# Get the directory we're searching
|
227
|
+
dir = File.expand_path((dir.is_a? FolderObject) ? dir.location : dir,@current_dir)
|
228
|
+
ls(dir).select {|item| item.name =~ %r{#{re_string}}}
|
229
|
+
end
|
230
|
+
|
231
|
+
# Returns the path to the current directory
|
232
|
+
def pwd
|
233
|
+
@current_dir
|
234
|
+
end
|
235
|
+
alias :getcwd :pwd
|
236
|
+
|
237
|
+
def inspect
|
238
|
+
"<AirVideo Connection: #{@endpoint.host}:#{@endpoint.port}>"
|
239
|
+
end
|
240
|
+
|
241
|
+
#private
|
242
|
+
def conversion_settings(videoobj)
|
243
|
+
video = videoobj.video_stream
|
244
|
+
scaling = [video['width'] / @max_width, video['height'] / @max_height]
|
245
|
+
|
246
|
+
if scaling.max > 1.0
|
247
|
+
video['width'] = (video['width'] / scaling.max).to_i
|
248
|
+
video['height'] = (video['height'] / scaling.max).to_i
|
249
|
+
end
|
250
|
+
|
251
|
+
# TODO: fill these in correctly
|
252
|
+
AvMap::Hash.new("air.video.ConversionRequest", {
|
253
|
+
"resolutionWidth"=>video['width'],
|
254
|
+
"resolutionHeight"=>video['height'],
|
255
|
+
"cropLeft"=>0,
|
256
|
+
"cropRight"=>0,
|
257
|
+
"cropTop"=>0,
|
258
|
+
"cropBottom"=>0,
|
259
|
+
"itemId" => videoobj.location[1..-1],
|
260
|
+
"offset"=>0.0,
|
261
|
+
"quality"=>0.699999988079071,
|
262
|
+
"videoStream"=>0,#video['index'],
|
263
|
+
"audioStream"=>1,#videoobj.audio_stream['index'],
|
264
|
+
"subtitleInfo"=>nil,
|
265
|
+
"audioBoost"=>0.0,
|
266
|
+
"allowedBitratesLocal"=> AirVideo::AvMap::BitrateList["1536"],
|
267
|
+
"allowedBitratesRemote"=> AirVideo::AvMap::BitrateList["384"]
|
268
|
+
})
|
269
|
+
end
|
270
|
+
|
271
|
+
def pin_request(service,method,params)
|
272
|
+
server = "inmethod.com"
|
273
|
+
port = "1112"
|
274
|
+
pin_endpoint = URI.parse "http://#{server}:#{port}/service"
|
275
|
+
|
276
|
+
avrequest = {
|
277
|
+
"requestURL" => pin_endpoint.to_s,
|
278
|
+
"clientVersion" =>100,
|
279
|
+
"serviceName" => service,
|
280
|
+
"methodName" => method,
|
281
|
+
"parameters" => params,
|
282
|
+
#"clientIdentifier" => "89eae483355719f119d698e8d11e8b356525ecfb",
|
283
|
+
"clientIdentifier" => "5e8ddc669b2098b0e3dcae5aa1d19338e517544f",
|
284
|
+
"passwordDigest" => nil
|
285
|
+
}
|
286
|
+
|
287
|
+
req = Net::HTTP::Post.new(pin_endpoint.path)
|
288
|
+
req['User-Agent'] = 'AirVideo/2.4.1 CFNetwork/485.10.2 Darwin/10.3.1'
|
289
|
+
req['Accept'] = '*/*'
|
290
|
+
req['Accept-Language'] = 'en-us'
|
291
|
+
req['Accept-Encoding'] = 'gzip, deflate'
|
292
|
+
req['Connection'] = 'keep-alive'
|
293
|
+
|
294
|
+
req.body = AvMap::Hash.new("air.connect.Request", avrequest).to_avmap
|
295
|
+
|
296
|
+
http = Net::HTTP
|
297
|
+
|
298
|
+
http.start(pin_endpoint.host, pin_endpoint.port) do |http|
|
299
|
+
res = http.request(req)
|
300
|
+
AvMap.parse StringIO.new(res.body)
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
def request(service,method,params)
|
305
|
+
avrequest = {
|
306
|
+
"requestURL" => @endpoint.to_s,
|
307
|
+
"clientVersion" =>240,
|
308
|
+
"serviceName" => service,
|
309
|
+
"methodName" => method,
|
310
|
+
# TODO: Figure out what this is!
|
311
|
+
#"clientIdentifier" => "89eae483355719f119d698e8d11e8b356525ecfb",
|
312
|
+
"clientIdentifier" => "5e8ddc669b2098b0e3dcae5aa1d19338e517544f",
|
313
|
+
"parameters" =>params
|
314
|
+
}
|
315
|
+
avrequest['passwordDigest'] = @passworddigest if not @passworddigest.nil?
|
316
|
+
|
317
|
+
@req.body = AvMap::Hash.new("air.connect.Request", avrequest).to_avmap
|
318
|
+
|
319
|
+
@http.start(@endpoint.host,@endpoint.port) do |http|
|
320
|
+
res = http.request(@req)
|
321
|
+
parse = AvMap.parse StringIO.new(res.body)
|
322
|
+
return parse
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
# Represents a folder as listed by the AirVideo server.
|
327
|
+
#
|
328
|
+
# Has helper functions like #cd which will move the current directory of the originating AirVideo::Client instance to this folder.
|
329
|
+
class FolderObject
|
330
|
+
attr_reader :name, :location
|
331
|
+
Helpers = [:cd, :ls, :search]
|
332
|
+
|
333
|
+
# Shouldn't be used outside of the AirVideo module
|
334
|
+
def initialize(server,name,location) # :nodoc:
|
335
|
+
@server = server
|
336
|
+
@name = name
|
337
|
+
@location = "/"+location
|
338
|
+
end
|
339
|
+
|
340
|
+
def inspect
|
341
|
+
"<Folder: #{(name.nil?) ? "/Unknown/" : name}>"
|
342
|
+
end
|
343
|
+
|
344
|
+
def cd
|
345
|
+
@server.cd(self)
|
346
|
+
end
|
347
|
+
|
348
|
+
def ls
|
349
|
+
@server.ls(self)
|
350
|
+
end
|
351
|
+
|
352
|
+
def search(re_string)
|
353
|
+
@server.search(re_string,self)
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
357
|
+
# Represents a video file as listed by the AirVideo server.
|
358
|
+
#
|
359
|
+
# 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.
|
360
|
+
class VideoObject
|
361
|
+
attr_reader :name, :location, :details, :streams
|
362
|
+
attr_accessor :audio_stream, :video_stream
|
363
|
+
|
364
|
+
# Shouldn't be used outside of the AirVideo module
|
365
|
+
def initialize(server,name,location,detail = nil) # :nodoc:
|
366
|
+
@server = server
|
367
|
+
@name = name
|
368
|
+
@location = "/"+location
|
369
|
+
@details = detail # nil implies the details haven't been loaded
|
370
|
+
# These are the defaults, all videos *should* have these.
|
371
|
+
@video_stream = {'index' => 1}
|
372
|
+
@audio_stream = {'index' => 0}
|
373
|
+
details if !@details.nil?
|
374
|
+
end
|
375
|
+
|
376
|
+
def details
|
377
|
+
#print "####### In airvideo details #######\n"
|
378
|
+
@details = @server.get_details(self)
|
379
|
+
#print "########### @details = #{@details}\n"
|
380
|
+
######dump attributes of hash
|
381
|
+
#@details.each {|key, value| puts "#{key} = #{value}\n"}
|
382
|
+
if !@details.nil?
|
383
|
+
@streams = {'video' => [],'audio' => [],'unknown' => []}
|
384
|
+
#print "@streams = #{@streams}\n"
|
385
|
+
#print "@details['detail'] = ",@details['detail'],"\n"
|
386
|
+
@details['detail']['streams'].each do |stream|
|
387
|
+
@streams[case
|
388
|
+
when 0
|
389
|
+
"video"
|
390
|
+
when 1
|
391
|
+
"audio"
|
392
|
+
else
|
393
|
+
"unknown"
|
394
|
+
end
|
395
|
+
]
|
396
|
+
end
|
397
|
+
|
398
|
+
@audio_stream = @details['detail']['streams'][0]
|
399
|
+
#print "@audio_stream = #{@audio_stream}\n"
|
400
|
+
@video_stream = @details['detail']['streams'][0]
|
401
|
+
#print "@video_stream = #{@video_stream}\n"
|
402
|
+
end
|
403
|
+
@details
|
404
|
+
end
|
405
|
+
|
406
|
+
# Checks to see if this video has that audio stream index, then changes internal settings so that live conversions will use this stream.
|
407
|
+
def audio_stream=(stream_hash_or_index)
|
408
|
+
index = stream_hash_or_index['index'] rescue stream_hash_or_index
|
409
|
+
get_details if @details.nil?
|
410
|
+
raise RuntimeError, "Couldn't retrieve video details" if @details.nil?
|
411
|
+
raise RuntimeError, "No such audio stream" if @streams['audio'].collect{|stream| stream['index']}.include? index
|
412
|
+
@audio_stream = index
|
413
|
+
end
|
414
|
+
|
415
|
+
# Checks to see if this video has that video stream index, then changes internal settings so that live conversions will use this stream.
|
416
|
+
def video_stream=(stream_hash_or_index)
|
417
|
+
index = stream_hash_or_index['index'] rescue stream_hash_or_index
|
418
|
+
get_details if @details.nil?
|
419
|
+
raise RuntimeError, "Couldn't retrieve video details" if @details.nil?
|
420
|
+
raise RuntimeError, "No such audio stream" if @streams['video'].collect{|stream| stream['index']}.include? index
|
421
|
+
@video_stream = index
|
422
|
+
end
|
423
|
+
|
424
|
+
# Gives the URL for direct video playback
|
425
|
+
def url
|
426
|
+
@server.get_url(self,false)
|
427
|
+
end
|
428
|
+
|
429
|
+
# Gives the URL for live conversion video playback
|
430
|
+
def live_url
|
431
|
+
@server.get_url(self,true)
|
432
|
+
end
|
433
|
+
|
434
|
+
def inspect
|
435
|
+
"<Video: #{name}>"
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
# A two-way parser for AirVideo's communication protocol.
|
441
|
+
#
|
442
|
+
# s = "Hello World!".to_avmap
|
443
|
+
# # => "s\000\000\000\012Hello World!"
|
444
|
+
# p AvMap.parse(s)
|
445
|
+
# # => "Hello World!"
|
446
|
+
#
|
447
|
+
module AvMap
|
448
|
+
# Expects an IO object. I use either a file IO or a StringIO object here.
|
449
|
+
def self.parse(stream)
|
450
|
+
@input = stream
|
451
|
+
self.read_identifier
|
452
|
+
end
|
453
|
+
|
454
|
+
private
|
455
|
+
def self.read_identifier(depth = 0)
|
456
|
+
ident = @input.read(1)
|
457
|
+
case ident
|
458
|
+
when "o" # Hash
|
459
|
+
unknown = @input.read(4).unpack("N")[0]
|
460
|
+
hash = Hash.new(@input.read(@input.read(4).unpack("N")[0]), {})
|
461
|
+
unknown = @input.read(4).unpack("N")[0]
|
462
|
+
num_els = @input.read(4).unpack("N")[0]
|
463
|
+
#$stderr.puts "#{" "*depth}Hash: #{unknown} // #{num_els} times"
|
464
|
+
1.upto(num_els) do |iter|
|
465
|
+
hash_item = @input.read(@input.read(4).unpack("N")[0])
|
466
|
+
#$stderr.puts "#{" "*depth}-#{unknown}:#{iter} - #{hash_item}"
|
467
|
+
hash[hash_item] = self.read_identifier(depth + 1)
|
468
|
+
end
|
469
|
+
hash
|
470
|
+
when "s" # String
|
471
|
+
#$stderr.puts "#{" "*depth}String"
|
472
|
+
unknown = @input.read(4).unpack("N")[0]
|
473
|
+
@input.read(@input.read(4).unpack("N")[0])
|
474
|
+
when "i" # Integer?
|
475
|
+
#$stderr.puts "#{" "*depth}Integer"
|
476
|
+
@input.read(4).unpack("N")[0]
|
477
|
+
when "a","e" # Array
|
478
|
+
#$stderr.puts "#{" "*depth}Array"
|
479
|
+
unknown = @input.read(4).unpack("N")[0]
|
480
|
+
num_els = @input.read(4).unpack("N")[0]
|
481
|
+
arr = []
|
482
|
+
1.upto(num_els) do |iter|
|
483
|
+
arr.push self.read_identifier(depth + 1)
|
484
|
+
end
|
485
|
+
arr
|
486
|
+
when "n" # nil
|
487
|
+
#$stderr.puts "#{" "*depth}Nil"
|
488
|
+
nil
|
489
|
+
when "f" # Float?
|
490
|
+
@input.read(8).unpack('G')[0]
|
491
|
+
when "l" # Big Integer
|
492
|
+
@input.read(8).unpack("NN").reverse.inject([0,0]){|res,el| [res[0] + (el << (32 * res[1])),res[1] + 1]}[0]
|
493
|
+
when "r" # Integer?
|
494
|
+
#$stderr.puts "#{" "*depth}R?"
|
495
|
+
@input.read(4).unpack("N")[0]
|
496
|
+
when "x" # Binary Data
|
497
|
+
#$stderr.puts "#{" "*depth}R?"
|
498
|
+
unknown = @input.read(4).unpack("N")[0]
|
499
|
+
BinaryData.new @input.read(@input.read(4).unpack("N")[0])
|
500
|
+
else
|
501
|
+
raise NotImplementedError, "I don't know what to do with the '#{ident}' identifier"
|
502
|
+
end
|
503
|
+
end
|
504
|
+
|
505
|
+
# 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.
|
506
|
+
class Hash < Hash
|
507
|
+
attr_accessor :name
|
508
|
+
|
509
|
+
# Create a new Hash with a name. Yay!
|
510
|
+
def initialize(key,hash)
|
511
|
+
super()
|
512
|
+
@name = key
|
513
|
+
merge! hash
|
514
|
+
self
|
515
|
+
end
|
516
|
+
|
517
|
+
def inspect
|
518
|
+
@name+super
|
519
|
+
end
|
520
|
+
end
|
521
|
+
|
522
|
+
|
523
|
+
# A simple container for Binary Data. With AirVideo this is used to hold thumbnail JPG data.
|
524
|
+
class BinaryData
|
525
|
+
attr_reader :data
|
526
|
+
|
527
|
+
# Not really useful outside of the AvMap module
|
528
|
+
def initialize(data) # :nodoc:
|
529
|
+
@data = data
|
530
|
+
end
|
531
|
+
|
532
|
+
# Writes the data to the given filename
|
533
|
+
#
|
534
|
+
# TODO: LibMagic to detect what the extension should be?
|
535
|
+
def write_to(filename)
|
536
|
+
open(filename,"w") do |f|
|
537
|
+
f.write @data
|
538
|
+
end
|
539
|
+
end
|
540
|
+
|
541
|
+
def length
|
542
|
+
@data.length
|
543
|
+
end
|
544
|
+
|
545
|
+
def inspect
|
546
|
+
"<Data: #{@data.length} bytes>"
|
547
|
+
end
|
548
|
+
end
|
549
|
+
|
550
|
+
# In order to demand that an array be classified with the 'e' header, rather than the 'a' header, use a BitrateList array.
|
551
|
+
#
|
552
|
+
# 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.
|
553
|
+
class BitrateList < Array; end
|
554
|
+
|
555
|
+
end
|
556
|
+
end
|
557
|
+
|
558
|
+
# Add the #to_avmap method for ease of use in the Client
|
559
|
+
class Object
|
560
|
+
# Will convert an object into an AirVideo map, if the object and it's contents are supported
|
561
|
+
def to_avmap(reset_counter = true)
|
562
|
+
@@to_avmap_counter = 0 if reset_counter
|
563
|
+
case self
|
564
|
+
when Array
|
565
|
+
letter = (self.is_a? AirVideo::AvMap::BitrateList) ? "e" : "a"
|
566
|
+
self.push nil if self.length == 0 # Must have at least one entry in the hash, I think
|
567
|
+
"#{letter}#{[(@@to_avmap_counter += 1) - 1].pack("N")}#{[self.length].pack("N")}"+self.collect do |item|
|
568
|
+
item.to_avmap(false)
|
569
|
+
end.join
|
570
|
+
when AirVideo::AvMap::Hash
|
571
|
+
version = case self.name
|
572
|
+
when "air.video.ConversionRequest"
|
573
|
+
240
|
574
|
+
when "air.video.BrowseRequest"
|
575
|
+
240
|
576
|
+
else
|
577
|
+
1
|
578
|
+
end
|
579
|
+
|
580
|
+
# Implement metaData:
|
581
|
+
# :length :type
|
582
|
+
# "\000\000\000\010"+"metaData"+
|
583
|
+
# :data :counter :number of items
|
584
|
+
# "d"+"\000\000\000\015"+"\000\000\000\002"+
|
585
|
+
# :string :counter :length of str :string value
|
586
|
+
# "s"+"\000\000\000\016"+"\000\000\000\006"+"device"+
|
587
|
+
# "s"+"\000\000\000\017"+"\000\000\000\006"+"iPhone"+
|
588
|
+
# "s"+"\000\000\000\020"+"\000\000\000\015"+"clientVersion"+
|
589
|
+
# "s"+"\000\000\000\021"+"\000\000\000\005"+"2.4.1"
|
590
|
+
# :nil:integer value
|
591
|
+
# "ni"+"\000\000\000\000" 'nili0000'
|
592
|
+
|
593
|
+
#iphone (working):
|
594
|
+
# 00 00 00 08 6d ........ 384....m
|
595
|
+
#06D2 65 74 61 44 61 74 61 64 00 00 00 0d 00 00 00 02 etaDatad ........
|
596
|
+
#06E2 73 00 00 00 0e 00 00 00 06 64 65 76 69 63 65 73 s....... .devices
|
597
|
+
#06F2 00 00 00 0f 00 00 00 06 69 50 68 6f 6e 65 73 00 ........ iPhones.
|
598
|
+
#0702 00 00 10 00 00 00 0d 63 6c 69 65 6e 74 56 65 72 .......c lientVer
|
599
|
+
#0712 73 69 6f 6e 73 00 00 00 11 00 00 00 05 32 2e 34 sions... .....2.4
|
600
|
+
#0722 2e 31 6e 69 00 00 00 00 .1ni....
|
601
|
+
|
602
|
+
|
603
|
+
#mine (not working):
|
604
|
+
#0319 2e 5b 56 54 56 5d 2e 6d 70 34 00 00 00 08 6d 65 .[VTV].m p4....me
|
605
|
+
#0329 74 61 44 61 74 61 64 00 00 00 0a 00 00 00 02 73 taDatad. .......s
|
606
|
+
#0339 00 00 00 0b 00 00 00 06 64 65 76 69 63 65 73 00 ........ devices.
|
607
|
+
#0349 00 00 0c 00 00 00 06 69 50 68 6f 6e 65 73 00 00 .......i Phones..
|
608
|
+
#0359 00 0d 00 00 00 0d 63 6c 69 65 6e 74 56 65 72 73 ......cl ientVers
|
609
|
+
#0369 69 6f 6e 73 00 00 00 0e 00 00 00 05 32 2e 34 2e ions.... ....2.4.
|
610
|
+
#0379 31 00 00 00 0f 72 65 73 6f 6c 75 74 69 6f 6e 57 1....
|
611
|
+
|
612
|
+
case self.name
|
613
|
+
when "metaData"
|
614
|
+
"d#{[(@@to_avmap_counter += 1) - 1].pack("N")}#{[self.count].pack("N")}"+self.to_a.collect do |key,val|
|
615
|
+
"s#{[(@@to_avmap_counter += 1) - 1].pack("N")}#{[key.length].pack("N")}#{key}"+val.to_avmap(false)
|
616
|
+
end.join # need to add "ni"+"\000\000\000\000 = nil0"
|
617
|
+
#(nil+0).to_avmap(true)
|
618
|
+
else
|
619
|
+
"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|
|
620
|
+
"#{[key.length].pack("N")}#{key}"+val.to_avmap(false)
|
621
|
+
end.join
|
622
|
+
end
|
623
|
+
|
624
|
+
when AirVideo::AvMap::BinaryData
|
625
|
+
"x#{[(@@to_avmap_counter += 1) - 1].pack("N")}#{[self.length].pack("N")}#{self.data}"
|
626
|
+
when String
|
627
|
+
# s:count:length:value
|
628
|
+
"s#{[(@@to_avmap_counter += 1) - 1].pack("N")}#{[self.length].pack("N")}#{self}"
|
629
|
+
#when Hash
|
630
|
+
#
|
631
|
+
when NilClass
|
632
|
+
"n"
|
633
|
+
when Integer
|
634
|
+
# i:integer N = Long, network (big-endian) byte order
|
635
|
+
"i#{[self].pack('N')}"
|
636
|
+
when Float # unsure of this is what this is meant to be
|
637
|
+
"f#{[self].pack('G')}"
|
638
|
+
else
|
639
|
+
raise NotImplementedError, "Can't turn a #{self.class} into an avmap"
|
640
|
+
end
|
641
|
+
end
|
642
|
+
end
|
metadata
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: airvideo-ng
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 0
|
7
|
+
- 0
|
8
|
+
- 9
|
9
|
+
version: 0.0.9
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Kalw forked from JP Hastings
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2012-03-26 00:00:00 +02:00
|
18
|
+
default_executable:
|
19
|
+
dependencies: []
|
20
|
+
|
21
|
+
description: "Communicate with an AirVideo server, even through a proxy: Retrieve the streaming URLs for your videos."
|
22
|
+
email: regis.despres+rubygem@gmail.com
|
23
|
+
executables: []
|
24
|
+
|
25
|
+
extensions: []
|
26
|
+
|
27
|
+
extra_rdoc_files:
|
28
|
+
- LICENSE
|
29
|
+
- README.rdoc
|
30
|
+
files:
|
31
|
+
- LICENSE
|
32
|
+
- README.rdoc
|
33
|
+
- Rakefile
|
34
|
+
- VERSION
|
35
|
+
- lib/airvideo.rb
|
36
|
+
has_rdoc: true
|
37
|
+
homepage: http://github.com/kalw/AirVideo
|
38
|
+
licenses: []
|
39
|
+
|
40
|
+
post_install_message:
|
41
|
+
rdoc_options: []
|
42
|
+
|
43
|
+
require_paths:
|
44
|
+
- lib
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
segments:
|
50
|
+
- 0
|
51
|
+
version: "0"
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
segments:
|
57
|
+
- 0
|
58
|
+
version: "0"
|
59
|
+
requirements: []
|
60
|
+
|
61
|
+
rubyforge_project:
|
62
|
+
rubygems_version: 1.3.6
|
63
|
+
signing_key:
|
64
|
+
specification_version: 3
|
65
|
+
summary: Allows communication with an AirVideo server
|
66
|
+
test_files: []
|
67
|
+
|