airplay 0.2.9 → 1.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (82) hide show
  1. data/Gemfile +5 -0
  2. data/Gemfile.lock +68 -0
  3. data/HUGS +15 -0
  4. data/LICENSE +20 -0
  5. data/README.md +50 -72
  6. data/Rakefile +6 -9
  7. data/SPEC.md +34 -0
  8. data/airplay.gemspec +28 -16
  9. data/bin/air +27 -0
  10. data/examples/demo.rb +35 -0
  11. data/examples/image.rb +9 -0
  12. data/examples/video.rb +24 -0
  13. data/lib/airplay.rb +38 -17
  14. data/lib/airplay/browser.rb +68 -0
  15. data/lib/airplay/cli.rb +104 -0
  16. data/lib/airplay/configuration.rb +29 -0
  17. data/lib/airplay/connection.rb +114 -0
  18. data/lib/airplay/connection/authentication.rb +37 -0
  19. data/lib/airplay/connection/persistent.rb +44 -0
  20. data/lib/airplay/device.rb +81 -0
  21. data/lib/airplay/device/features.rb +43 -0
  22. data/lib/airplay/device/info.rb +22 -0
  23. data/lib/airplay/devices.rb +46 -0
  24. data/lib/airplay/logger.rb +20 -0
  25. data/lib/airplay/playable.rb +24 -0
  26. data/lib/airplay/protocol.rb +9 -72
  27. data/lib/airplay/protocol/app.rb +52 -0
  28. data/lib/airplay/protocol/message.rb +8 -0
  29. data/lib/airplay/protocol/playback_info.rb +49 -0
  30. data/lib/airplay/protocol/player.rb +170 -0
  31. data/lib/airplay/protocol/reverse.rb +69 -0
  32. data/lib/airplay/protocol/slideshow.rb +66 -0
  33. data/lib/airplay/protocol/timers.rb +25 -0
  34. data/lib/airplay/protocol/viewer.rb +56 -0
  35. data/lib/airplay/structure.rb +7 -0
  36. data/lib/airplay/viewable.rb +16 -0
  37. data/test/fixtures/cassettes/airplay/listing_slideshow_features.yml +201 -0
  38. data/test/fixtures/cassettes/airplay/play_an_entire_video.yml +100 -0
  39. data/test/fixtures/cassettes/airplay/sending_a_video.yml +71 -0
  40. data/test/fixtures/cassettes/airplay/sending_an_image.yml +26439 -0
  41. data/test/fixtures/cassettes/airplay/stop_any_transmission.yml +8851 -0
  42. data/test/fixtures/files/logo.png +0 -0
  43. data/test/fixtures/files/transition_0.png +0 -0
  44. data/test/fixtures/files/transition_1.png +0 -0
  45. data/test/fixtures/files/transition_2.png +0 -0
  46. data/test/fixtures/files/transition_3.png +0 -0
  47. data/test/integration/discovery_test.rb +13 -0
  48. data/test/integration/features_test.rb +14 -0
  49. data/test/integration/send_media_test.rb +37 -0
  50. data/test/integration/slideshow_test.rb +26 -0
  51. data/test/test_helper.rb +42 -0
  52. data/test/unit/node_test.rb +17 -0
  53. data/test/unit/protocol_test.rb +44 -0
  54. metadata +247 -77
  55. data/.gitignore +0 -2
  56. data/.travis.yml +0 -6
  57. data/examples/jobs.jpg +0 -0
  58. data/examples/send_image.rb +0 -5
  59. data/examples/send_video.rb +0 -7
  60. data/lib/airplay/client.rb +0 -60
  61. data/lib/airplay/protocol/image.rb +0 -41
  62. data/lib/airplay/protocol/media.rb +0 -64
  63. data/lib/airplay/protocol/scrub.rb +0 -37
  64. data/lib/airplay/server.rb +0 -2
  65. data/lib/airplay/server/browser.rb +0 -35
  66. data/lib/airplay/server/node.rb +0 -7
  67. data/test/authentication.rb +0 -13
  68. data/test/discovery.rb +0 -27
  69. data/test/fixtures/cassettes/airplay/authenticate_all_the_things_.yml +0 -139
  70. data/test/fixtures/cassettes/airplay/control_a_video_being_played_in_apple_tv.yml +0 -242
  71. data/test/fixtures/cassettes/airplay/get_current_scrub_from_apple_tv.yml +0 -69
  72. data/test/fixtures/cassettes/airplay/go_to_a_given_position_in_the_video.yml +0 -157
  73. data/test/fixtures/cassettes/airplay/send_audio_to_apple_tv.yml +0 -33
  74. data/test/fixtures/cassettes/airplay/send_image_to_apple_tv.yml +0 -661
  75. data/test/fixtures/cassettes/airplay/send_image_to_apple_tv_with_effects.yml +0 -591
  76. data/test/fixtures/cassettes/airplay/send_video_to_apple_tv.yml +0 -33
  77. data/test/fixtures/image.gif +0 -0
  78. data/test/fixtures/image2.gif +0 -0
  79. data/test/helper.rb +0 -31
  80. data/test/images.rb +0 -31
  81. data/test/media.rb +0 -47
  82. data/test/scrub.rb +0 -31
