airplay 1.0.2 → 1.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +1 -0
- data/.travis.yml +9 -5
- data/Gemfile +1 -4
- data/Gemfile.lock +13 -14
- data/README.md +38 -5
- data/Rakefile +19 -8
- data/airplay-cli.gemspec +1 -1
- data/bin/air +12 -2
- data/doc/bitdeli.md +1 -0
- data/doc/header.md +2 -1
- data/doc/img/block_tv.jpg +0 -0
- data/doc/installation.md +2 -2
- data/doc/testing.md +14 -0
- data/doc/toc.md +10 -0
- data/doc/usage.md +7 -2
- data/lib/airplay/browser.rb +8 -4
- data/lib/airplay/cli.rb +35 -0
- data/lib/airplay/cli/doctor.rb +60 -0
- data/lib/airplay/cli/version.rb +1 -1
- data/lib/airplay/configuration.rb +3 -1
- data/lib/airplay/connection.rb +46 -17
- data/lib/airplay/connection/authentication.rb +3 -4
- data/lib/airplay/device.rb +57 -17
- data/lib/airplay/device/info.rb +1 -1
- data/lib/airplay/devices.rb +3 -1
- data/lib/airplay/player.rb +10 -2
- data/lib/airplay/server.rb +18 -4
- data/lib/airplay/version.rb +1 -1
- data/lib/airplay/viewer.rb +65 -8
- data/test/integration/fetching_device_information_test.rb +22 -0
- data/test/integration/view_images_test.rb +39 -0
- data/test/integration_helper.rb +23 -0
- data/test/unit/configuration_test.rb +1 -1
- metadata +22 -41
@@ -0,0 +1,60 @@
|
|
1
|
+
module Airplay
|
2
|
+
module CLI
|
3
|
+
class Doctor
|
4
|
+
DebugDevice = Struct.new(:node, :resolved) do
|
5
|
+
def host
|
6
|
+
info = Socket.getaddrinfo(resolved.target, nil, Socket::AF_INET)
|
7
|
+
info[0][2]
|
8
|
+
rescue SocketError
|
9
|
+
target
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :devices
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@devices = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def information
|
20
|
+
find_devices!
|
21
|
+
|
22
|
+
devices.each do |device|
|
23
|
+
puts <<-EOS.gsub!(" "*12, "")
|
24
|
+
Name: #{device.node.name}
|
25
|
+
Host: #{device.host}
|
26
|
+
Port: #{device.resolved.port}
|
27
|
+
Full Name: #{device.node.fullname}
|
28
|
+
Iface: #{device.node.interface_name}
|
29
|
+
TXT: #{device.resolved.text_record}
|
30
|
+
|
31
|
+
EOS
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def find_devices!
|
38
|
+
timeout(5) do
|
39
|
+
DNSSD.browse!(Airplay::Browser::SEARCH) do |node|
|
40
|
+
try_resolving(node)
|
41
|
+
break unless node.flags.more_coming?
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def try_resolving(node)
|
47
|
+
timeout(5) do
|
48
|
+
resolver = DNSSD::Service.new
|
49
|
+
resolver.resolve(node) do |resolved|
|
50
|
+
devices << DebugDevice.new(node, resolved)
|
51
|
+
|
52
|
+
break unless resolved.flags.more_coming?
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
data/lib/airplay/cli/version.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require "log4r/config"
|
2
|
+
require "celluloid/autostart"
|
2
3
|
require "airplay/logger"
|
3
4
|
|
4
5
|
# Public: Airplay core module
|
@@ -10,12 +11,13 @@ module Airplay
|
|
10
11
|
attr_accessor :log_level, :output, :autodiscover, :host, :port
|
11
12
|
|
12
13
|
def initialize
|
14
|
+
Celluloid.boot # Force Thread Pool initialization
|
13
15
|
Log4r.define_levels(*Log4r::Log4rConfig::LogLevels)
|
14
16
|
|
15
17
|
@log_level = Log4r::ERROR
|
16
18
|
@autodiscover = true
|
17
19
|
@host = "0.0.0.0"
|
18
|
-
@port =
|
20
|
+
@port = nil
|
19
21
|
@output = Log4r::Outputter.stdout
|
20
22
|
end
|
21
23
|
|
data/lib/airplay/connection.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
require "celluloid"
|
1
|
+
require "celluloid/autostart"
|
2
2
|
require "airplay/connection/persistent"
|
3
3
|
require "airplay/connection/authentication"
|
4
4
|
|
@@ -7,6 +7,8 @@ module Airplay
|
|
7
7
|
#
|
8
8
|
class Connection
|
9
9
|
Response = Struct.new(:connection, :response)
|
10
|
+
PasswordRequired = Class.new(StandardError)
|
11
|
+
WrongPassword = Class.new(StandardError)
|
10
12
|
|
11
13
|
include Celluloid
|
12
14
|
|
@@ -43,11 +45,7 @@ module Airplay
|
|
43
45
|
# Returns a response object
|
44
46
|
#
|
45
47
|
def post(resource, body = "", headers = {})
|
46
|
-
|
47
|
-
request = Net::HTTP::Post.new(resource)
|
48
|
-
request.body = body
|
49
|
-
|
50
|
-
send_request(request, headers)
|
48
|
+
prepare_request(:post, resource, body, headers)
|
51
49
|
end
|
52
50
|
|
53
51
|
# Public: Executes a PUT to a resource
|
@@ -59,11 +57,7 @@ module Airplay
|
|
59
57
|
# Returns a response object
|
60
58
|
#
|
61
59
|
def put(resource, body = "", headers = {})
|
62
|
-
|
63
|
-
request = Net::HTTP::Put.new(resource)
|
64
|
-
request.body = body
|
65
|
-
|
66
|
-
send_request(request, headers)
|
60
|
+
prepare_request(:put, resource, body, headers)
|
67
61
|
end
|
68
62
|
|
69
63
|
# Public: Executes a GET to a resource
|
@@ -74,14 +68,34 @@ module Airplay
|
|
74
68
|
# Returns a response object
|
75
69
|
#
|
76
70
|
def get(resource, headers = {})
|
77
|
-
|
78
|
-
request = Net::HTTP::Get.new(resource)
|
79
|
-
|
80
|
-
send_request(request, headers)
|
71
|
+
prepare_request(:get, resource, nil, headers)
|
81
72
|
end
|
82
73
|
|
83
74
|
private
|
84
75
|
|
76
|
+
# Private: Prepares HTTP requests for :get, :post and :put
|
77
|
+
#
|
78
|
+
# verb - The http method/verb to use for the request
|
79
|
+
# resource - The resource on the currently active Device
|
80
|
+
# body - The body of the action
|
81
|
+
# headers - The headers of the request
|
82
|
+
#
|
83
|
+
# Returns a response object
|
84
|
+
#
|
85
|
+
def prepare_request(verb, resource, body, headers)
|
86
|
+
msg = "#{verb.upcase} #{resource}"
|
87
|
+
|
88
|
+
request = Net::HTTP.const_get(verb.capitalize).new(resource)
|
89
|
+
|
90
|
+
unless verb.eql?(:get)
|
91
|
+
request.body = body
|
92
|
+
msg.concat(" with #{body.bytesize} bytes")
|
93
|
+
end
|
94
|
+
|
95
|
+
@logger.info(msg)
|
96
|
+
send_request(request, headers)
|
97
|
+
end
|
98
|
+
|
85
99
|
# Private: The defaults connection headers
|
86
100
|
#
|
87
101
|
# Returns the default headers
|
@@ -105,14 +119,29 @@ module Airplay
|
|
105
119
|
request.initialize_http_header(default_headers.merge(headers))
|
106
120
|
|
107
121
|
if @device.password?
|
108
|
-
authentication = Airplay::Connection::Authentication.new(persistent)
|
122
|
+
authentication = Airplay::Connection::Authentication.new(@device, persistent)
|
109
123
|
request = authentication.sign(request)
|
110
124
|
end
|
111
125
|
|
112
126
|
@logger.info("Sending request to #{@device.address}")
|
113
127
|
response = persistent.request(request)
|
114
128
|
|
115
|
-
Airplay::Connection::Response.new(persistent, response)
|
129
|
+
verify_response(Airplay::Connection::Response.new(persistent, response))
|
130
|
+
end
|
131
|
+
|
132
|
+
# Private: Verifies response
|
133
|
+
#
|
134
|
+
# response - The Response object
|
135
|
+
#
|
136
|
+
# Returns a response object or exception
|
137
|
+
#
|
138
|
+
def verify_response(response)
|
139
|
+
if response.response.status == 401
|
140
|
+
return PasswordRequired.new if !@device.password?
|
141
|
+
return WrongPassword.new if @device.password?
|
142
|
+
end
|
143
|
+
|
144
|
+
response
|
116
145
|
end
|
117
146
|
end
|
118
147
|
end
|
@@ -2,7 +2,7 @@ require "net/http/digest_auth"
|
|
2
2
|
|
3
3
|
module Airplay
|
4
4
|
class Connection
|
5
|
-
|
5
|
+
Authentication = Struct.new(:device, :handler) do
|
6
6
|
def sign(request)
|
7
7
|
auth_token = authenticate(request)
|
8
8
|
request.add_field('Authorization', auth_token) if auth_token
|
@@ -12,11 +12,10 @@ module Airplay
|
|
12
12
|
private
|
13
13
|
|
14
14
|
def uri(request)
|
15
|
-
|
16
|
-
path = "http://#{server.address}#{request.path}"
|
15
|
+
path = "http://#{device.address}#{request.path}"
|
17
16
|
uri = URI.parse(path)
|
18
17
|
uri.user = "Airplay"
|
19
|
-
uri.password =
|
18
|
+
uri.password = device.password
|
20
19
|
|
21
20
|
uri
|
22
21
|
end
|
data/lib/airplay/device.rb
CHANGED
@@ -23,6 +23,8 @@ module Airplay
|
|
23
23
|
@type = attributes[:type]
|
24
24
|
@password = attributes[:password]
|
25
25
|
|
26
|
+
@it_has_password = false
|
27
|
+
|
26
28
|
Airplay.configuration.load
|
27
29
|
end
|
28
30
|
|
@@ -34,6 +36,19 @@ module Airplay
|
|
34
36
|
@_ip ||= address.split(":").first
|
35
37
|
end
|
36
38
|
|
39
|
+
# Public: Sets server information based on text records
|
40
|
+
#
|
41
|
+
# Returns text records hash.
|
42
|
+
#
|
43
|
+
def text_records=(record)
|
44
|
+
@text_records = {
|
45
|
+
"model" => record["model"],
|
46
|
+
"features" => record["features"],
|
47
|
+
"macAddress" => record["deviceid"],
|
48
|
+
"srcvers" => record["srcvers"]
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
37
52
|
# Public: Sets the password for the device
|
38
53
|
#
|
39
54
|
# passwd - The password string
|
@@ -49,6 +64,7 @@ module Airplay
|
|
49
64
|
# Returns boolean for the presence of a password
|
50
65
|
#
|
51
66
|
def password?
|
67
|
+
return @it_has_password if @it_has_password
|
52
68
|
!!password && !password.empty?
|
53
69
|
end
|
54
70
|
|
@@ -102,26 +118,30 @@ module Airplay
|
|
102
118
|
@_connection = nil
|
103
119
|
end
|
104
120
|
|
105
|
-
|
106
|
-
|
107
|
-
# Private: Validates the mandatory attributes for a device
|
121
|
+
# Public: The unique id of the device (mac address)
|
108
122
|
#
|
109
|
-
#
|
123
|
+
# Returns the mac address based on basic_info or server_info
|
110
124
|
#
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
if !([:name, :address] - attributes.keys).empty?
|
115
|
-
raise MissingAttributes.new("A :name and an :address are mandatory")
|
125
|
+
def id
|
126
|
+
@_id ||= begin
|
127
|
+
basic_info.fetch("macAddress", server_info["macAddress"])
|
116
128
|
end
|
117
129
|
end
|
118
130
|
|
131
|
+
private
|
132
|
+
|
133
|
+
def it_has_password!
|
134
|
+
@it_has_password = true
|
135
|
+
end
|
136
|
+
|
119
137
|
# Private: Access the basic info of the device
|
120
138
|
#
|
121
139
|
# Returns a hash with the basic information
|
122
140
|
#
|
123
141
|
def basic_info
|
124
142
|
@_basic_info ||= begin
|
143
|
+
return @text_records if @text_records
|
144
|
+
|
125
145
|
response = connection.get("/server-info").response
|
126
146
|
plist = CFPropertyList::List.new(data: response.body)
|
127
147
|
CFPropertyList.native_types(plist.value)
|
@@ -133,16 +153,36 @@ module Airplay
|
|
133
153
|
# Returns a hash with extra information
|
134
154
|
#
|
135
155
|
def extra_info
|
136
|
-
@_extra_info ||=
|
137
|
-
|
138
|
-
|
139
|
-
|
156
|
+
@_extra_info ||=
|
157
|
+
begin
|
158
|
+
new_device = clone
|
159
|
+
new_device.refresh_connection
|
160
|
+
new_device.address = "#{ip}:7100"
|
140
161
|
|
141
|
-
|
142
|
-
|
162
|
+
result = new_device.connection.get("/stream.xml")
|
163
|
+
raise result if !result.is_a?(Airplay::Connection::Response)
|
143
164
|
|
144
|
-
|
145
|
-
|
165
|
+
response = result.response
|
166
|
+
return {} if response.status != 200
|
167
|
+
|
168
|
+
plist = CFPropertyList::List.new(data: response.body)
|
169
|
+
CFPropertyList.native_types(plist.value)
|
170
|
+
rescue Airplay::Connection::PasswordRequired
|
171
|
+
it_has_password!
|
172
|
+
|
173
|
+
return {}
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# Private: Validates the mandatory attributes for a device
|
178
|
+
#
|
179
|
+
# attributes - The attributes hash to be validated
|
180
|
+
#
|
181
|
+
# Returns nothing or raises a MissingAttributes if some key is missing
|
182
|
+
#
|
183
|
+
def validate_attributes(attributes)
|
184
|
+
if !([:name, :address] - attributes.keys).empty?
|
185
|
+
raise MissingAttributes.new("A :name and an :address are mandatory")
|
146
186
|
end
|
147
187
|
end
|
148
188
|
end
|
data/lib/airplay/device/info.rb
CHANGED
@@ -10,7 +10,7 @@ module Airplay
|
|
10
10
|
def initialize(device)
|
11
11
|
@device = device
|
12
12
|
@model = device.server_info["model"]
|
13
|
-
@os_version = device.server_info["
|
13
|
+
@os_version = device.server_info["srcvers"]
|
14
14
|
@mac_address = device.server_info["macAddress"]
|
15
15
|
end
|
16
16
|
|
data/lib/airplay/devices.rb
CHANGED
data/lib/airplay/player.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require "uri"
|
2
2
|
require "forwardable"
|
3
3
|
require "micromachine"
|
4
|
-
require "celluloid"
|
4
|
+
require "celluloid/autostart"
|
5
5
|
require "cfpropertylist"
|
6
6
|
|
7
7
|
require "airplay/connection"
|
@@ -130,7 +130,7 @@ module Airplay
|
|
130
130
|
#
|
131
131
|
def scrub
|
132
132
|
return unless playing?
|
133
|
-
response = connection.get("/scrub")
|
133
|
+
response = connection.get("/scrub").response
|
134
134
|
parts = response.body.split("\n")
|
135
135
|
Hash[parts.collect { |v| v.split(": ") }]
|
136
136
|
end
|
@@ -170,6 +170,14 @@ module Airplay
|
|
170
170
|
connection.post("/stop")
|
171
171
|
end
|
172
172
|
|
173
|
+
# Public: Seeks to the specified position (seconds) in the video
|
174
|
+
#
|
175
|
+
# Returns nothing
|
176
|
+
#
|
177
|
+
def seek(position)
|
178
|
+
connection.async.post("/scrub?position=#{position}")
|
179
|
+
end
|
180
|
+
|
173
181
|
def loading?; state == :loading end
|
174
182
|
def playing?; state == :playing end
|
175
183
|
def paused?; state == :paused end
|
data/lib/airplay/server.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
require "rack"
|
2
2
|
require "socket"
|
3
|
-
require "celluloid"
|
3
|
+
require "celluloid/autostart"
|
4
4
|
require "reel/rack"
|
5
5
|
|
6
6
|
require "airplay/logger"
|
@@ -10,8 +10,10 @@ module Airplay
|
|
10
10
|
class Server
|
11
11
|
include Celluloid
|
12
12
|
|
13
|
+
attr_reader :port
|
14
|
+
|
13
15
|
def initialize
|
14
|
-
@port = Airplay.configuration.port
|
16
|
+
@port = Airplay.configuration.port || find_free_port
|
15
17
|
@logger = Airplay::Logger.new("airplay::server")
|
16
18
|
@server = Rack::Server.new(
|
17
19
|
server: :reel,
|
@@ -53,9 +55,9 @@ module Airplay
|
|
53
55
|
#
|
54
56
|
# Returns a boolean with the state
|
55
57
|
#
|
56
|
-
def running?
|
58
|
+
def running?(port = @port)
|
57
59
|
begin
|
58
|
-
socket = TCPSocket.new(private_ip,
|
60
|
+
socket = TCPSocket.new(private_ip, port)
|
59
61
|
socket.close unless socket.nil?
|
60
62
|
true
|
61
63
|
rescue Errno::ECONNREFUSED, Errno::EBADF, Errno::EADDRNOTAVAIL
|
@@ -72,5 +74,17 @@ module Airplay
|
|
72
74
|
addr.ipv4_private?
|
73
75
|
end.ip_address
|
74
76
|
end
|
77
|
+
|
78
|
+
# Private: Finds a free port by asking the kernel for a free one
|
79
|
+
#
|
80
|
+
# Returns a free port number
|
81
|
+
#
|
82
|
+
def find_free_port
|
83
|
+
socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
|
84
|
+
socket.listen(1)
|
85
|
+
port = socket.local_address.ip_port
|
86
|
+
socket.close
|
87
|
+
port
|
88
|
+
end
|
75
89
|
end
|
76
90
|
end
|
data/lib/airplay/version.rb
CHANGED