airvideo 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.rdoc +22 -0
- data/Rakefile +20 -0
- data/VERSION +1 -0
- data/lib/airvideo.rb +360 -0
- 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
|
+
|