├── .ruby-version ├── .gitignore ├── lib ├── airplay │ ├── version.rb │ ├── cli │ │ ├── version.rb │ │ ├── doctor.rb │ │ └── image_viewer.rb │ ├── server │ │ └── app.rb │ ├── viewable.rb │ ├── logger.rb │ ├── player │ │ ├── timers.rb │ │ ├── playlist.rb │ │ ├── playback_info.rb │ │ └── media.rb │ ├── device │ │ ├── info.rb │ │ └── features.rb │ ├── group │ │ └── players.rb │ ├── playable.rb │ ├── connection │ │ ├── authentication.rb │ │ └── persistent.rb │ ├── configuration.rb │ ├── group.rb │ ├── devices.rb │ ├── server.rb │ ├── browser.rb │ ├── viewer.rb │ ├── connection.rb │ ├── device.rb │ ├── cli.rb │ └── player.rb └── airplay.rb ├── doc ├── img │ ├── logo.png │ ├── block_tv.jpg │ ├── cli_list.png │ └── cli_play.png ├── installation.md ├── contributors.md ├── documentation.md ├── toc.md ├── testing.md ├── header.md └── usage.md ├── test ├── fixtures │ └── files │ │ ├── logo.png │ │ ├── transition_0.png │ │ ├── transition_1.png │ │ ├── transition_2.png │ │ └── transition_3.png ├── test_helper.rb ├── unit │ ├── device_test.rb │ ├── player │ │ └── media_test.rb │ └── configuration_test.rb ├── integration_helper.rb └── integration │ ├── fetching_device_information_test.rb │ └── view_images_test.rb ├── Gemfile ├── HUGS ├── .travis.yml ├── airplay-cli.gemspec ├── LICENSE ├── bin └── air ├── airplay.gemspec ├── Rakefile ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.6 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | .env 3 | -------------------------------------------------------------------------------- /lib/airplay/version.rb: -------------------------------------------------------------------------------- 1 | module Airplay 2 | VERSION = "1.0.5" 3 | end 4 | -------------------------------------------------------------------------------- /doc/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/doc/img/logo.png -------------------------------------------------------------------------------- /doc/img/block_tv.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/doc/img/block_tv.jpg -------------------------------------------------------------------------------- /doc/img/cli_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/doc/img/cli_list.png -------------------------------------------------------------------------------- /doc/img/cli_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/doc/img/cli_play.png -------------------------------------------------------------------------------- /lib/airplay/cli/version.rb: -------------------------------------------------------------------------------- 1 | module Airplay 2 | module CLI 3 | VERSION = "1.0.3" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/files/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/test/fixtures/files/logo.png -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec name: 'airplay' 4 | gemspec name: 'airplay-cli' 5 | 6 | gem "rake" 7 | -------------------------------------------------------------------------------- /test/fixtures/files/transition_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/test/fixtures/files/transition_0.png -------------------------------------------------------------------------------- /test/fixtures/files/transition_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/test/fixtures/files/transition_1.png -------------------------------------------------------------------------------- /test/fixtures/files/transition_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/test/fixtures/files/transition_2.png -------------------------------------------------------------------------------- /test/fixtures/files/transition_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elcuervo/airplay/HEAD/test/fixtures/files/transition_3.png -------------------------------------------------------------------------------- /doc/installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ### Library 4 | 5 | `gem install airplay` 6 | 7 | ## CLI 8 | 9 | `gem install airplay-cli` 10 | -------------------------------------------------------------------------------- /doc/contributors.md: -------------------------------------------------------------------------------- 1 | ## Contributors 2 | 3 | Last but not least a special thanks to all the [contributors](https://github.com/elcuervo/airplay/graphs/contributors) 4 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "minitest/spec" 3 | require "minitest/pride" 4 | require "minitest/autorun" 5 | require "minitest/given" 6 | -------------------------------------------------------------------------------- /doc/documentation.md: -------------------------------------------------------------------------------- 1 | ## Documentation 2 | 3 | All the documentation of the README can be found in the `doc` folder. 4 | To generate an updated README based on the contents of `doc` please use `rake doc:generate` 5 | -------------------------------------------------------------------------------- /doc/toc.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | * [Contribute](#contribute) 4 | * [Installation](#installation) 5 | * [Usage](#usage) 6 | * [CLI](#cli-1) 7 | * [Library](#library-1) 8 | * [Testing](#testing) 9 | * [Documentation](#documentation) 10 | * [Contributors](#contributors) 11 | -------------------------------------------------------------------------------- /HUGS: -------------------------------------------------------------------------------- 1 | THE HUGWARE LICENSE 2 | 3 | LICENSE 4 | 5 | If there is no other license you can do whatever you want with this while you 6 | retain the attribution to the author. 7 | 8 | HUGS 9 | 10 | The author spent time to make this software so please show some gratitude, 11 | in any 12 | form. A hug, a tweet, a beer on a conference or just a plain old email. Your 13 | choice. 14 | 15 | Less hate, more hugs. 16 | -------------------------------------------------------------------------------- /lib/airplay/server/app.rb: -------------------------------------------------------------------------------- 1 | require "cuba" 2 | require "securerandom" 3 | 4 | module Airplay 5 | class Server 6 | class App < Cuba 7 | settings[:assets] ||= Hash.new do |h, k| 8 | h[k] = SecureRandom.uuid 9 | end 10 | 11 | define do 12 | on "assets/:uuid" do |uuid| 13 | run Rack::File.new(settings[:assets].key(uuid)) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/airplay/viewable.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "airplay/viewer" 3 | 4 | module Airplay 5 | module Viewable 6 | extend Forwardable 7 | 8 | def_delegators :viewer, :view, :transitions 9 | 10 | private 11 | 12 | # Private: The Viewer handler 13 | # 14 | # Returns a Viewer object 15 | # 16 | def viewer 17 | @_viewer ||= Airplay::Viewer.new(self) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/unit/device_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "airplay/device" 3 | 4 | describe Airplay::Device do 5 | context "a Device must have at least a name and an address" do 6 | When(:result) { Airplay::Device.new name: "Test" } 7 | Then { result == Failure(Airplay::Device::MissingAttributes) } 8 | 9 | When(:result) { Airplay::Device.new address: "192.168.1.1:80" } 10 | Then { result == Failure(Airplay::Device::MissingAttributes) } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/airplay/logger.rb: -------------------------------------------------------------------------------- 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 | # Returns nothing 17 | # 18 | def <<(message) 19 | debug message 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/airplay/player/timers.rb: -------------------------------------------------------------------------------- 1 | module Airplay 2 | class Player 3 | class Timers 4 | include Enumerable 5 | extend Forwardable 6 | 7 | def_delegators :@timers, :each, :size, :empty? 8 | 9 | def initialize 10 | @timers = [] 11 | end 12 | 13 | def <<(timer) 14 | @timers << timer 15 | end 16 | 17 | def reset 18 | @timers.each { |t| t.reset } 19 | @timers = [] 20 | end 21 | 22 | def cancel 23 | @timers.each { |t| t.cancel } 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/unit/player/media_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "airplay/player/media" 3 | 4 | describe Airplay::Player::Media do 5 | context "finding if a media is compatible" do 6 | Given(:media) { Airplay::Player::Media.new("test.mov") } 7 | When(:compatibility) { media.compatible? } 8 | Then { compatibility == true } 9 | end 10 | 11 | context "finding if a media is not compatible" do 12 | Given(:media) { Airplay::Player::Media.new("test.psd") } 13 | When(:compatibility) { media.compatible? } 14 | Then { compatibility == false } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.1.0 5 | before_install: 6 | - sudo apt-get -qq update 7 | - sudo apt-get -qq install rdnssd libavahi-compat-libdnssd-dev 8 | script: bundle exec rake 9 | env: 10 | global: 11 | - secure: PDEIoCg7nim32QGeJKvWcpcTEB0jOSfrH+v5ebrO4ioqznUfoaf1qypWNHrXYY4hINA+L5Tm6HXAPah2OhFkBxZzIlKgWlH1MIx5y6Q7PyBN6MTZMw1GN7sTm+zy9z79ytjdtepI4rlKVSxhAwl3Uid7mNliZx6T8eOP634Rg9s= 12 | - secure: EXq4zIfoWXu7u6gc/DksqAw82zAhIQQvotN33nGA7fh/zuJrHv5/a/7lYd6MghbiSsQp0+Kx/uJifzikjoTp3us1fWUxP9u+DRsuulTwpxG3tIg1JyHz6eY+5XTEdc8G0AFIH0Wh88OLw45DkR+m3PN24pwRNk8X6C2VdotNVvE= 13 | -------------------------------------------------------------------------------- /doc/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Now there are two types of tests: Regular unit tests and integration tests. 4 | Thanks to the magic of the internet and a raspberry pi there are integration 5 | tests with a real Apple TV that is currently accessible. 6 | 7 | ![Block TV](doc/img/block_tv.jpg) 8 | 9 | The Apple TV is password protected to avoid issues with the tests but is 10 | configured in Travis CI. For that reason you won't be able to run those tests if 11 | you don't have an Apple TV. 12 | 13 | Run unit tests with: `rake test:unit` and integration ones with: `rake test:integration` 14 | You can run all of them together with: `rake test:all` 15 | -------------------------------------------------------------------------------- /test/integration_helper.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "airplay" 3 | require "celluloid/autostart" 4 | 5 | device = ENV.fetch("TEST_TV_URL", "some-apple-tv:7000") 6 | Airplay.configure { |c| c.autodiscover = false } 7 | Airplay.devices.add("Block TV", device) 8 | 9 | def test_device 10 | @_device ||= begin 11 | device = Airplay["Block TV"] 12 | device.password = ENV["TEST_TV_PASSWORD"] 13 | device 14 | end 15 | end 16 | 17 | def sample_images 18 | @_images ||= Dir.glob("test/fixtures/files/*.png") 19 | end 20 | 21 | def sample_video 22 | @_video ||= "http://movietrailers.apple.com/movies/universal/rush/rush-tlr3_480p.mov" 23 | end 24 | -------------------------------------------------------------------------------- /test/integration/fetching_device_information_test.rb: -------------------------------------------------------------------------------- 1 | require "integration_helper" 2 | 3 | describe "Getting information from a device" do 4 | Given(:device) { test_device } 5 | 6 | context "getting the id" do 7 | When(:id) { device.id } 8 | Then { id == "58:55:CA:1F:3E:80" } 9 | end 10 | 11 | context "getting some minimun info" do 12 | When(:min_info) { device.info } 13 | Then { min_info.model == "AppleTV2,1" } 14 | And { min_info.respond_to?(:os_version) } 15 | And { min_info.respond_to?(:mac_address) } 16 | end 17 | 18 | context "getting server information" do 19 | When(:full_info) { device.server_info } 20 | Then { full_info.keys.size >= 15 } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/airplay/device/info.rb: -------------------------------------------------------------------------------- 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["srcvers"] 14 | @mac_address = device.server_info["macAddress"] 15 | end 16 | 17 | def resolution 18 | @_resolution ||= begin 19 | "#{@device.server_info["width"]}x#{@device.server_info["height"]}" 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/airplay/group/players.rb: -------------------------------------------------------------------------------- 1 | module Airplay 2 | class Group 3 | class Players 4 | attr_reader :players 5 | 6 | def initialize(players) 7 | @players = players 8 | end 9 | 10 | def progress(callback) 11 | players.each do |player| 12 | player.progress -> info { 13 | callback.call(player.device, info) if player.playing? 14 | } 15 | end 16 | end 17 | 18 | def wait 19 | sleep 0.1 while still_playing? 20 | players.map(&:cleanup) 21 | end 22 | 23 | private 24 | 25 | def still_playing? 26 | states = players.map { |player| !player.played? || player.stopped? } 27 | states.uniq == [true] 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /airplay-cli.gemspec: -------------------------------------------------------------------------------- 1 | $: << File.expand_path("../lib", __FILE__) 2 | require "airplay/cli/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "airplay-cli" 6 | s.version = Airplay::CLI::VERSION 7 | s.summary = "Airplay CLI" 8 | s.description = "Send pics and videos using the terminal" 9 | s.executables = "air" 10 | s.licenses = ["MIT", "HUGWARE"] 11 | s.authors = ["elcuervo"] 12 | s.email = ["yo@brunoaguirre.com"] 13 | s.homepage = "http://github.com/elcuervo/airplay" 14 | s.files = %w(bin/air lib/airplay/cli.rb) 15 | 16 | s.required_ruby_version = ">= 2.7.0" 17 | 18 | s.add_dependency("airplay", ">= 1.0.5") 19 | s.add_dependency("clap", "~> 1.0.0") 20 | s.add_dependency("ruby-progressbar", "~> 1.2.0") 21 | end 22 | -------------------------------------------------------------------------------- /doc/header.md: -------------------------------------------------------------------------------- 1 | # Airplay 2 | 3 | [![Code Climate](https://codeclimate.com/github/elcuervo/airplay.png)](https://codeclimate.com/github/elcuervo/airplay) 4 | [![Build Status](https://travis-ci.org/elcuervo/airplay.png?branch=master)](https://travis-ci.org/elcuervo/airplay) 5 | 6 | ![Airplay](doc/img/logo.png) 7 | 8 | Airplay attempts to be compatible with the latest AppleTV firmware but I'd like 9 | to add compatibility to other servers. 10 | 11 | ## Contribute 12 | 13 | You can contribute with code, bugs or feature requests. 14 | 15 | The development of the gem takes time and there's a lot of research and hardware 16 | tests to make all of this. If you want to contribute please consider donating as 17 | much as you want in: [Paypal](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=HE867B8J6ARQ4) or [Gumroad](https://gumroad.com/l/airplay) 18 | -------------------------------------------------------------------------------- /test/unit/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "airplay/configuration" 3 | 4 | describe Airplay::Configuration do 5 | Given(:configuration) { Airplay::Configuration.new } 6 | 7 | context "default configuration" do 8 | Then { configuration.log_level == 4 } 9 | Then { configuration.autodiscover == true } 10 | Then { configuration.host == "0.0.0.0" } 11 | Then { configuration.port == nil } 12 | Then { configuration.output.respond_to?(:info) } 13 | end 14 | 15 | context "changing configuration" do 16 | When do 17 | configuration.log_level = 1 18 | configuration.autodiscover = false 19 | configuration.host = "200.47.220.245" 20 | configuration.port = "80" 21 | end 22 | 23 | Then { configuration.log_level == 1 } 24 | And { configuration.autodiscover == false } 25 | And { configuration.host == "200.47.220.245" } 26 | And { configuration.port == "80" } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/airplay/playable.rb: -------------------------------------------------------------------------------- 1 | require "airplay/player" 2 | 3 | module Airplay 4 | module Playable 5 | # Public: Plays a given video 6 | # 7 | # file_or_url - The video to be played 8 | # options - Optional start position 9 | # 10 | # Returns a Player object to control the playback 11 | # 12 | def play(file_or_url = "playlist", options = {}) 13 | player.async.play(file_or_url, options) 14 | player 15 | end 16 | 17 | # Public: Gets the current playlist 18 | # 19 | # Returns the Playlist 20 | # 21 | def playlist 22 | player.playlist 23 | end 24 | 25 | # Public: Gets all the playlists 26 | # 27 | # Returns the Playlists 28 | # 29 | def playlists 30 | player.playlists 31 | end 32 | 33 | # Public: Gets the player object 34 | # 35 | # Returns a Player object 36 | def player 37 | @_player ||= Airplay::Player.new(self) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/airplay/player/playlist.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "airplay/player/media" 3 | 4 | module Airplay 5 | class Player 6 | class Playlist 7 | include Enumerable 8 | extend Forwardable 9 | 10 | attr_reader :name 11 | 12 | def_delegators :@items, :each, :size, :empty? 13 | 14 | def initialize(name) 15 | @name = name 16 | @items = [] 17 | @position = 0 18 | end 19 | 20 | def to_ary 21 | @items 22 | end 23 | 24 | def <<(file_or_url) 25 | @items << Media.new(file_or_url) 26 | end 27 | 28 | def next?; @position + 1 <= @items.size end 29 | def previous?; @position - 1 >= 0 end 30 | 31 | def next 32 | return nil if !next? 33 | 34 | item = @items[@position] 35 | @position += 1 36 | item 37 | end 38 | 39 | def previous 40 | return nil if !previous? 41 | 42 | @position -= 1 43 | @items[@position] 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/airplay/connection/authentication.rb: -------------------------------------------------------------------------------- 1 | require "net/http/digest_auth" 2 | 3 | module Airplay 4 | class Connection 5 | Authentication = Struct.new(:device, :handler) do 6 | def sign(request) 7 | auth_token = authenticate(request) 8 | request.add_field('Authorization', auth_token) if auth_token 9 | request 10 | end 11 | 12 | private 13 | 14 | def uri(request) 15 | path = "http://#{device.address}#{request.path}" 16 | uri = URI.parse(path) 17 | uri.user = "Airplay" 18 | uri.password = device.password 19 | 20 | uri 21 | end 22 | 23 | def authenticate(request) 24 | response = handler.request(request) 25 | 26 | auth = response["www-authenticate"] || response["WWW-Authenticate"] 27 | digest_authentication(request, auth) if auth 28 | end 29 | 30 | def digest_authentication(request, auth) 31 | digest = Net::HTTP::DigestAuth.new 32 | digest.auth_header(uri(request), auth, request.method) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Bruno Aguirre 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. 21 | -------------------------------------------------------------------------------- /lib/airplay/configuration.rb: -------------------------------------------------------------------------------- 1 | require "log4r/config" 2 | require "celluloid/autostart" 3 | require "airplay/logger" 4 | 5 | # Public: Airplay core module 6 | # 7 | module Airplay 8 | # Public: Handles the Airplay configuration 9 | # 10 | class Configuration 11 | attr_accessor :log_level, :output, :autodiscover, :host, :port 12 | 13 | def initialize 14 | Celluloid.boot # Force Thread Pool initialization 15 | Log4r.define_levels(*Log4r::Log4rConfig::LogLevels) 16 | 17 | @log_level = Log4r::ERROR 18 | @autodiscover = true 19 | @host = "0.0.0.0" 20 | @port = nil 21 | @output = Log4r::Outputter.stdout 22 | end 23 | 24 | # Public: Loads the configuration into the affected parts 25 | # 26 | # Returns nothing. 27 | # 28 | def load 29 | level = if @log_level.is_a?(Fixnum) 30 | @log_level 31 | else 32 | Log4r.const_get(@log_level.upcase) 33 | end 34 | 35 | Log4r::Logger.root.add @output 36 | Log4r::Logger.root.level = level 37 | Celluloid.logger = Airplay::Logger.new("airplay::celluloid") 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/airplay/player/playback_info.rb: -------------------------------------------------------------------------------- 1 | module Airplay 2 | class Player 3 | PlaybackInfo = Struct.new(:info) do 4 | def uuid 5 | info["uuid"] 6 | end 7 | 8 | def duration 9 | info["duration"] 10 | end 11 | 12 | def has_duration? 13 | !duration.to_f.zero? 14 | end 15 | 16 | def position 17 | info["position"] 18 | end 19 | 20 | def percent 21 | return unless position && has_duration? 22 | (position * 100 / duration).floor 23 | end 24 | 25 | def likely_to_keep_up? 26 | info["playbackLikelyToKeepUp"] 27 | end 28 | 29 | def stall_count 30 | info["stallCount"] 31 | end 32 | 33 | def ready_to_play? 34 | info["readyToPlay"] 35 | end 36 | 37 | def stopped? 38 | info.empty? 39 | end 40 | 41 | def playing? 42 | info.has_key?("rate") && info.fetch("rate", false) && !info["rate"].zero? 43 | end 44 | 45 | def paused? 46 | !playing? 47 | end 48 | 49 | def played? 50 | # This is weird. I know. Bear with me. 51 | info.keys.size == 2 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/integration/view_images_test.rb: -------------------------------------------------------------------------------- 1 | require "integration_helper" 2 | 3 | describe "Sending images to a device" do 4 | Given(:device) { test_device } 5 | 6 | context "being a file path" do 7 | When(:view) { device.view(sample_images[0]) } 8 | Then { view == true } 9 | end 10 | 11 | context "being a raw image" do 12 | Given(:image) { File.read(sample_images[1]) } 13 | When(:view) { device.view(image) } 14 | Then { view == true } 15 | end 16 | 17 | context "being a raw binary image" do 18 | Given(:image) { File.open(sample_images[1], "rb").read } 19 | When(:view) { device.view(image) } 20 | Then { view == true } 21 | end 22 | 23 | context "being a IO stream" do 24 | Given(:image) { StringIO.new(File.read(sample_images[2])) } 25 | When(:view) { device.view(image) } 26 | Then { view == true } 27 | end 28 | 29 | context "being a URL" do 30 | Given(:image) { "https://github.com/elcuervo/airplay/raw/master/doc/img/logo.png" } 31 | When(:view) { device.view(image) } 32 | Then { view == true } 33 | end 34 | 35 | context "sending an unsupported type" do 36 | When(:view) { device.view(42) } 37 | Then { view == Failure(Airplay::Viewer::UnsupportedType) } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/airplay/connection/persistent.rb: -------------------------------------------------------------------------------- 1 | require "net/ptth" 2 | require "securerandom" 3 | 4 | module Airplay 5 | # Public: The class that handles all the outgoing basic HTTP connections 6 | # 7 | class Connection 8 | # Public: Class that wraps a persistent connection to point to the airplay 9 | # server and other configuration 10 | # 11 | class Persistent 12 | attr_reader :session, :mac_address 13 | 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 | @session = SecureRandom.uuid 20 | @mac_address = "0x#{SecureRandom.hex(6)}" 21 | 22 | @socket.socket 23 | end 24 | 25 | def close 26 | socket.close 27 | end 28 | 29 | def socket 30 | @socket.socket 31 | end 32 | 33 | # Public: send a request to the active server 34 | # 35 | # request - The Net::HTTP request to be executed 36 | # &block - An optional block to be executed within the block 37 | # 38 | def request(request) 39 | @socket.request(request) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/airplay/player/media.rb: -------------------------------------------------------------------------------- 1 | require "mime/types" 2 | 3 | require "airplay/server" 4 | 5 | module Airplay 6 | class Player 7 | class Media 8 | attr_reader :file_or_url, :url 9 | 10 | COMPATIBLE_TYPES = %w( 11 | application/mp4 12 | video/mp4 13 | video/vnd.objectvideo 14 | video/MP2T 15 | video/quicktime 16 | video/mpeg4 17 | ) 18 | 19 | def initialize(file_or_url) 20 | @file_or_url = file_or_url 21 | end 22 | 23 | def url 24 | @_url ||= case true 25 | when local? 26 | Airplay.server.serve(File.expand_path(file_or_url)) 27 | when remote? 28 | file_or_url 29 | else 30 | raise Errno::ENOENT, file_or_url 31 | end 32 | end 33 | 34 | def compatible? 35 | @_compatible ||= begin 36 | path = File.basename(file_or_url) 37 | compatibility = MIME::Types.type_for(path).map(&:to_s) & COMPATIBLE_TYPES 38 | compatibility.any? 39 | end 40 | end 41 | 42 | def local? 43 | File.exists?(file_or_url) 44 | end 45 | 46 | def remote? 47 | !!(file_or_url =~ URI::regexp) 48 | end 49 | 50 | def to_s; url end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/airplay/device/features.rb: -------------------------------------------------------------------------------- 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_stream?: 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 44 | -------------------------------------------------------------------------------- /lib/airplay/group.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "airplay/group/players" 3 | 4 | module Airplay 5 | class Group 6 | include Enumerable 7 | extend Forwardable 8 | 9 | def_delegators :@devices, :each, :size, :empty? 10 | 11 | def initialize(name) 12 | @devices = [] 13 | @players = [] 14 | @name = name 15 | end 16 | 17 | # Public: Adds a device to the list 18 | # 19 | # value - The Device 20 | # 21 | # Returns nothing 22 | # 23 | def <<(device) 24 | @devices << device 25 | end 26 | 27 | # Public: Plays a video on all the grouped devices 28 | # 29 | # file_or_url - The file or url to be sent to the devices 30 | # options - The options to be sent 31 | # 32 | # Returns a Players instance that syncs the devices 33 | # 34 | def play(file_or_url, options = {}) 35 | @players = @devices.map { |device| device.play(file_or_url, options) } 36 | Players.new(@players) 37 | end 38 | 39 | # Public: Views an image on all the grouped devices 40 | # 41 | # media_or_io - The file or url to be sent to the devices 42 | # options - The options to be sent 43 | # 44 | # Returns an array of arrays with the result of the playback 45 | # 46 | def view(media_or_io, options = {}) 47 | @devices.map do |device| 48 | ok = device.view(media_or_io, options) 49 | [device, ok] 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /bin/air: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "clap" 4 | require "airplay/cli" 5 | require "airplay/cli/version" 6 | 7 | begin 8 | Clap.run ARGV, 9 | "--device" => lambda { |device_name| @device = Airplay[device_name] }, 10 | "--url" => lambda { |url| @url = url }, 11 | "--wait" => lambda { |sec| @wait = sec.to_i }, 12 | "--interactive" => lambda { @interactive = true }, 13 | "--password" => lambda { |password| @password = password }, 14 | 15 | "help" => Airplay::CLI.method(:help), 16 | 17 | "list" => Airplay::CLI.method(:list), 18 | 19 | "doctor" => Airplay::CLI.method(:doctor), 20 | 21 | "version" => Airplay::CLI.method(:version), 22 | 23 | "play" => lambda { |video| 24 | options = { 25 | device: @device || Airplay.devices.first, 26 | password: @password || false, 27 | url: @url || false 28 | } 29 | Airplay::CLI.play(video, options) 30 | }, 31 | 32 | "view" => lambda { |file| 33 | options = { 34 | device: @device || @url ? false : Airplay.devices.first, 35 | interactive: @interactive || false, 36 | password: @password || false, 37 | url: @url || false, 38 | wait: @wait || 3 39 | } 40 | Airplay::CLI.view(file, options) 41 | } 42 | 43 | Airplay::CLI.help if ARGV.empty? 44 | 45 | rescue Airplay::Browser::NoDevicesFound 46 | puts "No devices found." 47 | rescue Interrupt 48 | puts "Bye!" 49 | end 50 | 51 | # vim: ft=ruby 52 | -------------------------------------------------------------------------------- /lib/airplay/cli/doctor.rb: -------------------------------------------------------------------------------- 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 | DNSSD.resolve(node) do |resolved| 49 | devices << DebugDevice.new(node, resolved) 50 | 51 | break unless resolved.flags.more_coming? 52 | end 53 | end 54 | end 55 | 56 | 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /airplay.gemspec: -------------------------------------------------------------------------------- 1 | $: << File.expand_path("../lib", __FILE__) 2 | require "airplay/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "airplay" 6 | s.version = Airplay::VERSION 7 | s.summary = "Airplay client" 8 | s.description = "Send image/video to an airplay enabled device" 9 | s.licenses = ["MIT", "HUGWARE"] 10 | s.authors = ["elcuervo"] 11 | s.email = ["yo@brunoaguirre.com"] 12 | s.homepage = "http://github.com/elcuervo/airplay" 13 | s.files = `git ls-files`.split("\n") 14 | s.test_files = `git ls-files test`.split("\n") 15 | 16 | s.required_ruby_version = ">= 2.7.0" 17 | 18 | s.add_dependency("dnssd", "~> 3.0") 19 | s.add_dependency("CFPropertyList", "~> 2.2") 20 | s.add_dependency("mime-types", ">= 1.16") 21 | s.add_dependency("log4r", "~> 1.1.10") 22 | s.add_dependency("cuba", "~> 3.1.0") 23 | s.add_dependency("micromachine", "~> 1.0.4") 24 | s.add_dependency("celluloid", ">= 0.17.0") 25 | s.add_dependency("reel", "~> 0.5.0") 26 | s.add_dependency("reel-rack", "~> 0.2.2") 27 | s.add_dependency("net-ptth", "= 0.0.17") 28 | s.add_dependency("net-http-digest_auth", "~> 1.2.1") 29 | 30 | s.add_development_dependency("minitest", "~> 4.4.0") 31 | s.add_development_dependency("minitest-given", "~> 3.0.0") 32 | s.add_development_dependency("fakeweb", "~> 1.3.0") 33 | s.add_development_dependency("vcr", "~> 2.4.0") 34 | end 35 | -------------------------------------------------------------------------------- /lib/airplay/cli/image_viewer.rb: -------------------------------------------------------------------------------- 1 | module Airplay 2 | module CLI 3 | class ImageViewer 4 | attr_reader :options, :devices 5 | 6 | def initialize(devices, options = {}) 7 | @devices = Array(devices) 8 | @options = options 9 | end 10 | 11 | def view(file, transition = "SlideLeft") 12 | puts "Showing #{file}" 13 | devices.each do |device| 14 | device.view(file, transition: transition) 15 | end 16 | end 17 | 18 | def slideshow(files) 19 | puts "Autoplay every #{options[:wait]}" 20 | files.each do |file| 21 | view(file) 22 | sleep options[:wait] 23 | end 24 | end 25 | 26 | def interactive(files) 27 | numbers = Array(0...files.count) 28 | transition = "None" 29 | 30 | i = 0 31 | loop do 32 | view(files[i], transition) 33 | 34 | case read_char 35 | when "\e[C" # Right Arrow 36 | i = i + 1 > numbers.count - 1 ? 0 : i + 1 37 | transition = "SlideLeft" 38 | when "\e[D" # Left Arrow 39 | i = i - 1 < 0 ? numbers.count - 1 : i - 1 40 | transition = "SlideRight" 41 | else 42 | break 43 | end 44 | end 45 | end 46 | 47 | private 48 | 49 | def read_char 50 | STDIN.echo = false 51 | STDIN.raw! 52 | 53 | input = STDIN.getc.chr 54 | if input == "\e" then 55 | input << STDIN.read_nonblock(3) rescue nil 56 | input << STDIN.read_nonblock(2) rescue nil 57 | end 58 | ensure 59 | STDIN.echo = true 60 | STDIN.cooked! 61 | 62 | return input 63 | end 64 | 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/airplay/devices.rb: -------------------------------------------------------------------------------- 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 Device object 22 | # 23 | def find_by_name(device_name) 24 | find_by_block { |device| device if device.name == device_name } 25 | end 26 | 27 | # Public: Finds a device given an ip 28 | # 29 | # ip - The ip of the device 30 | # 31 | # Returns a Device object 32 | # 33 | def find_by_ip(ip) 34 | find_by_block { |device| device if device.ip == ip } 35 | end 36 | 37 | # Public: Adds a device to the pool 38 | # 39 | # name - The name of the device 40 | # address - The address of the device 41 | # 42 | # Returns nothing 43 | # 44 | def add(name, address) 45 | device = Device.new(name: name, address: address) 46 | self << device 47 | device 48 | end 49 | 50 | # Public: Adds a device to the list 51 | # 52 | # value - The Device 53 | # 54 | # Returns nothing 55 | # 56 | def <<(device) 57 | return if find_by_ip(device.ip) 58 | @items << device 59 | end 60 | 61 | private 62 | 63 | # Private: Finds a devices based on a block 64 | # 65 | # &block - The block to be executed 66 | # 67 | # Returns the result of the find on that given block 68 | # 69 | def find_by_block(&block) 70 | @items.find(&block) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/airplay.rb: -------------------------------------------------------------------------------- 1 | require "airplay/configuration" 2 | require "airplay/browser" 3 | require "airplay/group" 4 | require "airplay/server" 5 | require "airplay/version" 6 | 7 | # Public: Airplay core module 8 | # 9 | module Airplay 10 | class << self 11 | # Public: General configuration 12 | # 13 | # &block - The block that will modify the configuration 14 | # 15 | # Returns the configuration file. 16 | # 17 | def configure(&block) 18 | yield(configuration) if block 19 | end 20 | 21 | # Public: Access the server object 22 | # 23 | # Returns the Server object 24 | # 25 | def server 26 | @_server ||= Server.new 27 | end 28 | 29 | # Public: Browses for devices in the current network 30 | # 31 | # Returns nothing. 32 | # 33 | def browse 34 | browser.browse 35 | end 36 | 37 | # Public: Access or create a group based on a key 38 | # 39 | # Returns the Hash object. 40 | # 41 | def group 42 | @_group ||= Hash.new { |h, k| h[k] = Group.new(k) } 43 | end 44 | 45 | # Public: Helper method to access all the devices 46 | # 47 | # Returns a Group with all the devices. 48 | # 49 | def all 50 | @_all ||= begin 51 | group = Group.new(:all) 52 | devices.each { |device| group << device } 53 | group 54 | end 55 | end 56 | 57 | # Public: Lists found devices if autodiscover is enabled 58 | # 59 | # Returns an Array with all the devices 60 | # 61 | def devices 62 | browse if browser.devices.empty? && configuration.autodiscover 63 | browser.devices 64 | end 65 | 66 | # Public: Access the configuration object 67 | # 68 | # Returns the Configuration object 69 | # 70 | def configuration 71 | @_configuration ||= Configuration.new 72 | end 73 | 74 | # Public: Access a device by name 75 | # 76 | # device_name - The name to search on the devices 77 | # 78 | # Returns the found device 79 | # 80 | def [](device_name) 81 | devices.find_by_name(device_name) 82 | end 83 | 84 | private 85 | 86 | # Private: Access the browser object 87 | # 88 | # Returns the momoized Browser object 89 | # 90 | def browser 91 | @_browser ||= Browser.new 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/airplay/server.rb: -------------------------------------------------------------------------------- 1 | require "rack" 2 | require "socket" 3 | require "celluloid/autostart" 4 | require "reel/rack" 5 | 6 | require "airplay/logger" 7 | require "airplay/server/app" 8 | 9 | module Airplay 10 | class Server 11 | include Celluloid 12 | 13 | attr_reader :port 14 | 15 | def initialize 16 | @port = Airplay.configuration.port || find_free_port 17 | @logger = Airplay::Logger.new("airplay::server") 18 | @server = Rack::Server.new( 19 | server: :reel, 20 | Host: private_ip, 21 | Port: @port, 22 | Logger: @logger, 23 | AccessLog: [], 24 | quiet: true, 25 | app: App.app 26 | ) 27 | 28 | start! 29 | end 30 | 31 | # Public: Adds a file to serve 32 | # 33 | # file - The file path to be served 34 | # 35 | # Returns the url that the file will have 36 | # 37 | def serve(file) 38 | sleep 0.1 until running? 39 | asset_id = App.settings[:assets][file] 40 | 41 | "http://#{private_ip}:#{@port}/assets/#{asset_id}" 42 | end 43 | 44 | # Public: Starts the server in a new thread 45 | # 46 | # Returns nothing 47 | # 48 | def start! 49 | Thread.start { @server.start } 50 | end 51 | 52 | private 53 | 54 | # Private: Checks the state if the server by attempting a connection to it 55 | # 56 | # Returns a boolean with the state 57 | # 58 | def running?(port = @port) 59 | begin 60 | socket = TCPSocket.new(private_ip, port) 61 | socket.close unless socket.nil? 62 | true 63 | rescue Errno::ECONNREFUSED, Errno::EBADF, Errno::EADDRNOTAVAIL 64 | false 65 | end 66 | end 67 | 68 | # Private: The local ip of the machine 69 | # 70 | # Returns the ip address of the current machine 71 | # 72 | def private_ip 73 | @_ip ||= Socket.ip_address_list.detect do |addr| 74 | addr.ipv4_private? 75 | end.ip_address 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 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $: << File.expand_path("../lib", __FILE__) 2 | 3 | require "rake/testtask" 4 | require "fileutils" 5 | require "airplay/version" 6 | require "airplay/cli/version" 7 | 8 | class RuleBuilder 9 | attr_reader :task, :info 10 | 11 | TASKS = [:lib, :cli] 12 | 13 | def initialize(options = {}) 14 | @task = options[:task] 15 | 16 | @info = { 17 | lib: { name: "airplay", version: Airplay::VERSION }, 18 | cli: { name: "airplay-cli", version: Airplay::CLI::VERSION } 19 | } 20 | end 21 | 22 | def construct(action) 23 | -> n { 24 | task.call(:all) do 25 | TASKS.each { |task| Rake::Task["#{action}:#{task}"].invoke } 26 | end 27 | 28 | TASKS.each do |task_name| 29 | task.call(task_name) { action_method(action, task_name) } 30 | end 31 | } 32 | end 33 | 34 | private 35 | 36 | def action_method(action, task_name) 37 | method("#{action}_gem".to_sym).call(task_name) 38 | end 39 | 40 | def build_gem(type) 41 | name = info[type][:name] 42 | version = info[type][:version] 43 | 44 | gem_name = "#{name}-#{version}.gem" 45 | 46 | FileUtils.mkdir_p("pkg") 47 | `gem build #{name}.gemspec` 48 | FileUtils.mv(gem_name, "pkg") 49 | 50 | puts "#{name} (v#{version}) builded!" 51 | 52 | "./pkg/#{gem_name}" 53 | end 54 | 55 | def install_gem(type) 56 | name = info[type][:name] 57 | gem_path = build_gem(type) 58 | `gem install --local #{gem_path}` 59 | 60 | puts "#{gem_path} installed!" 61 | end 62 | 63 | def release_gem(type) 64 | name = info[type][:name] 65 | gem_path = build_gem(type) 66 | `gem push #{gem_path}` 67 | 68 | puts "#{gem_path} released!" 69 | end 70 | end 71 | 72 | 73 | 74 | builder = RuleBuilder.new(task: method(:task)) 75 | namespace :build, &builder.construct(:build) 76 | namespace :install, &builder.construct(:install) 77 | namespace :release, &builder.construct(:release) 78 | 79 | Rake::TestTask.new("test:all") do |t| 80 | t.libs << "test" 81 | t.pattern = "test/**/*_test.rb" 82 | end 83 | 84 | Rake::TestTask.new("test:unit") do |t| 85 | t.libs << "test" 86 | t.pattern = "test/unit/**/*_test.rb" 87 | end 88 | 89 | Rake::TestTask.new("test:integration") do |t| 90 | t.libs << "test" 91 | t.pattern = "test/integration**/*_test.rb" 92 | end 93 | 94 | task :default => "test:all" 95 | 96 | namespace :doc do 97 | task :generate do 98 | structure = %w(header toc installation usage testing documentation contributors) 99 | 100 | File.open("README.md", "w+") do |f| 101 | structure.each { |part| f << File.read("doc/#{part}.md") + "\n" } 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | airplay (1.0.5) 5 | CFPropertyList (~> 2.2) 6 | celluloid (>= 0.17.0) 7 | cuba (~> 3.1.0) 8 | dnssd (~> 3.0) 9 | log4r (~> 1.1.10) 10 | micromachine (~> 1.0.4) 11 | mime-types (>= 1.16) 12 | net-http-digest_auth (~> 1.2.1) 13 | net-ptth (= 0.0.17) 14 | reel (~> 0.5.0) 15 | reel-rack (~> 0.2.2) 16 | airplay-cli (1.0.3) 17 | airplay (>= 1.0.5) 18 | clap (~> 1.0.0) 19 | ruby-progressbar (~> 1.2.0) 20 | 21 | GEM 22 | remote: http://rubygems.org/ 23 | specs: 24 | CFPropertyList (2.3.1) 25 | addressable (2.3.8) 26 | celluloid (0.17.0) 27 | bundler 28 | celluloid-essentials 29 | celluloid-extras 30 | celluloid-fsm 31 | celluloid-pool 32 | celluloid-supervision 33 | dotenv 34 | nenv 35 | rspec-logsplit (>= 0.1.2) 36 | timers (~> 4.0.0) 37 | celluloid-essentials (0.20.1.1) 38 | bundler 39 | dotenv 40 | nenv 41 | rspec-logsplit (>= 0.1.2) 42 | timers (~> 4.0.0) 43 | celluloid-extras (0.20.0) 44 | bundler 45 | dotenv 46 | nenv 47 | rspec-logsplit (>= 0.1.2) 48 | timers (~> 4.0.0) 49 | celluloid-fsm (0.20.0) 50 | bundler 51 | dotenv 52 | nenv 53 | rspec-logsplit (>= 0.1.2) 54 | timers (~> 4.0.0) 55 | celluloid-io (0.16.2) 56 | celluloid (>= 0.16.0) 57 | nio4r (>= 1.1.0) 58 | celluloid-pool (0.20.0) 59 | bundler 60 | dotenv 61 | nenv 62 | rspec-logsplit (>= 0.1.2) 63 | timers (~> 4.0.0) 64 | celluloid-supervision (0.20.0) 65 | bundler 66 | dotenv 67 | nenv 68 | rspec-logsplit (>= 0.1.2) 69 | timers (~> 4.0.0) 70 | clap (1.0.0) 71 | cuba (3.1.1) 72 | rack 73 | dnssd (3.0.1) 74 | domain_name (0.5.24) 75 | unf (>= 0.0.5, < 1.0.0) 76 | dotenv (2.0.2) 77 | fakeweb (1.3.0) 78 | given_core (3.0.0) 79 | sorcerer (>= 0.3.7) 80 | hitimes (1.2.2) 81 | http (0.9.0) 82 | addressable (~> 2.3) 83 | http-cookie (~> 1.0) 84 | http-form_data (~> 1.0.1) 85 | http_parser.rb (~> 0.6.0) 86 | http-cookie (1.0.2) 87 | domain_name (~> 0.5) 88 | http-form_data (1.0.1) 89 | http_parser.rb (0.6.0) 90 | log4r (1.1.10) 91 | micromachine (1.0.4) 92 | mime-types (2.6.1) 93 | minitest (4.4.0) 94 | minitest-given (3.0.0) 95 | given_core (= 3.0.0) 96 | minitest (> 4.3) 97 | nenv (0.2.0) 98 | net-http-digest_auth (1.2.1) 99 | net-ptth (0.0.17) 100 | celluloid-io (>= 0.15.0) 101 | http_parser.rb (>= 0.6.0.beta.2) 102 | rack (>= 1.4.5) 103 | nio4r (1.1.1) 104 | rack (1.6.4) 105 | rake (10.4.2) 106 | reel (0.5.0) 107 | celluloid (>= 0.15.1) 108 | celluloid-io (>= 0.15.0) 109 | http (>= 0.6.0.pre) 110 | http_parser.rb (>= 0.6.0) 111 | websocket_parser (>= 0.1.6) 112 | reel-rack (0.2.2) 113 | rack (>= 1.4.0) 114 | reel (>= 0.5.0) 115 | rspec-logsplit (0.1.3) 116 | ruby-progressbar (1.2.0) 117 | sorcerer (1.0.2) 118 | timers (4.0.1) 119 | hitimes 120 | unf (0.1.4) 121 | unf_ext 122 | unf_ext (0.0.7.1) 123 | vcr (2.4.0) 124 | websocket_parser (1.0.0) 125 | 126 | PLATFORMS 127 | ruby 128 | 129 | DEPENDENCIES 130 | airplay! 131 | airplay-cli! 132 | fakeweb (~> 1.3.0) 133 | minitest (~> 4.4.0) 134 | minitest-given (~> 3.0.0) 135 | rake 136 | vcr (~> 2.4.0) 137 | 138 | BUNDLED WITH 139 | 2.3.24 140 | -------------------------------------------------------------------------------- /lib/airplay/browser.rb: -------------------------------------------------------------------------------- 1 | require "dnssd" 2 | require "timeout" 3 | 4 | require "airplay/logger" 5 | require "airplay/devices" 6 | 7 | module Airplay 8 | # Public: Browser class to find Airplay-enabled devices in the network 9 | # 10 | class Browser 11 | NoDevicesFound = Class.new(StandardError) 12 | 13 | SEARCH = "_airplay._tcp." 14 | 15 | def initialize 16 | @logger = Airplay::Logger.new("airplay::browser") 17 | end 18 | 19 | # Public: Browses in the search of devices and adds them to the nodes 20 | # 21 | # Returns nothing or raises NoDevicesFound if there are no devices 22 | # 23 | def browse 24 | timeout(5) do 25 | nodes = [] 26 | DNSSD.browse!(SEARCH) do |node| 27 | nodes << node 28 | next if node.flags.more_coming? 29 | 30 | nodes.each do |node| 31 | resolve(node) 32 | end 33 | 34 | break 35 | end 36 | end 37 | rescue Timeout::Error => e 38 | raise NoDevicesFound 39 | end 40 | 41 | # Public: Access to the node list 42 | # 43 | # Returns the Devices list object 44 | # 45 | def devices 46 | @_devices ||= Devices.new 47 | end 48 | 49 | private 50 | 51 | # Private: Resolves a node given a node and a resolver 52 | # 53 | # node - The given node 54 | # resolver - The DNSSD::Server that is resolving nodes 55 | # 56 | # Returns if there are more nodes coming 57 | # 58 | def node_resolver(node, resolved) 59 | address = get_device_address(resolved) 60 | type = get_type(resolved.text_record) 61 | 62 | device = create_device(node.name, address, type) 63 | device.text_records = resolved.text_record 64 | 65 | devices << device 66 | 67 | resolved.flags.more_coming? 68 | end 69 | 70 | # Private: Gets the device type 71 | # 72 | # records - The text records hash to be investigated 73 | # 74 | # Returns a symbol with the type 75 | # 76 | def get_type(records) 77 | # rhd means Remote HD the first product of the Airserver people 78 | if records.has_key?("rhd") 79 | :airserver 80 | else 81 | :apple_tv 82 | end 83 | end 84 | 85 | # Private: Resolves the node complete address 86 | # 87 | # resolved - The DNS Resolved object 88 | # 89 | # Returns a string with the address (host:ip) 90 | # 91 | def get_device_address(resolved) 92 | host = get_device_host(resolved.target) 93 | "#{host}:#{resolved.port}" 94 | end 95 | 96 | # Private: Resolves the node ip or hostname 97 | # 98 | # resolved - The DNS Resolved object 99 | # 100 | # Returns a string with the ip or the hostname 101 | # 102 | def get_device_host(target) 103 | info = Socket.getaddrinfo(target, nil, Socket::AF_INET) 104 | info[0][2] 105 | rescue SocketError 106 | target 107 | end 108 | 109 | # Private: Creates a device 110 | # 111 | # name - The device name 112 | # address - The device address 113 | # 114 | # Returns nothing 115 | # 116 | def create_device(name, address, type) 117 | Device.new( 118 | name: name.gsub(/\u00a0/, ' '), 119 | address: address, 120 | type: type 121 | ) 122 | end 123 | 124 | # Private: Resolves the node information given a node 125 | # 126 | # node - The node from the DNSSD browsing 127 | # 128 | # Returns nothing 129 | # 130 | def resolve(node) 131 | DNSSD.resolve(node) do |resolved| 132 | break unless node_resolver(node, resolved) 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/airplay/viewer.rb: -------------------------------------------------------------------------------- 1 | require "open-uri" 2 | 3 | module Airplay 4 | # Public: The class to handle image broadcast to a device 5 | # 6 | class Viewer 7 | UnsupportedType = Class.new(TypeError) 8 | 9 | TRANSITIONS = %w(None Dissolve SlideLeft SlideRight) 10 | 11 | def initialize(device) 12 | @device = device 13 | @logger = Airplay::Logger.new("airplay::viewer") 14 | end 15 | 16 | # Public: Broadcasts the content to the device 17 | # 18 | # media_or_io - The url, file path or io of the image/s 19 | # options - Options that include the device 20 | # * transition: the type of transition (Default: None) 21 | # 22 | # Returns if the images was actually sent 23 | # 24 | def view(media_or_io, options = {}) 25 | content = get_content(media_or_io) 26 | transition = options.fetch(:transition, "None") 27 | 28 | @logger.info "Fetched content (#{content.bytesize} bytes)" 29 | @logger.debug "PUT /photo with transition: #{transition}" 30 | 31 | response = connection.put("/photo", content, { 32 | "Content-Length" => content.bytesize.to_s, 33 | "X-Apple-Transition" => transition 34 | }) 35 | 36 | response.response.status == 200 37 | end 38 | 39 | # Public: The list of transitions 40 | # 41 | # Returns the list of trasitions 42 | # 43 | def transitions; TRANSITIONS end 44 | 45 | private 46 | 47 | # Public: The connection 48 | # 49 | # Returns the connection 50 | # 51 | def connection 52 | @_connection ||= Airplay::Connection.new(@device) 53 | end 54 | 55 | # Private: Gets the content of the possible media_or_io 56 | # 57 | # media_or_io - The url, file, path or read compatible source 58 | # 59 | # Returns the content of the media 60 | # 61 | def get_content(media_or_io) 62 | case true 63 | when is_binary?(media_or_io) then media_or_io 64 | when is_file?(media_or_io) then File.read(media_or_io) 65 | when is_url?(media_or_io) then open(media_or_io).read 66 | when is_string?(media_or_io) then media_or_io 67 | when is_io?(media_or_io) then media_or_io.read 68 | else raise UnsupportedType.new("That media type is unsupported") 69 | end 70 | end 71 | 72 | # Private: Check if the string is binary 73 | # 74 | # string - The string to be checked 75 | # 76 | # Returns true/false 77 | # 78 | def is_binary?(string) 79 | string.encoding.names.include?("BINARY") 80 | rescue 81 | false 82 | end 83 | 84 | # Private: Check if the string is in the filesystem 85 | # 86 | # string - The string to be checked 87 | # 88 | # Returns true/false 89 | # 90 | def is_file?(string) 91 | return false if string.is_a?(StringIO) 92 | !File.directory?(string) && File.exists?(File.expand_path(string)) 93 | rescue 94 | false 95 | end 96 | 97 | # Private: Check if the string is a URL 98 | # 99 | # string - The string to be checked 100 | # 101 | # Returns true/false 102 | # 103 | def is_url?(string) 104 | !!(string =~ URI::regexp) 105 | rescue 106 | false 107 | end 108 | 109 | # Private: Check if the string is actually a string 110 | # 111 | # string - The string to be checked 112 | # 113 | # Returns true/false 114 | # 115 | def is_string?(string) 116 | string.is_a?(String) 117 | end 118 | 119 | # Private: Check if the string can be read 120 | # 121 | # string - The string to be checked 122 | # 123 | # Returns true/false 124 | # 125 | def is_io?(string) 126 | string.respond_to?(:read) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /doc/usage.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | ### CLI 4 | 5 | #### View devices 6 | 7 | `air list` 8 | 9 | ![air list](doc/img/cli_list.png) 10 | 11 | ```text 12 | * Apple TV (AppleTV2,1 running 11A502) 13 | ip: 192.168.1.12 14 | resolution: 1280x720 15 | ``` 16 | 17 | #### Play a video 18 | 19 | `air play [url to video or local file]` 20 | 21 | ![air play](doc/img/cli_play.png) 22 | 23 | ```text 24 | Playing http://movietrailers.apple.com/movies/universal/rush/rush-tlr3_480p.mov?width=848&height=352 25 | Time: 00:00:13 [===== ] 7% Apple TV 26 | ``` 27 | 28 | ### Show images 29 | 30 | `air view [url to image or image folder]` 31 | 32 | ### Library 33 | 34 | #### Configuration 35 | 36 | ```ruby 37 | Airplay.configure do |config| 38 | config.log_level # Log4r levels (Default: Log4r::ERROR) 39 | config.autodiscover # Allows to search for nodes (Default: true) 40 | config.host # In which host to bind the server (Default: 0.0.0.0) 41 | config.port # In which port to bind the server (Default: will find one) 42 | config.output # Where to log (Default: Log4r::Outputter.stdout) 43 | end 44 | ``` 45 | 46 | #### Devices 47 | 48 | ```ruby 49 | require "airplay" 50 | 51 | Airplay.devices.each do |device| 52 | puts device.name 53 | end 54 | ``` 55 | 56 | #### Accessing and Grouping 57 | 58 | ```ruby 59 | # You can access a known device easily 60 | device = Airplay["Apple TV"] 61 | 62 | # And add the password of the device if needed 63 | device.password = "my super secret password" 64 | 65 | # Or you can group known devices to have them do a given action together 66 | Airplay.group["Backyard"] << Airplay["Apple TV"] 67 | Airplay.group["Backyard"] << Airplay["Room TV"] 68 | 69 | # The groups can later do some actions like: 70 | Airplay.group["Backyard"].play("video") 71 | ``` 72 | 73 | #### Images 74 | 75 | ```ruby 76 | require "airplay" 77 | 78 | apple_tv = Airplay["Apple TV"] 79 | 80 | # You can send local files 81 | apple_tv.view("my_image.png") 82 | 83 | # Or use remote files 84 | apple_tv.view("https://github.com/elcuervo/airplay/raw/master/doc/img/logo.png") 85 | 86 | # And define a transition 87 | apple_tv.view("url_to_the_image", transition: "Dissolve") 88 | 89 | # View all transitions 90 | apple_tv.transitions 91 | ``` 92 | 93 | #### Video 94 | 95 | ```ruby 96 | require "airplay" 97 | 98 | apple_tv = Airplay["Apple TV"] 99 | trailer = "http://movietrailers.apple.com/movies/dreamworks/needforspeed/needforspeed-tlr1xxzzs2_480p.mov" 100 | 101 | player = apple_tv.play(trailer) 102 | ``` 103 | 104 | ##### Playlist 105 | 106 | ```ruby 107 | # You can also add videos to a playlist and let the library handle them 108 | player.playlist << "video_url" 109 | player.playlist << "video_path" 110 | player.play 111 | 112 | # Or control it yourself 113 | player.next 114 | player.previous 115 | 116 | # Or if you prefer you can have several playlists 117 | player = apple_tv.player 118 | player.playlists["Star Wars Classic"] << "Star Wars Episode IV: A New Hope" 119 | player.playlists["Star Wars Classic"] << "Star Wars Episode V: The Empire Strikes Back" 120 | player.playlists["Star Wars Classic"] << "Star Wars Episode VI: Return of the Jedi" 121 | 122 | player.playlists["Star Wars"] << "Star Wars Episode I: The Phantom Menace" 123 | player.playlists["Star Wars"] << "Star Wars Episode II: Attack of the Clones" 124 | player.playlists["Star Wars"] << "Star Wars Episode III: Revenge of the Sith" 125 | 126 | player.use("Star Wars Classic") 127 | player.play 128 | player.wait 129 | ``` 130 | 131 | ##### Player 132 | 133 | ```ruby 134 | # Wait until the video is finished 135 | player.wait 136 | 137 | # Actions 138 | player.pause 139 | player.resume 140 | player.stop 141 | player.scrub 142 | player.info 143 | player.seek 144 | 145 | # Access the playback time per second 146 | player.progress -> progress { 147 | puts "I'm viewing #{progress.position} of #{progress.duration}" 148 | } 149 | ``` 150 | -------------------------------------------------------------------------------- /lib/airplay/connection.rb: -------------------------------------------------------------------------------- 1 | require "celluloid/autostart" 2 | require "airplay/connection/persistent" 3 | require "airplay/connection/authentication" 4 | 5 | module Airplay 6 | # Public: The class that handles all the outgoing basic HTTP connections 7 | # 8 | class Connection 9 | Response = Struct.new(:connection, :response) 10 | PasswordRequired = Class.new(StandardError) 11 | WrongPassword = Class.new(StandardError) 12 | 13 | include Celluloid 14 | 15 | def initialize(device, options = {}) 16 | @device = device 17 | @options = options 18 | @logger = Airplay::Logger.new("airplay::connection") 19 | end 20 | 21 | # Public: Establishes a persistent connection to the device 22 | # 23 | # Returns the persistent connection 24 | # 25 | def persistent 26 | address = @options[:address] || "http://#{@device.address}" 27 | @_persistent ||= Airplay::Connection::Persistent.new(address, @options) 28 | end 29 | 30 | # Public: Closes the opened connection 31 | # 32 | # Returns nothing 33 | # 34 | def close 35 | persistent.close 36 | @_persistent = nil 37 | end 38 | 39 | # Public: Executes a POST to a resource 40 | # 41 | # resource - The resource on the currently active Device 42 | # body - The body of the action 43 | # headers - Optional headers 44 | # 45 | # Returns a response object 46 | # 47 | def post(resource, body = "", headers = {}) 48 | prepare_request(:post, resource, body, headers) 49 | end 50 | 51 | # Public: Executes a PUT to a resource 52 | # 53 | # resource - The resource on the currently active Device 54 | # body - The body of the action 55 | # headers - Optional headers 56 | # 57 | # Returns a response object 58 | # 59 | def put(resource, body = "", headers = {}) 60 | prepare_request(:put, resource, body, headers) 61 | end 62 | 63 | # Public: Executes a GET to a resource 64 | # 65 | # resource - The resource on the currently active Device 66 | # headers - Optional headers 67 | # 68 | # Returns a response object 69 | # 70 | def get(resource, headers = {}) 71 | prepare_request(:get, resource, nil, headers) 72 | end 73 | 74 | private 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 | 99 | # Private: The defaults connection headers 100 | # 101 | # Returns the default headers 102 | # 103 | def default_headers 104 | { 105 | "User-Agent" => "MediaControl/1.0", 106 | "X-Apple-Session-ID" => persistent.session, 107 | "X-Apple-Device-ID" => persistent.mac_address 108 | } 109 | end 110 | 111 | # Private: Sends a request to the Device 112 | # 113 | # request - The Request object 114 | # headers - The headers of the request 115 | # 116 | # Returns a response object 117 | # 118 | def send_request(request, headers) 119 | request.initialize_http_header(default_headers.merge(headers)) 120 | 121 | if @device.password? 122 | authentication = Airplay::Connection::Authentication.new(@device, persistent) 123 | request = authentication.sign(request) 124 | end 125 | 126 | @logger.info("Sending request to #{@device.address}") 127 | response = persistent.request(request) 128 | 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 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/airplay/device.rb: -------------------------------------------------------------------------------- 1 | require "airplay" 2 | require "airplay/playable" 3 | require "airplay/viewable" 4 | require "airplay/device/features" 5 | require "airplay/device/info" 6 | 7 | module Airplay 8 | # Public: Represents an Airplay Node 9 | # 10 | class Device 11 | MissingAttributes = Class.new(KeyError) 12 | 13 | attr_reader :name, :address, :type, :password 14 | 15 | include Playable 16 | include Viewable 17 | 18 | def initialize(attributes = {}) 19 | validate_attributes(attributes) 20 | 21 | @name = attributes[:name] 22 | @address = attributes[:address] 23 | @type = attributes[:type] 24 | @password = attributes[:password] 25 | 26 | @it_has_password = false 27 | 28 | Airplay.configuration.load 29 | end 30 | 31 | # Public: Access the ip of the device 32 | # 33 | # Returns the memoized ip address 34 | # 35 | def ip 36 | @_ip ||= address.split(":").first 37 | end 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 | 52 | # Public: Sets the password for the device 53 | # 54 | # passwd - The password string 55 | # 56 | # Returns nothing 57 | # 58 | def password=(passwd) 59 | @password = passwd 60 | end 61 | 62 | # Public: Checks if the devices has a password 63 | # 64 | # Returns boolean for the presence of a password 65 | # 66 | def password? 67 | return @it_has_password if @it_has_password 68 | !!password && !password.empty? 69 | end 70 | 71 | # Public: Set the addess of the device 72 | # 73 | # address - The address string of the device 74 | # 75 | # Returns nothing 76 | # 77 | def address=(address) 78 | @address = address 79 | end 80 | 81 | # Public: Access the Features of the device 82 | # 83 | # Returns the Featurs of the device 84 | # 85 | def features 86 | @_features ||= Features.new(self) 87 | end 88 | 89 | # Public: Access the Info of the device 90 | # 91 | # Returns the Info of the device 92 | # 93 | def info 94 | @_info ||= Info.new(self) 95 | end 96 | 97 | # Public: Access the full information of the device 98 | # 99 | # Returns a hash with all the information 100 | # 101 | def server_info 102 | @_server_info ||= basic_info.merge(extra_info) 103 | end 104 | 105 | # Public: Establishes a conection to the device 106 | # 107 | # Returns the Connection 108 | # 109 | def connection 110 | @_connection ||= Airplay::Connection.new(self) 111 | end 112 | 113 | # Public: Forces the refresh of the connection 114 | # 115 | # Returns nothing 116 | # 117 | def refresh_connection 118 | @_connection = nil 119 | end 120 | 121 | # Public: The unique id of the device (mac address) 122 | # 123 | # Returns the mac address based on basic_info or server_info 124 | # 125 | def id 126 | @_id ||= begin 127 | basic_info.fetch("macAddress", server_info["macAddress"]) 128 | end 129 | end 130 | 131 | private 132 | 133 | def it_has_password! 134 | @it_has_password = true 135 | end 136 | 137 | # Private: Access the basic info of the device 138 | # 139 | # Returns a hash with the basic information 140 | # 141 | def basic_info 142 | @_basic_info ||= begin 143 | return @text_records if @text_records 144 | 145 | response = connection.get("/server-info").response 146 | plist = CFPropertyList::List.new(data: response.body) 147 | CFPropertyList.native_types(plist.value) 148 | end 149 | end 150 | 151 | # Private: Access extra info of the device 152 | # 153 | # Returns a hash with extra information 154 | # 155 | def extra_info 156 | @_extra_info ||= 157 | begin 158 | new_device = clone 159 | new_device.refresh_connection 160 | new_device.address = "#{ip}:7100" 161 | 162 | result = new_device.connection.get("/stream.xml") 163 | raise result if !result.is_a?(Airplay::Connection::Response) 164 | 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") 186 | end 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Airplay 2 | 3 | [![Code Climate](https://codeclimate.com/github/elcuervo/airplay.png)](https://codeclimate.com/github/elcuervo/airplay) 4 | [![Build Status](https://travis-ci.org/elcuervo/airplay.png?branch=master)](https://travis-ci.org/elcuervo/airplay) 5 | 6 | ![Airplay](doc/img/logo.png) 7 | 8 | Airplay attempts to be compatible with the latest AppleTV firmware but I'd like 9 | to add compatibility to other servers. 10 | 11 | ## Contribute 12 | 13 | You can contribute with code, bugs or feature requests. 14 | 15 | The development of the gem takes time and there's a lot of research and hardware 16 | tests to make all of this. If you want to contribute please consider donating as 17 | much as you want in: [Paypal](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=HE867B8J6ARQ4) or [Gumroad](https://gumroad.com/l/airplay) 18 | 19 | # Table of Contents 20 | 21 | * [Contribute](#contribute) 22 | * [Installation](#installation) 23 | * [Usage](#usage) 24 | * [CLI](#cli-1) 25 | * [Library](#library-1) 26 | * [Testing](#testing) 27 | * [Documentation](#documentation) 28 | * [Contributors](#contributors) 29 | 30 | ## Installation 31 | 32 | ### Library 33 | 34 | `gem install airplay` 35 | 36 | ## CLI 37 | 38 | `gem install airplay-cli` 39 | 40 | ## Usage 41 | 42 | ### CLI 43 | 44 | #### View devices 45 | 46 | `air list` 47 | 48 | ![air list](doc/img/cli_list.png) 49 | 50 | ```text 51 | * Apple TV (AppleTV2,1 running 11A502) 52 | ip: 192.168.1.12 53 | resolution: 1280x720 54 | ``` 55 | 56 | #### Play a video 57 | 58 | `air play [url to video or local file]` 59 | 60 | ![air play](doc/img/cli_play.png) 61 | 62 | ```text 63 | Playing http://movietrailers.apple.com/movies/universal/rush/rush-tlr3_480p.mov?width=848&height=352 64 | Time: 00:00:13 [===== ] 7% Apple TV 65 | ``` 66 | 67 | ### Show images 68 | 69 | `air view [url to image or image folder]` 70 | 71 | ### Library 72 | 73 | #### Configuration 74 | 75 | ```ruby 76 | Airplay.configure do |config| 77 | config.log_level # Log4r levels (Default: Log4r::ERROR) 78 | config.autodiscover # Allows to search for nodes (Default: true) 79 | config.host # In which host to bind the server (Default: 0.0.0.0) 80 | config.port # In which port to bind the server (Default: will find one) 81 | config.output # Where to log (Default: Log4r::Outputter.stdout) 82 | end 83 | ``` 84 | 85 | #### Devices 86 | 87 | ```ruby 88 | require "airplay" 89 | 90 | Airplay.devices.each do |device| 91 | puts device.name 92 | end 93 | ``` 94 | 95 | #### Accessing and Grouping 96 | 97 | ```ruby 98 | # You can access a known device easily 99 | device = Airplay["Apple TV"] 100 | 101 | # And add the password of the device if needed 102 | device.password = "my super secret password" 103 | 104 | # Or you can group known devices to have them do a given action together 105 | Airplay.group["Backyard"] << Airplay["Apple TV"] 106 | Airplay.group["Backyard"] << Airplay["Room TV"] 107 | 108 | # The groups can later do some actions like: 109 | Airplay.group["Backyard"].play("video") 110 | ``` 111 | 112 | #### Images 113 | 114 | ```ruby 115 | require "airplay" 116 | 117 | apple_tv = Airplay["Apple TV"] 118 | 119 | # You can send local files 120 | apple_tv.view("my_image.png") 121 | 122 | # Or use remote files 123 | apple_tv.view("https://github.com/elcuervo/airplay/raw/master/doc/img/logo.png") 124 | 125 | # And define a transition 126 | apple_tv.view("url_to_the_image", transition: "Dissolve") 127 | 128 | # View all transitions 129 | apple_tv.transitions 130 | ``` 131 | 132 | #### Video 133 | 134 | ```ruby 135 | require "airplay" 136 | 137 | apple_tv = Airplay["Apple TV"] 138 | trailer = "http://movietrailers.apple.com/movies/dreamworks/needforspeed/needforspeed-tlr1xxzzs2_480p.mov" 139 | 140 | player = apple_tv.play(trailer) 141 | ``` 142 | 143 | ##### Playlist 144 | 145 | ```ruby 146 | # You can also add videos to a playlist and let the library handle them 147 | player.playlist << "video_url" 148 | player.playlist << "video_path" 149 | player.play 150 | 151 | # Or control it yourself 152 | player.next 153 | player.previous 154 | 155 | # Or if you prefer you can have several playlists 156 | player = apple_tv.player 157 | player.playlists["Star Wars Classic"] << "Star Wars Episode IV: A New Hope" 158 | player.playlists["Star Wars Classic"] << "Star Wars Episode V: The Empire Strikes Back" 159 | player.playlists["Star Wars Classic"] << "Star Wars Episode VI: Return of the Jedi" 160 | 161 | player.playlists["Star Wars"] << "Star Wars Episode I: The Phantom Menace" 162 | player.playlists["Star Wars"] << "Star Wars Episode II: Attack of the Clones" 163 | player.playlists["Star Wars"] << "Star Wars Episode III: Revenge of the Sith" 164 | 165 | player.use("Star Wars Classic") 166 | player.play 167 | player.wait 168 | ``` 169 | 170 | ##### Player 171 | 172 | ```ruby 173 | # Wait until the video is finished 174 | player.wait 175 | 176 | # Actions 177 | player.pause 178 | player.resume 179 | player.stop 180 | player.scrub 181 | player.info 182 | player.seek 183 | 184 | # Access the playback time per second 185 | player.progress -> progress { 186 | puts "I'm viewing #{progress.position} of #{progress.duration}" 187 | } 188 | ``` 189 | 190 | # Testing 191 | 192 | Now there are two types of tests: Regular unit tests and integration tests. 193 | Thanks to the magic of the internet and a raspberry pi there are integration 194 | tests with a real Apple TV that is currently accessible. 195 | 196 | ![Block TV](doc/img/block_tv.jpg) 197 | 198 | The Apple TV is password protected to avoid issues with the tests but is 199 | configured in Travis CI. For that reason you won't be able to run those tests if 200 | you don't have an Apple TV. 201 | 202 | Run unit tests with: `rake test:unit` and integration ones with: `rake test:integration` 203 | You can run all of them together with: `rake test:all` 204 | 205 | ## Documentation 206 | 207 | All the documentation of the README can be found in the `doc` folder. 208 | To generate an updated README based on the contents of `doc` please use `rake doc:generate` 209 | 210 | ## Contributors 211 | 212 | Last but not least a special thanks to all the [contributors](https://github.com/elcuervo/airplay/graphs/contributors) 213 | 214 | -------------------------------------------------------------------------------- /lib/airplay/cli.rb: -------------------------------------------------------------------------------- 1 | require "ruby-progressbar" 2 | require "airplay" 3 | require "airplay/cli/image_viewer" 4 | require "airplay/cli/doctor" 5 | 6 | # Public: Airplay core module 7 | # 8 | module Airplay 9 | # Public: Airplay CLI module 10 | # 11 | module CLI 12 | class << self 13 | # Public: Shows CLI help 14 | # 15 | # Returns nothing. 16 | # 17 | def help 18 | Airplay.configuration.load 19 | puts <<-EOS.gsub!(" "*10, "") 20 | Usage: air [OPTIONS] ACTION [URL OR PATH] 21 | 22 | Command line for the apple tv. 23 | Example: air play my_video.mov 24 | 25 | Actions: 26 | 27 | list - Lists the available devices in the network. 28 | help - This help. 29 | version - The current airplay-cli version. 30 | play - Plays a local or remote video. 31 | view - Shows an image or a folder of images, can be an url. 32 | doctor - Shows some debug information to trace bugs. 33 | 34 | Options: 35 | 36 | --device - Name of the device where it should be played (Default: The first one) 37 | --wait - The wait time for playing an slideshow (Default: 3) 38 | --interactive - Control the slideshow using left and right arrows. 39 | --password - Adds the device password 40 | --url - Allows you to specify an Apple TV url 41 | 42 | EOS 43 | end 44 | 45 | # Public: Lists all the devices to STDOUT 46 | # 47 | # Returns nothing. 48 | # 49 | def list 50 | Airplay.devices.each do |device| 51 | puts <<-EOS.gsub(/^\s{12}/,'') 52 | * #{device.name} (#{device.info.model} running #{device.info.os_version}) 53 | ip: #{device.ip} 54 | mac: #{device.id} 55 | password?: #{device.password? ? "yes" : "no"} 56 | type: #{device.type} 57 | resolution: #{device.info.resolution} 58 | 59 | EOS 60 | end 61 | end 62 | 63 | # Public: Plays a video given a device 64 | # 65 | # video - The url or file path to the video 66 | # options - Options that include the device 67 | # * device: The device in which it should run 68 | # 69 | # Returns nothing. 70 | # 71 | def play(video, options) 72 | device = options[:device] 73 | password = options[:password] 74 | url = options[:url] 75 | 76 | Airplay.devices.add("Apple TV", url) if url 77 | device.password = password if password 78 | 79 | player = device.play(video) 80 | puts "Playing #{video}" 81 | bar = ProgressBar.create( 82 | title: device.name, 83 | format: "%a [%B] %p%% %t" 84 | ) 85 | 86 | player.progress -> playback { 87 | bar.progress = playback.percent if playback.percent 88 | } 89 | 90 | player.wait 91 | end 92 | 93 | def doctor 94 | puts <<-EOS.gsub!(" "*10, "") 95 | 96 | This will run some basic tests on your network trying to find errors 97 | and debug information to help fix them. 98 | 99 | EOS 100 | 101 | who = Airplay::CLI::Doctor.new 102 | 103 | puts "Running dns-sd tests:" 104 | who.information 105 | end 106 | 107 | # Public: Show an image given a device 108 | # 109 | # file_or_dir - The url, file path or folder path to the image/s 110 | # options - Options that include the device 111 | # * device: The device in which it should run 112 | # * interactive: Boolean flag to control playback with the 113 | # arrow keys 114 | # 115 | # Returns nothing. 116 | # 117 | def view(file_or_dir, options) 118 | device = options[:device] 119 | password = options[:password] 120 | url = options[:url] 121 | 122 | if url 123 | Airplay.configure { |c| c.autodiscover = false } 124 | device = Airplay.devices.add("Apple TV", url) 125 | end 126 | device.password = password if password 127 | 128 | viewer = ImageViewer.new(device, options) 129 | 130 | if File.directory?(file_or_dir) 131 | files = Dir.glob("#{file_or_dir}/*") 132 | 133 | if options[:interactive] 134 | viewer.interactive(files) 135 | else 136 | viewer.slideshow(files) 137 | end 138 | else 139 | viewer.view(file_or_dir) 140 | sleep 141 | end 142 | end 143 | 144 | # Public: Shows the current CLI version 145 | # 146 | # Returns nothing 147 | # 148 | def version 149 | Airplay.configuration.load 150 | v = Airplay::CLI::VERSION 151 | puts <<-EOS 152 | 153 | i@@@@@@@@@@@@@@@@@@@@@@@@@ 154 | i80000000000000000000000000000 155 | i80000000000000000000000000000000G 156 | i8000000000000000000000000000000000000 157 | i80000000000000000000000000000000000000000 158 | @00000 @0000000000000000000000000000000000000@ 000000@ 159 | @0000008 @000000000000000000000000000000000@ 80000000@ 160 | @001 @00000000000000000000000000000@ 100@ 161 | @001 @0000000000000000000000000@ 100@ 162 | @001 80000000000000000000008 t00@ 163 | @001 8000000000000000008 t00@ 164 | @001 800000000000008 t00@ 165 | @001 G000000000G t00@ 166 | @001 G00000G t00@ 167 | @001 L0L t00@ 168 | @001 t00@ 169 | @001 air t00@ 170 | @001 #{v} t00@ 171 | @001 t00@ 172 | @001 t00@ 173 | @001 100@ 174 | @00000000000000000000000000000000000000000000000000000G000@ 175 | @000000000000000000000000000000000000000000000000000000000@ 176 | 177 | EOS 178 | end 179 | 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/airplay/player.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "forwardable" 3 | require "micromachine" 4 | require "celluloid/autostart" 5 | require "cfpropertylist" 6 | 7 | require "airplay/connection" 8 | require "airplay/server" 9 | require "airplay/player/timers" 10 | require "airplay/player/media" 11 | require "airplay/player/playback_info" 12 | require "airplay/player/playlist" 13 | 14 | module Airplay 15 | # Public: The class that handles all the video playback 16 | # 17 | class Player 18 | extend Forwardable 19 | include Celluloid 20 | 21 | def_delegators :@machine, :state, :on 22 | 23 | attr_reader :device 24 | 25 | def initialize(device) 26 | @device = device 27 | end 28 | 29 | # Public: Gets all the playlists 30 | # 31 | # Returns the Playlists 32 | # 33 | def playlists 34 | @_playlists ||= Hash.new { |h,k| h[k] = Playlist.new(k) } 35 | end 36 | 37 | # Public: Gets the current playlist 38 | # 39 | # Returns the first Playlist if none defined or creates a new one 40 | # 41 | def playlist 42 | @_playlist ||= if playlists.any? 43 | key, value = playlists.first 44 | value 45 | else 46 | Playlist.new("Default") 47 | end 48 | end 49 | 50 | # Public: Sets a given playlist 51 | # 52 | # name - The name of the playlist to be used 53 | # 54 | # Returns nothing 55 | # 56 | def use(name) 57 | @_playlist = playlists[name] 58 | end 59 | 60 | # Public: Plays a given url or file. 61 | # Creates a new persistent connection to ensure that 62 | # the socket will be kept alive 63 | # 64 | # file_or_url - The url or file to be reproduced 65 | # options - Optional starting time 66 | # 67 | # Returns nothing 68 | # 69 | def play(media_to_play = "playlist", options = {}) 70 | start_the_machine 71 | check_for_playback_status 72 | 73 | media = case true 74 | when media_to_play.is_a?(Media) then media_to_play 75 | when media_to_play == "playlist" && playlist.any? 76 | playlist.next 77 | else Media.new(media_to_play) 78 | end 79 | 80 | content = { 81 | "Content-Location" => media, 82 | "Start-Position" => options.fetch(:time, 0.0) 83 | } 84 | 85 | data = content.map { |k, v| "#{k}: #{v}" }.join("\r\n") 86 | 87 | response = persistent.async.post("/play", data + "\r\n", { 88 | "Content-Type" => "text/parameters" 89 | }) 90 | 91 | timers.reset 92 | end 93 | 94 | # Public: Handles the progress of the playback, the given &block get's 95 | # executed every second while the video is played. 96 | # 97 | # &block - Block to be executed in every playable second. 98 | # 99 | # Returns nothing 100 | # 101 | def progress(callback) 102 | timers << every(1) do 103 | callback.call(info) if playing? 104 | end 105 | end 106 | 107 | # Public: Plays the next video in the playlist 108 | # 109 | # Returns the video that was selected or nil if none 110 | # 111 | def next 112 | video = playlist.next 113 | play(video) if video 114 | video 115 | end 116 | 117 | # Public: Plays the previous video in the playlist 118 | # 119 | # Returns the video that was selected or nil if none 120 | # 121 | def previous 122 | video = playlist.previous 123 | play(video) if video 124 | video 125 | end 126 | 127 | # Public: Shows the current playback time if a video is being played. 128 | # 129 | # Returns a hash with the :duration and current :position 130 | # 131 | def scrub 132 | return unless playing? 133 | response = connection.get("/scrub").response 134 | parts = response.body.split("\n") 135 | Hash[parts.collect { |v| v.split(": ") }] 136 | end 137 | 138 | # Public: checks current playback information 139 | # 140 | # Returns a PlaybackInfo object with the playback information 141 | # 142 | def info 143 | response = connection.get("/playback-info").response 144 | plist = CFPropertyList::List.new(data: response.body) 145 | hash = CFPropertyList.native_types(plist.value) 146 | PlaybackInfo.new(hash) 147 | end 148 | 149 | # Public: Resumes a paused video 150 | # 151 | # Returns nothing 152 | # 153 | def resume 154 | connection.async.post("/rate?value=1") 155 | end 156 | 157 | # Public: Pauses a playing video 158 | # 159 | # Returns nothing 160 | # 161 | def pause 162 | connection.async.post("/rate?value=0") 163 | end 164 | 165 | # Public: Stops the video 166 | # 167 | # Returns nothing 168 | # 169 | def stop 170 | connection.post("/stop") 171 | end 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 | 181 | def loading?; state == :loading end 182 | def playing?; state == :playing end 183 | def paused?; state == :paused end 184 | def played?; state == :played end 185 | def stopped?; state == :stopped end 186 | 187 | # Public: Locks the execution until the video gets fully played 188 | # 189 | # Returns nothing 190 | # 191 | def wait 192 | sleep 1 while wait_for_playback? 193 | cleanup 194 | end 195 | 196 | # Public: Cleans up the player 197 | # 198 | # Returns nothing 199 | # 200 | def cleanup 201 | timers.cancel 202 | persistent.close 203 | end 204 | 205 | private 206 | 207 | # Private: Returns if we have to wait for playback 208 | # 209 | # Returns a boolean if we need to wait 210 | # 211 | def wait_for_playback? 212 | return true if playlist.next? 213 | loading? || playing? || paused? 214 | end 215 | 216 | # Private: The timers 217 | # 218 | # Returns a Timers object 219 | # 220 | def timers 221 | @_timers ||= Timers.new 222 | end 223 | 224 | # Private: The connection 225 | # 226 | # Returns the current connection to the device 227 | # 228 | def connection 229 | @_connection ||= Airplay::Connection.new(@device) 230 | end 231 | 232 | # Private: The persistent connection 233 | # 234 | # Returns the persistent connection to the device 235 | # 236 | def persistent 237 | @_persistent ||= Airplay::Connection.new(@device, keep_alive: true) 238 | end 239 | 240 | # Private: Starts checking for playback status ever 1 second 241 | # Adds one timer to the pool 242 | # 243 | # Returns nothing 244 | # 245 | def check_for_playback_status 246 | timers << every(1) do 247 | case true 248 | when info.stopped? && playing? then @machine.trigger(:stopped) 249 | when info.played? && playing? then @machine.trigger(:played) 250 | when info.playing? && !playing? then @machine.trigger(:playing) 251 | when info.paused? && playing? then @machine.trigger(:paused) 252 | end 253 | end 254 | end 255 | 256 | # Private: Get ready the state machine 257 | # 258 | # Returns nothing 259 | # 260 | def start_the_machine 261 | @machine = MicroMachine.new(:loading) 262 | 263 | @machine.on(:stopped) { cleanup } 264 | @machine.on(:played) do 265 | cleanup 266 | self.next if playlist.next? 267 | end 268 | 269 | @machine.when(:loading, :stopped => :loading) 270 | @machine.when(:playing, { 271 | :paused => :playing, 272 | :loading => :playing, 273 | :stopped => :playing 274 | }) 275 | 276 | @machine.when(:paused, :loading => :paused, :playing => :paused) 277 | @machine.when(:stopped, :playing => :stopped, :paused => :stopped) 278 | @machine.when(:played, :playing => :played, :paused => :played) 279 | end 280 | end 281 | end 282 | --------------------------------------------------------------------------------