@@ -0,0 +1,44 @@
1
+ require "net/ptth"
2
+ require "uuid"
3
+
4
+ module Airplay
5
+ # Public: The class that handles all the outgoing basic HTTP connections
6
+ #
7
+ class Connection
8
+ attr_reader :session
9
+
10
+ # Public: Class that wraps a persistent connection to point to the airplay
11
+ # server and other configuration
12
+ #
13
+ class Persistent
14
+ def initialize(address, options = {})
15
+ @logger = Airplay::Logger.new("airplay::connection::persistent")
16
+ @socket = Net::PTTH.new(address, options)
17
+ @socket.set_debug_output = @logger
18
+
19
+ @socket.socket
20
+ end
21
+
22
+ def session
23
+ @_session ||= UUID.generate
24
+ end
25
+
26
+ def close
27
+ socket.close
28
+ end
29
+
30
+ def socket
31
+ @socket.socket
32
+ end
33
+
34
+ # Public: send a request to the active server
35
+ #
36
+ # request - The Net::HTTP request to be executed
37
+ # &block - An optional block to be executed within the block
38
+ #
39
+ def request(request)
40
+ @socket.request(request)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,81 @@
1
+ require "airplay/playable"
2
+ require "airplay/viewable"
3
+ require "airplay/device/features"
4
+ require "airplay/device/info"
5
+
6
+ module Airplay
7
+ # Public: Represents an Airplay Node
8
+ #
9
+ class Device
10
+ attr_reader :name, :address, :password
11
+
12
+ include Playable
13
+ include Viewable
14
+
15
+ def initialize(attributes = {})
16
+ @name = attributes[:name]
17
+ @address = attributes[:address]
18
+ @password = attributes[:password]
19
+
20
+ Airplay.configuration.load
21
+ end
22
+
23
+ def ip
24
+ @_ip ||= address.split(":").first
25
+ end
26
+
27
+ def password=(passwd)
28
+ @password = passwd
29
+ end
30
+
31
+ def password?
32
+ !!password && !password.empty?
33
+ end
34
+
35
+ def address=(address)
36
+ @address = address
37
+ end
38
+
39
+ def features
40
+ @_features ||= Features.new(self)
41
+ end
42
+
43
+ def info
44
+ @_info ||= Info.new(self)
45
+ end
46
+
47
+ def server_info
48
+ @_server_info ||= basic_info.merge(extra_info)
49
+ end
50
+
51
+ def connection
52
+ @_connection ||= Airplay::Connection.new(self)
53
+ end
54
+
55
+ def refresh_connection
56
+ @_connection = nil
57
+ end
58
+
59
+ private
60
+
61
+ def basic_info
62
+ @_basic_info ||= begin
63
+ response = connection.get("/server-info").response
64
+ plist = CFPropertyList::List.new(data: response.body)
65
+ CFPropertyList.native_types(plist.value)
66
+ end
67
+ end
68
+
69
+ def extra_info
70
+ @_extra_info ||= begin
71
+ new_device = clone
72
+ new_device.refresh_connection
73
+ new_device.address = "#{ip}:7100"
74
+
75
+ response = new_device.connection.get("/stream.xml").response
76
+ plist = CFPropertyList::List.new(data: response.body)
77
+ CFPropertyList.native_types(plist.value)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,43 @@
1
+ module Airplay
2
+ # Public: Represents an Airplay Node
3
+ #
4
+ class Device
5
+ # Public: The feature list of a given device
6
+ #
7
+ class Features
8
+ attr_reader :properties
9
+
10
+ def initialize(device)
11
+ @device = device
12
+ check_features
13
+ end
14
+
15
+ private
16
+
17
+ def check_features
18
+ hex = @device.server_info["features"].to_s.hex
19
+ @properties = {
20
+ video?: 0 < (hex & ( 1 << 0 )),
21
+ photo?: 0 < (hex & ( 1 << 1 )),
22
+ video_fair_play?: 0 < (hex & ( 1 << 2 )),
23
+ video_volume_control?: 0 < (hex & ( 1 << 3 )),
24
+ video_http_live_streamr?: 0 < (hex & ( 1 << 4 )),
25
+ slideshow?: 0 < (hex & ( 1 << 5 )),
26
+ screen?: 0 < (hex & ( 1 << 7 )),
27
+ screen_rotate?: 0 < (hex & ( 1 << 8 )),
28
+ audio?: 0 < (hex & ( 1 << 9 )),
29
+ audio_redundant?: 0 < (hex & ( 1 << 11 )),
30
+ FPSAPv2pt5_AES_GCM?: 0 < (hex & ( 1 << 12 )),
31
+ photo_caching?: 0 < (hex & ( 1 << 13 ))
32
+ }
33
+
34
+ @properties.each do |key, value|
35
+ self.class.send(:define_method, key) { value }
36
+ end
37
+
38
+ end
39
+
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ module Airplay
2
+ # Public: Represents an Airplay Device
3
+ #
4
+ class Device
5
+ # Public: Simple class to represent information of a Device
6
+ #
7
+ class Info
8
+ attr_reader :model, :os_version, :mac_address
9
+
10
+ def initialize(device)
11
+ @device = device
12
+ @model = device.server_info["model"]
13
+ @os_version = device.server_info["osBuildVersion"]
14
+ @mac_address = device.server_info["macAddress"]
15
+ end
16
+
17
+ def resolution
18
+ @_resolution ||= "#{@device.server_info["width"]}x#{@device.server_info["height"]}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,46 @@
1
+ require "forwardable"
2
+ require "airplay/device"
3
+
4
+ module Airplay
5
+ # Public: Represents an array of devices
6
+ #
7
+ class Devices
8
+ include Enumerable
9
+ extend Forwardable
10
+
11
+ def_delegators :@items, :each, :size, :empty?
12
+
13
+ def initialize
14
+ @items = []
15
+ end
16
+
17
+ # Public: Finds a device given a name
18
+ #
19
+ # device_name - The name of the device
20
+ #
21
+ # Returns a Node object
22
+ #
23
+ def find_by_name(device_name)
24
+ find_by_block { |device| device if device.name == device_name }
25
+ end
26
+
27
+ def find_by_ip(ip)
28
+ find_by_block { |device| device if device.ip == ip }
29
+ end
30
+
31
+ # Public: Adds a device to the list
32
+ #
33
+ # value - The Node
34
+ def <<(device)
35
+ return if find_by_ip(device.ip)
36
+ @items << device
37
+ end
38
+
39
+ private
40
+
41
+ def find_by_block(&block)
42
+ @items.find(&block)
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ require "log4r"
2
+
3
+ module Airplay
4
+ # Public: A Log4r wrapper
5
+ #
6
+ class Logger < Log4r::Logger
7
+ def initialize(*)
8
+ super
9
+ add Airplay.configuration.output
10
+ end
11
+
12
+ # Public: Helper method to be compatible with Net::HTTP
13
+ #
14
+ # message - The message to be logged as debug
15
+ #
16
+ def <<(message)
17
+ debug message
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ require "airplay/protocol/player"
2
+
3
+ module Airplay
4
+ module Playable
5
+
6
+ # Public: Plays a given video
7
+ #
8
+ # file_or_url - The video to be played
9
+ # options - Optional start position
10
+ #
11
+ # Returns a Player object to control the playback
12
+ #
13
+ def play(file_or_url, options = {})
14
+ player.async.play(file_or_url, options)
15
+ player
16
+ end
17
+
18
+ private
19
+
20
+ def player
21
+ @_player ||= Airplay::Protocol::Player.new(self)
22
+ end
23
+ end
24
+ end
@@ -1,75 +1,12 @@
1
- class Airplay::Protocol
2
- attr_reader :host, :resource, :request
3
-
4
- DEFAULT_HEADERS = { "User-Agent" => "MediaControl/1.0" }
5
- SEARCH = '_airplay._tcp.'
6
- PORT = 7000
7
-
8
- def initialize(host, port, password)
9
- @device = { :host => host, :port => port }
10
- @password = password
11
- @authentications = {}
12
- @http = Net::HTTP::Persistent.new
13
- @http.idle_timeout = nil
14
- @http.debug_output = $stdout if ENV.has_key?('HTTP_DEBUG')
15
- end
16
-
17
- def put(resource, body = nil, headers = {})
18
- @request = Net::HTTP::Put.new resource
19
- @request.body = body
20
- @request.initialize_http_header DEFAULT_HEADERS.merge(headers)
21
- make_request
22
- end
23
-
24
- def post(resource, body = nil, headers = {})
25
- @request = Net::HTTP::Post.new resource
26
- @request.body = body
27
- @request.initialize_http_header DEFAULT_HEADERS.merge(headers)
28
- make_request
1
+ module Airplay
2
+ # Public: The protocol module
3
+ #
4
+ module Protocol
29
5
  end
30
-
31
- def get(resource, headers = {})
32
- @request = Net::HTTP::Get.new resource
33
- @request.initialize_http_header DEFAULT_HEADERS.merge(headers)
34
- make_request
35
- end
36
-
37
- private
38
-
39
- def make_request
40
- path = "http://#{@device[:host]}:#{@device[:port]}#{@request.path}"
41
- @uri = URI.parse(path)
42
- @uri.user = "Airplay"
43
- @uri.password = @password
44
-
45
- add_auth_if_needed
46
-
47
- response = @http.request(@uri, @request) {}
48
-
49
- raise Airplay::Protocol::InvalidRequestError if response.code == "404"
50
- response.body
51
- end
52
-
53
- def add_auth_if_needed
54
- if @password
55
- authenticate
56
- @request.add_field('Authorization', @authentications[@uri.path])
57
- end
58
- end
59
-
60
- def authenticate
61
- response = @http.request(@uri, @request) {}
62
- auth = response['www-authenticate']
63
- digest_authentication(auth) if auth
64
- end
65
-
66
- def digest_authentication(auth)
67
- digest = Net::HTTP::DigestAuth.new
68
- @authentications[@uri.path] ||=
69
- digest.auth_header(@uri, auth, @request.method)
70
- end
71
-
72
6
  end
73
7
 
74
- class Airplay::Protocol::InvalidRequestError < StandardError; end
75
- class Airplay::Protocol::InvalidMediaError < StandardError; end
8
+ require "airplay/protocol/message"
9
+ require "airplay/protocol/image"
10
+ require "airplay/protocol/reverse"
11
+ require "airplay/protocol/slideshow"
12
+ require "airplay/protocol/player"
@@ -0,0 +1,52 @@
1
+ require "cuba"
2
+ require "base64"
3
+ require "cfpropertylist"
4
+
5
+ module Airplay::Protocol
6
+ # Public: The app to be mounted on the reverse connection
7
+ #
8
+ class App < Cuba
9
+ class << self
10
+ attr_accessor :pipeline
11
+ end
12
+
13
+ def pipe
14
+ self.class.pipeline.mailbox
15
+ end
16
+
17
+ define do
18
+ on post, "event" do
19
+ plist = CFPropertyList::List.new(data: req.body.read)
20
+ native_plist = CFPropertyList.native_types(plist.value)
21
+
22
+ message = Message.create(
23
+ type: :event,
24
+ content: native_plist
25
+ )
26
+
27
+ pipe << message
28
+ end
29
+
30
+ on "slideshows/1/assets/:id" do |id|
31
+ image = File.read("test/fixtures/files/logo.png")
32
+ image.force_encoding("BINARY")
33
+
34
+ data = {
35
+ data: CFPropertyList::Blob.new(image),
36
+ info: {
37
+ id: 1,
38
+ key: id
39
+ }
40
+ }
41
+
42
+ res["Content-Type"] = "application/x-apple-binary-plist"
43
+
44
+ plist = CFPropertyList::List.new
45
+ plist.value = CFPropertyList.guess(data)
46
+ binary_plist = plist.to_str
47
+
48
+ res.write binary_plist
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,8 @@
1
+ require "airplay/structure"
2
+
3
+ module Airplay::Protocol
4
+ # Public: Basic class to represent a message from an event callback
5
+ #
6
+ class Message < Structure.new(:type, :content)
7
+ end
8
+ end
@@ -0,0 +1,49 @@
1
+ module Airplay::Protocol
2
+ PlaybackInfo = Struct.new(:info) do
3
+ def uuid
4
+ info["uuid"]
5
+ end
6
+
7
+ def duration
8
+ info["duration"]
9
+ end
10
+
11
+ def position
12
+ info["position"]
13
+ end
14
+
15
+ def percent
16
+ return unless position && duration
17
+ (position * 100 / duration).floor
18
+ end
19
+
20
+ def likely_to_keep_up?
21
+ info["playbackLikelyToKeepUp"]
22
+ end
23
+
24
+ def stall_count
25
+ info["stallCount"]
26
+ end
27
+
28
+ def ready_to_play?
29
+ info["readyToPlay"]
30
+ end
31
+
32
+ def stopped?
33
+ info.empty?
34
+ end
35
+
36
+ def playing?
37
+ info.has_key?("rate") && info.fetch("rate", false) && !info["rate"].zero?
38
+ end
39
+
40
+ def paused?
41
+ !playing?
42
+ end
43
+
44
+ def played?
45
+ # This is weird. I know. Bear with me.
46
+ info.keys.size == 2
47
+ end
48
+ end
49
+ end