├── assets ├── AirPlay-ChromeCast │ └── Icon ├── AirPlay-ChromeCast.png ├── album-art-missing.jpg ├── AirPlay-ChromeCast.icns └── airplay-chromecast.sketch ├── .gitignore ├── scripts ├── test.scpt ├── save_artwork.scpt └── connect.js ├── AirPlay-ChromeCast.icns ├── AirPlay-ChromeCast.ico ├── README.md ├── Makefile ├── lib ├── index.js ├── utils.js ├── itunes.js ├── chromecast.js └── airplay.js ├── test ├── utils-test.js ├── fixtures │ ├── server-csr.pem │ ├── server-cert.pem │ └── server-key.pem ├── chromecast-test.js ├── airplay-chromecast-test.js ├── airplay-test.js └── _helper.js ├── package.json └── example.json /assets/AirPlay-ChromeCast/Icon : -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *DS_Store -------------------------------------------------------------------------------- /scripts/test.scpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/scripts/test.scpt -------------------------------------------------------------------------------- /AirPlay-ChromeCast.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/AirPlay-ChromeCast.icns -------------------------------------------------------------------------------- /AirPlay-ChromeCast.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/AirPlay-ChromeCast.ico -------------------------------------------------------------------------------- /scripts/save_artwork.scpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/scripts/save_artwork.scpt -------------------------------------------------------------------------------- /assets/AirPlay-ChromeCast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/assets/AirPlay-ChromeCast.png -------------------------------------------------------------------------------- /assets/album-art-missing.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/assets/album-art-missing.jpg -------------------------------------------------------------------------------- /assets/AirPlay-ChromeCast.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/assets/AirPlay-ChromeCast.icns -------------------------------------------------------------------------------- /assets/airplay-chromecast.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewelsby/airplay-chromecast/HEAD/assets/airplay-chromecast.sketch -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AirPlay Chromecast 2 | 3 | Enable nearby Chromecast devices to appear as AirPlay audio devices. 4 | 5 | ## Usage 6 | 7 | **This application is work in progress** 8 | 9 | npm install 10 | node lib/index.js 11 | 12 | ## Limitations 13 | 14 | Currently this project support Audio only. 15 | 16 | ## Disclaimer 17 | 18 | This project is not affiliated with or sponsored Apple Inc. or Google Inc. 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION=0.33.9 2 | build: 3 | make build_mac 4 | make build_win 5 | 6 | build_mac: 7 | ./node_modules/.bin/electron-packager . 'AirPlay Chromecast' --platform=darwin --arch=all --version=${VERSION} --icon=AirPlay-ChromeCast.icns --overwrite 8 | build_win: 9 | ./node_modules/.bin/electron-packager . 'Airplay Chromecast' --platform=win32 --arch=all --version=${VERSION} --icon=AirPlay-ChromeCast.ico --overwrite 10 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict' 3 | 4 | var mdns = require('mdns') 5 | var AirPlay = require('./airplay') 6 | var debug = require('debug')('airplay-chromecast') 7 | 8 | var browser = mdns.createBrowser(mdns.tcp('googlecast')) 9 | 10 | browser.on('serviceUp', function (service) { 11 | debug('found device "%s" at %s:%d', service.name, service.addresses[0], service.port) 12 | var airplay = new AirPlay() 13 | airplay.announce(service) 14 | }) 15 | 16 | browser.start() 17 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var RSVP = require('rsvp') 3 | // var debug = require('debug')('airplay-chromecast:utils') 4 | 5 | function promiseWhen(condition, timeout){ 6 | if(!timeout){ 7 | timeout = 2000; 8 | } 9 | var done = RSVP.defer(); 10 | setTimeout(function(){ 11 | done.reject(); 12 | }, timeout); 13 | function loop(){ 14 | if(condition()){ 15 | return done.resolve(); 16 | } 17 | setTimeout(loop,0); 18 | } 19 | setTimeout(loop,0); 20 | 21 | return done.promise; 22 | } 23 | 24 | var utils = { 25 | promiseWhen: promiseWhen 26 | } 27 | 28 | module.exports = utils 29 | -------------------------------------------------------------------------------- /scripts/connect.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | /*globals Application*/ 3 | 4 | function main() { 5 | "use strict"; 6 | var data = {}; 7 | var itunes = new Application("iTunes"); 8 | var app = Application.currentApplication(); 9 | app.includeStandardAdditions = true; 10 | data = JSON.parse(JSON.stringify(itunes.properties())); 11 | data.currentTrack = JSON.parse(JSON.stringify(itunes.currentTrack.properties())); 12 | data.currentTrack.location = itunes.currentTrack.location().toString(); 13 | var artwork = app.doShellScript("osascript save_artwork.scpt"); 14 | data.currentTrack.artwork = artwork; 15 | return JSON.stringify(data, null, 2); 16 | } 17 | main(); 18 | 19 | 20 | /** Useful links 21 | http://qiita.com/zakuroishikuro/items/a7def965f49a2ab55be4 22 | */ 23 | -------------------------------------------------------------------------------- /test/utils-test.js: -------------------------------------------------------------------------------- 1 | var utils = require('./../lib/utils') 2 | describe('promiseWhen', function () { 3 | it('resolves imidately when condition is true', function (done) { 4 | utils.promiseWhen(function () { 5 | return true 6 | }).then(function () { 7 | done() 8 | }) 9 | }) 10 | 11 | it('resolves when condition changes to true', function (done) { 12 | var value = false 13 | setTimeout(function () { 14 | value = true 15 | }, 10) 16 | utils.promiseWhen(function () { 17 | return value 18 | }).then(function () { 19 | done() 20 | }) 21 | }) 22 | 23 | it('resolves multiple times', function (done) { 24 | var value = false 25 | setTimeout(function () { 26 | value = true 27 | }, 10) 28 | utils.promiseWhen(function () { 29 | return value 30 | }).then(function () { 31 | utils.promiseWhen(function () { 32 | return true 33 | }).then(function () { 34 | done() 35 | }) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /test/fixtures/server-csr.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICijCCAXICAQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUx 3 | ITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN 4 | AQEBBQADggEPADCCAQoCggEBALWOc5D5bxjo7fhDkAl/X86c6Wv6YNmDhyVrBKCY 5 | 0iWqJjlUvJhPWe7ERu+wSHN0KhdIRT+erEuCY4VITP8rC6uxFuU+b0cKJwjyrGgb 6 | urKQSqngNNOP7fnWWhrp4ASrIv1Y3FBZBt+mkZML4WTjnkZPRHiPzwgpdTHuMijZ 7 | rJ5fTYPo1srzTnjwcoJWQFKoDUBJCK3HVfUJ1FbMJI0gEsZv9QvRCqMbO2b+vb62 8 | zaiNvpT4TlN4yUIxhapAvkC09XW7BZOjkZspJK3V0YjS6HFNrsWvqIqpzIDlMLJX 9 | QRE6aRUqRYQYH7jKghZiVsgyOQdrf4D6/aDssaj4sjfwfYcCAwEAAaAAMA0GCSqG 10 | SIb3DQEBCwUAA4IBAQB6EBE+tjy4uy4SmnqIOyk7lpnC7avjkL9H6Fn0BkxWTrJ2 11 | odAYXUz4v2KeTSjZnsfjCd4lcfz8ZLgh5Tj1KokrL3gjxLecz0xblwkYuIlv95oQ 12 | 24KlooycKSLx70tcBcz459wu1BVUqI6O5rgEoSuQWmWVngXA6csyoQY/W7/vGMwP 13 | 7CFzYWd4ZsbD8kZtlRuuAoBK6BMTz4ZkuvRCTrnQ1zsjdyyMYOSjHlw33pDbLgJT 14 | yrQlttRr0iVdY9UjWdoNCzAdKtZX/OA9NnOzqyomkZ3z6tsp79JLlzfvzTwZgK5Z 15 | tPMSlLb+2CZN5sOuE/WVgoWF6kk7+o8kcvp767Pn 16 | -----END CERTIFICATE REQUEST----- 17 | -------------------------------------------------------------------------------- /test/fixtures/server-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDBjCCAe4CCQDZ0WPjAHucBjANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQGEwJB 3 | VTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0 4 | cyBQdHkgTHRkMB4XDTE1MTAyMDExNTYwMVoXDTE1MTExOTExNTYwMVowRTELMAkG 5 | A1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNVBAoTGEludGVybmV0 6 | IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 7 | ALWOc5D5bxjo7fhDkAl/X86c6Wv6YNmDhyVrBKCY0iWqJjlUvJhPWe7ERu+wSHN0 8 | KhdIRT+erEuCY4VITP8rC6uxFuU+b0cKJwjyrGgburKQSqngNNOP7fnWWhrp4ASr 9 | Iv1Y3FBZBt+mkZML4WTjnkZPRHiPzwgpdTHuMijZrJ5fTYPo1srzTnjwcoJWQFKo 10 | DUBJCK3HVfUJ1FbMJI0gEsZv9QvRCqMbO2b+vb62zaiNvpT4TlN4yUIxhapAvkC0 11 | 9XW7BZOjkZspJK3V0YjS6HFNrsWvqIqpzIDlMLJXQRE6aRUqRYQYH7jKghZiVsgy 12 | OQdrf4D6/aDssaj4sjfwfYcCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAKKYXxAvh 13 | lOq+Ql2k5XvGRoyxtWdn+Oqr7L6ZIuF+K88fEFxxitYgZLa6C2tY+tjB/pRU74NR 14 | TGAfx2rZISZ7NKaDhV6BlZ4wSbjhfSsaxgBfK09OgL7tSATHHX5NiPCflw4UaT8G 15 | k51xWytT4Og1G4qru2WCsSkrXsG6T61iLLvMpF7xUYJsnTJFVN3ySgGkSImMOusP 16 | I3XefODc2TTdZcc9e8rN2phY4BMd9jeCoLGHQiHRZZEK+9aWRqIZKhq4VBoCmWWg 17 | rZk/PMBJY22FM6pNdQx9co5gJRpRENCFugeFWT4CD4oTggLmxDhUsjamfFAWkjox 18 | cEAzwvbOWOYTLg== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airplay-chromecast", 3 | "version": "1.0.0", 4 | "description": "Enable nearby Chromecast devices to appear as AirPlay audio devices.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "start": "electron .", 8 | "test": "mocha test/" 9 | }, 10 | "keywords": [ 11 | "itunes", 12 | "chromecast", 13 | "airplay", 14 | "streaming", 15 | "audio", 16 | "mac", 17 | "osx" 18 | ], 19 | "author": "Kyle Welsby ", 20 | "license": "MIT", 21 | "dependencies": { 22 | "castv2-client": "^1.1.0", 23 | "debug": "^2.2.0", 24 | "express": "^4.13.3", 25 | "ip": "^1.0.1", 26 | "lame": "^1.2.3", 27 | "macaddress": "^0.2.8", 28 | "mdns": "^2.2.10", 29 | "nodetunes": "git@github.com:microadam/nodetunes.git#master", 30 | "portastic": "git+https://github.com/kylewelsby/node-portastic.git", 31 | "rsvp": "^3.1.0" 32 | }, 33 | "devDependencies": { 34 | "chai": "^3.3.0", 35 | "electron-packager": "^5.1.0", 36 | "electron-prebuilt": "^0.33.8", 37 | "httplike": "^1.0.2", 38 | "mocha": "^2.3.3" 39 | }, 40 | "standard": { 41 | "globals": ["describe", "xdescribe", "it", "xit", "beforeEach", "afterEach"] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/chromecast-test.js: -------------------------------------------------------------------------------- 1 | // var assert = require('chai').assert 2 | var helper = require('./_helper') 3 | var Chromecast = require('./../lib/chromecast') 4 | 5 | describe('Chromecast', function () { 6 | 'use strict' 7 | describe('new Chromecast()', function () { 8 | xit('launches with default media receiver', function () { 9 | // TODO 10 | }) 11 | }) 12 | 13 | describe('.setVolume', function () { 14 | beforeEach(function () { 15 | var mock = new helper.MockChromecast() 16 | mock.start() 17 | }) 18 | it("set's the volume", function (done) { 19 | var cast = new Chromecast({ 20 | host: '127.0.0.1' 21 | }) 22 | 23 | cast.start().then(function () { 24 | return cast.setVolume({ 25 | volume: 0.5 26 | }).then(function () { 27 | done() 28 | }) 29 | }).catch(done) 30 | }) 31 | }) 32 | 33 | describe('.setMetadata', function () { 34 | it('publishes metadata', function (done) { 35 | var cast = new Chromecast({ 36 | host: '127.0.0.1' 37 | }) 38 | 39 | cast.start().then(function () { 40 | return cast.setMetadata({ 41 | title: 'hello world', 42 | metadataType: 3 43 | }).then(function () { 44 | done() 45 | }) 46 | }).catch(done) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /lib/itunes.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict' 3 | 4 | var exec = require('child_process').exec 5 | var Promise = require('rsvp').Promise 6 | var path = require('path') 7 | 8 | /** 9 | * @class 10 | * @name itunes 11 | * @desc Using Javascript OSX ActionScript to request the status of iTunes 12 | * @returns promise 13 | */ 14 | function iTunes () { 15 | this.scriptDir = path.join(__dirname, '..', 'scripts') 16 | } 17 | 18 | /** 19 | * @function 20 | * @name state 21 | * @desc Using Javascript OSX ActionScript, requests the status of iTunes along with the currentTrack data. 22 | * @returns promise 23 | */ 24 | iTunes.prototype.state = function () { 25 | return new Promise(function (resolve, reject) { 26 | exec('./connect.js', { 27 | cwd: this.scriptDir 28 | }, function (err, stdout, stderr) { 29 | if (err) { 30 | reject(err) 31 | } 32 | 33 | resolve(JSON.parse(stdout)) 34 | }) 35 | }.bind(this)) 36 | } 37 | 38 | /** 39 | * @function 40 | * @name getArtwork 41 | * @desc Using ActionScript, requests the currentTrack artwork, returing a file location. 42 | * @returns promise 43 | */ 44 | iTunes.prototype.getArtwork = function (callback) { 45 | return new Promise(function (resolve, reject) { 46 | exec('osascript save_artwork.scpt', { 47 | cwd: this.scriptDir 48 | }, function (err, stdout) { 49 | if (err) { 50 | reject(err) 51 | } 52 | resolve(stdout.replace(/(\r|\n)/g, '')) 53 | }) 54 | }.bind(this)) 55 | } 56 | 57 | module.exports = iTunes 58 | -------------------------------------------------------------------------------- /test/fixtures/server-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAtY5zkPlvGOjt+EOQCX9fzpzpa/pg2YOHJWsEoJjSJaomOVS8 3 | mE9Z7sRG77BIc3QqF0hFP56sS4JjhUhM/ysLq7EW5T5vRwonCPKsaBu6spBKqeA0 4 | 04/t+dZaGungBKsi/VjcUFkG36aRkwvhZOOeRk9EeI/PCCl1Me4yKNmsnl9Ng+jW 5 | yvNOePByglZAUqgNQEkIrcdV9QnUVswkjSASxm/1C9EKoxs7Zv69vrbNqI2+lPhO 6 | U3jJQjGFqkC+QLT1dbsFk6ORmykkrdXRiNLocU2uxa+oiqnMgOUwsldBETppFSpF 7 | hBgfuMqCFmJWyDI5B2t/gPr9oOyxqPiyN/B9hwIDAQABAoIBAB7sBi7Y7N0XPwCb 8 | sTqM4Sp1eyQS3s/tIOV0lrMrlA8qLZxyHDTsvup1r4c/RPe0/z475t9xDPNHX9/h 9 | cou7Lx+s57NfsOAukHtrqYWJw4CKJs1MDHND/kohAfO3hZoF882mhs+AejU5T232 10 | BguC4QAknArUo3RGa1l3sj9W45mNY/kCBBfpYDA90+Jl1548MKgrBXIfzNYmhA/w 11 | 02T5TLQB4o7KYYDIQ4wtSXLB8q/Ux2necg7r6Suq/SztqHsOK4bulU0jEGO7KzvR 12 | LuE8IhLCUnkwFoPkr8Ad9dWC26voPOKdlvlLh2nQ6pRngMSU9/UUmi5X5gz4S/S2 13 | 2zgEhrECgYEA7uz5vSPTo3CkjEqAtWr4Fj6Ql5c/iKedT1y3rSfZ3zGUc09cgWmt 14 | 4bycLJG/F91wkHxb3m3zPtWRPs79fZuwQJ7ond+44XvNkx0bcsrSRnwL6YHEGycp 15 | enp2SxpqmLOtrDfbPuvJyr4aGv4tQKvIELtU4f2CQGmx/xP5o1coMtMCgYEAwofv 16 | TeZ7c5WfDv3E5DZEVWYXWv+RIywLoyYYPjNOU++HXk8gLK2TOzKlOYRo1oyygQOb 17 | G8kg/eN4q/Sn0Bv9chK5uMLoZ8R0IfyyPDFL1E2AZo4f8Us1YIwmj6U1+f8PT6ys 18 | +g1avxdlZXV1gbhJZNgph9ddqrXSgXpfhipo0f0CgYBp9R4sCx9odCQFM2UxD2dG 19 | NijZkXHToMHa2Dt8xxeQK+Sx3pgfrEY4vfRkr54Xb1TkPsJfOlONohjvycwRUGIx 20 | xkdS/Ex5cvYQL/BkUB5As/J0c+AMSqnSb0zWsbhkUArNPVg150tuZjAdmGtpKsVm 21 | 5i0Wq95/wl/1x14GWjixkQKBgC90H5E+PzEz94gdqZEOF99JNztQiqQs023qFhHe 22 | TCY3kSsVXEDp2refJCUCwtxLmCPWsFi32J92hPJVjTY8DbhtDvcePLlLeBTuYsBC 23 | OkBA46ig1PP0p8G0jpeN8rvYV3DVK6EX7uIcn/R/EboiLW03OfJUPBovnnlNU84T 24 | vFw1AoGBAKsMYMyZKcfcCUea7NZVpdfL4VwxBZ5pEhd4+lB/2Dv/xA52tVMGJ3nJ 25 | ZzziVGBDwLxmZ3eM9iEOs9W9qYJJMq85DfKk2SK9m83ZXjb5v7BY5OpqkJFIqGls 26 | f+HPH2/4Kps6e3viSGmvtCZGtoS41FEB0U6S8Vh3awdPLG1qgCW0 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/airplay-chromecast-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, node */ 2 | var assert = require('chai').assert 3 | var mdns = require('mdns') 4 | var net = require('net') 5 | 6 | var helper = require('./_helper') 7 | 8 | require('../lib/index') 9 | 10 | describe('AirPlay Chromecast', function () { 11 | 'use strict' 12 | this.timeout(5000) 13 | 14 | it('it advertises Chromecast as AirPlay derice', function (done) { 15 | var browser = mdns.createBrowser(mdns.tcp('_raop')) 16 | 17 | browser.on('serviceUp', function (service) { 18 | if (/@Test-1$/.test(service.name)) { 19 | assert.match(service.name, /@Test-1$/, 'includes service name') 20 | done() 21 | } 22 | }) 23 | browser.start() 24 | 25 | var client = mdns.createAdvertisement(mdns.tcp('googlecast'), 9876, { 26 | name: 'Test-1' 27 | }) 28 | client.start() 29 | }) 30 | 31 | function updateDetail (port) { 32 | var client = new net.Socket().connect({ port: port }, function () { 33 | client.write(helper.rtpmethods.setParameter('metadataChange')) 34 | }) 35 | } 36 | 37 | function connectClient (port) { 38 | var client = new net.Socket().connect({ port: port }, function () { 39 | client.write(helper.rtpmethods.announce()) 40 | }) 41 | } 42 | 43 | describe('onPlay', function () { 44 | it('updates metadata', function (done) { 45 | net.createServer(function (socket) {}).listen(8009) 46 | var browser = mdns.createBrowser(mdns.tcp('_raop')) 47 | browser.on('serviceUp', function (service) { 48 | if (/@Test-1$/.test(service.name)) { 49 | connectClient(service.port) 50 | setTimeout(function () { 51 | updateDetail(service.port) 52 | }, 100) 53 | } 54 | }) 55 | browser.start() 56 | 57 | var cast = mdns.createAdvertisement(mdns.tcp('googlecast'), 9876, { 58 | name: 'Test-1' 59 | }) 60 | cast.start(function () { 61 | new net.Socket().connect({ port: 9876 }, function () {}) 62 | }) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/airplay-test.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert 2 | var path = require('path') 3 | var net = require('net') 4 | var helper = require('./_helper') 5 | var AirPlay = require('./../lib/airplay') 6 | 7 | describe('AirPlay', function () { 8 | 'use strict' 9 | describe('new Airplay()', function () { 10 | it('defines default artwork path', function () { 11 | assert.equal(new AirPlay().artworkPath, path.join(__dirname, '..', 'assets', 'album-art-missing.jpg'), 'album-art-missing.jpg') 12 | }) 13 | }) 14 | 15 | describe('#announce', function () { 16 | it("listens to 'clientConnected'", function (done) { 17 | this.timeout(5000) 18 | 19 | net.createServer(function (socket) {}).listen(8009) 20 | 21 | var client = new net.Socket() 22 | var airplay = new AirPlay() 23 | 24 | airplay.announce({ 25 | name: 'Test-1', 26 | addresses: [ 27 | '0.0.0.0' 28 | ] 29 | }).then(function () { 30 | airplay.server.on('clientConnected', function () { 31 | setTimeout(function () { 32 | assert.match(airplay.streamURI, /http:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{2,6}\/stream.mp3/) 33 | done() 34 | }, 10) 35 | }) 36 | client.connect({ port: airplay.port }, function () { 37 | client.write(helper.rtpmethods.announce()) 38 | }) 39 | }).catch(done) 40 | }) 41 | 42 | it("listens to 'metadataChange'", function (done) { 43 | var client = new net.Socket() 44 | var airplay = new AirPlay() 45 | airplay.getArtwork = function () { // stub artwork 46 | return { 47 | then: function(callback){ 48 | return callback("/tmp/image.jpg") 49 | } 50 | } 51 | } 52 | airplay.chromecast = { 53 | setMetadata: function (data) { 54 | assert.equal(data.title, 'Track Name', 'track name') 55 | assert.equal(data.artist, 'Artist', 'track artist') 56 | assert.equal(data.albumName, 'Album Name', 'track album name') 57 | assert.match(data.images[0].url, /^data:image\/jpeg;base64,/, 'base64 image') 58 | done() 59 | } 60 | } 61 | 62 | airplay.announce({ 63 | name: 'Test-1' 64 | }).then(function () { 65 | client.connect({ port: airplay.port }, function () { 66 | client.write(helper.rtpmethods.setParameter('metadataChange')) 67 | }) 68 | }).catch(function (err) { 69 | done(err) 70 | }) 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /example.json: -------------------------------------------------------------------------------- 1 | { 2 | "class": "application", 3 | "name": "iTunes", 4 | "playerState": "paused", 5 | "version": "12.3", 6 | "frontmost": false, 7 | "soundVolume": 97, 8 | "mute": false, 9 | "visualsEnabled": false, 10 | "fullScreen": true, 11 | "visualSize": "large", 12 | "eqEnabled": true, 13 | "fixedIndexing": false, 14 | "playerPosition": 695.1099853515625, 15 | "converting": false, 16 | "currentStreamTitle": null, 17 | "currentStreamURL": null, 18 | "airplayEnabled": false, 19 | "currentAirPlayDevices": [ 20 | null 21 | ], 22 | "iadIdentifier": "9E794FC9-FE93-4A19-B256-35D96D37C508", 23 | "currentTrack": { 24 | "class": "fileTrack", 25 | "id": 43697, 26 | "index": 15, 27 | "name": "DJ Jazzy Jeff @ The Do-Over L.A. (5.19.2013)", 28 | "persistentID": "9409A4530C22D7F5", 29 | "databaseID": 25449, 30 | "dateAdded": "2015-10-02T10:10:50.000Z", 31 | "time": "1:55:10", 32 | "duration": 6910.955078125, 33 | "artist": "The Do-Over", 34 | "albumArtist": "", 35 | "composer": "", 36 | "album": "", 37 | "genre": "", 38 | "bitRate": 320, 39 | "sampleRate": 44100, 40 | "trackCount": 0, 41 | "trackNumber": 0, 42 | "discCount": 0, 43 | "discNumber": 0, 44 | "size": 276486905, 45 | "volumeAdjustment": 0, 46 | "year": 0, 47 | "comment": "blog.thedoover.net.", 48 | "eq": "", 49 | "kind": "MPEG audio file", 50 | "videoKind": "none", 51 | "modificationDate": "2015-08-18T12:38:15.000Z", 52 | "enabled": true, 53 | "start": 0, 54 | "finish": 6910.955078125, 55 | "playedCount": 1, 56 | "playedDate": "2015-10-07T05:50:13.000Z", 57 | "skippedCount": 0, 58 | "skippedDate": null, 59 | "compilation": false, 60 | "gapless": null, 61 | "rating": 0, 62 | "bpm": 0, 63 | "grouping": "", 64 | "podcast": false, 65 | "itunesu": false, 66 | "bookmarkable": false, 67 | "bookmark": 0, 68 | "shufflable": true, 69 | "lyrics": "", 70 | "category": "", 71 | "description": "", 72 | "longDescription": null, 73 | "show": "", 74 | "seasonNumber": 0, 75 | "episodeID": "", 76 | "episodeNumber": 0, 77 | "unplayed": false, 78 | "sortName": "", 79 | "sortAlbum": "", 80 | "sortArtist": "Do-Over", 81 | "sortComposer": "", 82 | "sortAlbumArtist": "", 83 | "sortShow": "", 84 | "releaseDate": null, 85 | "loved": false, 86 | "albumLoved": false, 87 | "location": "/Users/kyle/Music/iTunes/iTunes Media/Music/The Do-Over/Unknown Album/DJ Jazzy Jeff @ The Do-Over L.A. (5.19.2013).mp3" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/chromecast.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict' 3 | 4 | var Client = require('castv2-client').Client 5 | var fs = require('fs') 6 | var path = require('path') 7 | var DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver 8 | var utils = require('./utils') 9 | var debug = require('debug')('airplay-chromecast:chromecast') 10 | var Promise = require('rsvp').Promise 11 | 12 | function Chromecast (config) { 13 | this.client = new Client() 14 | this.player = null 15 | this.host = config.host 16 | this.status = {} 17 | this.mediaInfo = {}; 18 | var artworkPath = path.join(__dirname, '..', 'assets', 'album-art-missing.jpg') 19 | fs.readFile(artworkPath, function (err, data) { 20 | if (!err) { 21 | this.artwork = 'data:image/jpeg;base64,' + new Buffer(data).toString('base64') 22 | } 23 | }.bind(this)) 24 | return this 25 | } 26 | 27 | Chromecast.prototype.start = function () { 28 | return new Promise(function (resolve, reject) { 29 | this.client.connect(this.host, function () { 30 | this.client.launch(DefaultMediaReceiver, function (err, player) { 31 | if (err) { 32 | throw err; 33 | } 34 | debug('Launched Player') 35 | this.player = player 36 | player.on('status', function (status) { 37 | debug('Status', status) 38 | this.status = status 39 | }.bind(this)) 40 | resolve(player) 41 | }.bind(this)) 42 | }.bind(this)) 43 | }.bind(this)) 44 | } 45 | 46 | Chromecast.prototype.load = function(){ 47 | return new Promise(function(resolve, reject) { 48 | this.player.load(this.mediaInfo, {autoplay: true}, function (err, status) { 49 | if (err) { 50 | reject(err) 51 | } 52 | debug("Loaded", status) 53 | resolve(status) 54 | }.bind(this)) 55 | }.bind(this)); 56 | } 57 | 58 | Chromecast.prototype.setStream = function (config) { 59 | if (!config.contentType) { 60 | config.contentType = 'audio/mpeg3' 61 | } 62 | this.mediaInfo = { 63 | contentId: config.streamURI, 64 | contentType: config.contentType, 65 | streamType: 'LIVE', 66 | metadata: { 67 | matadataType: 0, 68 | title: "AirPlay", 69 | subtitle: "", 70 | images: [ 71 | { 72 | url: this.artwork 73 | } 74 | ] 75 | } 76 | // metadata: this.currentMetadata 77 | } 78 | debug('Loading', this.mediaInfo) 79 | return this.load(); 80 | } 81 | 82 | Chromecast.prototype.setVolume = function (volumeSetting) { 83 | return new Promise(function (resolve, reject) { 84 | debug('set volume', volumeSetting) 85 | this.client.setVolume(volumeSetting, function (err) { 86 | if (err) { 87 | reject(err) 88 | } 89 | resolve() 90 | }) 91 | }.bind(this)) 92 | } 93 | 94 | Chromecast.prototype.setMetadata = function (metadata) { 95 | debug("set metadata", metadata) 96 | this.mediaInfo.metadata = metadata; 97 | // return this.load(); 98 | } 99 | 100 | module.exports = Chromecast 101 | -------------------------------------------------------------------------------- /lib/airplay.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict' 3 | 4 | var AirTunesServer = require('nodetunes') 5 | var Lame = require('lame') 6 | var express = require('express') 7 | var fs = require('fs') 8 | var path = require('path') 9 | var ip = require('ip') 10 | var portastic = require('portastic') 11 | var debug = require('debug')('airplay-chromecast:airplay') 12 | 13 | var utils = require('./utils') 14 | var Chromecast = require('./chromecast') 15 | var Itunes = require('./itunes') 16 | 17 | function AirPlay () { 18 | this.port = -1 19 | this.artworkURI = '' 20 | this.iTunes = new Itunes() 21 | this.chromecast = null 22 | this.currentMetadata = {} 23 | this.artworkPath = path.join(__dirname, '..', 'assets', 'album-art-missing.jpg') 24 | fs.readFile(this.artworkPath, function (err, data) { 25 | if (!err) { 26 | this.artworkURI = 'data:image/jpeg;base64,' + new Buffer(data).toString('base64') 27 | } 28 | }.bind(this)) 29 | } 30 | 31 | AirPlay.prototype.start = function (stream) { 32 | return new Promise(function (resolve, reject) { 33 | portastic.find({ 34 | min: 9800, 35 | max: 9890, 36 | retrieve: 1 37 | }).then(function (port) { 38 | port = port[0] 39 | var encoder = new Lame.Encoder({ 40 | channels: 2, 41 | bitDepth: 16, 42 | sampleRate: 44100, 43 | bitRate: 192, 44 | outSampleRate: 44100, 45 | mode: Lame.STEREO 46 | }) 47 | 48 | stream.pipe(encoder) 49 | 50 | var app = express() 51 | app.get('/artwork.jpg', function (req, res) { 52 | fs.createReadStream(this.artworkPath).pipe(res) 53 | }.bind(this)) 54 | app.get('/stream.mp3', function (req, res) { 55 | res.set({ 56 | 'Content-Type': 'audio/mpeg3', 57 | 'Transfer-Encoding': 'chunked' 58 | }) 59 | encoder.pipe(res) 60 | }) 61 | app.listen(port) 62 | this.streamURI = 'http://' + ip.address() + ':' + port + '/stream.mp3' 63 | this.artworkURI = 'http://' + ip.address() + ':' + port + '/artwork.jpg' 64 | resolve({ 65 | streamURI: this.streamURI, 66 | artworkURI: this.artworkURI 67 | }) 68 | }.bind(this)) 69 | }.bind(this)) 70 | } 71 | 72 | AirPlay.prototype.getArtwork = function () { 73 | return this.iTunes.getArtwork().then(function (artworkPath) { 74 | this.artworkPath = artworkPath 75 | return artworkPath 76 | }.bind(this)) 77 | } 78 | 79 | AirPlay.prototype.announce = function (service) { 80 | return new Promise(function (resolve, reject) { 81 | this.server = new AirTunesServer({ 82 | serverName: service.name 83 | }) 84 | 85 | this.server.on('clientConnected', function (stream) { 86 | debug('clientConnected') 87 | this.start(stream) 88 | this.chromecast = new Chromecast({ 89 | host: service.addresses.sort()[0] 90 | }) 91 | this.chromecast.start().then(function () { 92 | debug('Chromecast started') 93 | return this.chromecast.setStream({ 94 | contentType: 'audio/mpeg3', 95 | streamURI: this.streamURI 96 | }) 97 | }.bind(this)) 98 | }.bind(this)) 99 | 100 | this.server.on('clientDisconnected', function () { 101 | if (this.chromecast) { 102 | this.chromecast.close() 103 | } 104 | }) 105 | // 106 | // this.server.on("artworkChange", function(artwork) { 107 | // debug("artworkChange", artwork) 108 | // }) 109 | // 110 | this.server.on('volumeChange', function (volume) { 111 | debug('volumeChange', volume) 112 | return utils.promiseWhen(function () { 113 | return this.chromecast !== null 114 | }.bind(this)).then(function () { 115 | this.chromecast.setVolume({ 116 | volume: (Math.abs(volume) * 100) / 144 / 100 117 | }) 118 | }.bind(this)) 119 | }) 120 | 121 | this.server.on('metadataChange', function (metadata) { 122 | debug('metadataChange', metadata) 123 | this.getArtwork().then(function (path) { 124 | debug('artwork collected', path) 125 | this.currentMetadata = { 126 | albumName: metadata.asal, 127 | artist: metadata.asar, 128 | trackNumber: metadata.astn, 129 | discNumber: metadata.asdk, 130 | title: metadata.minm, 131 | images: [{ 132 | url: this.artworkURI 133 | }], 134 | metadataType: 3 135 | } 136 | 137 | return utils.promiseWhen(function () { 138 | return this.chromecast !== null 139 | }.bind(this)).then(function () { 140 | return this.chromecast.setMetadata(this.currentMetadata) 141 | }.bind(this)) 142 | }.bind(this)) 143 | }.bind(this)) 144 | 145 | this.server.start(function (err, server) { 146 | if (err) { 147 | reject(err) 148 | } 149 | this.port = server.port 150 | debug('Started AirPlay server: %s on port %s', service.name, server.port) 151 | resolve(server) 152 | }.bind(this)) 153 | }.bind(this)) 154 | } 155 | 156 | module.exports = AirPlay 157 | -------------------------------------------------------------------------------- /test/_helper.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var daap = require('node-daap') 4 | var Chromeserver = require('castv2').Server 5 | 6 | var rtpmethods = {} 7 | rtpmethods.announce = function () { 8 | var rsaAesKey = 'ldAdTcI8b2okzDhz3bCnFPwwMVwwGCVt8+0bqURzomwUVWh5gwuee14E8FszGvrJvl5+3lfXMMDw3MRTO4arG380WNq3hl7H+ck' + 9 | 'wgID2ZiV3YgSwh/oVA5QieD65m5vtYyNqe1dypQHOE0Fz/fOXb5ySpmzVvbJbMKP7H7DucpoXTWvk9CHMLZU8z9vWUVxMi862FPNLFWfrCE9NBM' + 10 | 'bwFk2r40QdbYC5fd+6d/ynrDLit6V5T/l8ESi6tcC4vRFrM8j2gQkGwLilpbKL+k38rBvZK+zTs8k/k25zOb7xtfrKoWJ7soIska+unVnEF5ILE' + 11 | 'XyE3eg0NsB/IrmqKIrV9Q==' 12 | var rsaAesIv = 'VkH+lhtE7jGkV5rUPM64aQ==' 13 | var codec = '96 L16/44100/2' 14 | var announceContent = 'v=0\r\no=AirTunes 7709564614789383330 0 IN IP4 172.17.104.138\r\ns=AirTunes\r\n' + 15 | 'i=Airply Chromecast\r\nc=IN IP4 172.17.104.138\r\nt=0 0\r\nm=audio 0 RTP/AVP 96\r\na=rtpmap:' + codec + '\r\n' + 16 | 'a=rsaaeskey:' + rsaAesKey + '\r\na=aesiv:' + rsaAesIv + '\r\na=min-latency:11025\r\na=max-latency:88200' 17 | var content = ('ANNOUNCE * RTSP/1.0\r\nCSeq: 0\r\nUser-Agent: AirPlay/190.9\r\nContent-Length: ' + announceContent.length + '\r\n\r\n' + announceContent) 18 | return content 19 | } 20 | rtpmethods.setParameter = function (type) { 21 | var content 22 | if (type === 'volumeChange') { 23 | // TODO: implement volumeChange response. 24 | } else { // metadataChange 25 | var name = daap.encode('minm', 'Track Name') 26 | var artist = daap.encode('asar', 'Artist') 27 | var album = daap.encode('asal', 'Album Name') 28 | var daapContent = daap.encodeList('mlit', name, artist, album) 29 | content = ('SET_PARAMETER * RTSP/1.0\r\nCSeq: 2\r\nUser-Agent: AirPlay/190.9\r\nContent-Type: application/x-dmap-tagged\r\nContent-Length: ' + daapContent.length + '\r\n\r\n' + daapContent) 30 | } 31 | return content 32 | } 33 | 34 | module.exports.rtpmethods = rtpmethods 35 | 36 | function MockChromecast () { 37 | } 38 | 39 | MockChromecast.prototype.start = function () { 40 | var server = new Chromeserver({ 41 | cert: fs.readFileSync(path.join(__dirname, 'fixtures', 'server-cert.pem')), 42 | key: fs.readFileSync(path.join(__dirname, 'fixtures', 'server-key.pem')), 43 | host: '127.0.0.1' 44 | }) 45 | server.listen(8009) 46 | server.on('message', function (id, sourceId, destId, namespace, data) { 47 | var json = {} 48 | if (typeof data === 'string') { 49 | json = JSON.parse(data) 50 | } 51 | if (json.type === 'PING') { 52 | server.send(id, destId, sourceId, namespace, JSON.stringify({ 53 | type: 'PONG' 54 | })) 55 | } 56 | if (json.type === 'SET_VOLUME') { 57 | server.send(id, destId, sourceId, namespace, JSON.stringify({ 58 | requestId: json.requestId, 59 | status: { 60 | applications: [ 61 | { 62 | 'appId': 'CC1AD845', 63 | 'displayName': 'Default Media Receiver', 64 | 'namespaces': [ 65 | 'urn:x-cast:com.google.cast.player.message', 66 | 'urn:x-cast:com.google.cast.media' 67 | ], 68 | 'sessionId': '5E2A9CCB-A70B-41FE-9202-B97D10D44889', 69 | 'statusText': 'Ready To Cast', 70 | 'transportId': 'web-3' 71 | } 72 | ], 73 | volume: { 74 | level: 1.0, 75 | muted: false 76 | } 77 | } 78 | })) 79 | } 80 | if (json.type === 'EDIT_TRACKS_INFO') { 81 | server.send(id, destId, sourceId, namespace, JSON.stringify({ 82 | requestId: json.requestId, 83 | type: 'RECEIVER_STATUS', 84 | status: { 85 | applications: [ 86 | { 87 | 'appId': 'CC1AD845', 88 | 'displayName': 'Default Media Receiver', 89 | 'namespaces': [ 90 | 'urn:x-cast:com.google.cast.player.message', 91 | 'urn:x-cast:com.google.cast.media' 92 | ], 93 | 'sessionId': '5E2A9CCB-A70B-41FE-9202-B97D10D44889', 94 | 'statusText': 'Ready To Cast', 95 | 'transportId': 'web-3' 96 | } 97 | ], 98 | volume: { 99 | level: 1.0, 100 | muted: false 101 | } 102 | } 103 | })) 104 | } 105 | if (json.type === 'LAUNCH') { 106 | server.send(id, destId, sourceId, namespace, JSON.stringify({ 107 | requestId: json.requestId, 108 | type: 'RECEIVER_STATUS', 109 | status: { 110 | applications: [ 111 | { 112 | 'appId': 'CC1AD845', 113 | 'displayName': 'Default Media Receiver', 114 | 'namespaces': [ 115 | 'urn:x-cast:com.google.cast.player.message', 116 | 'urn:x-cast:com.google.cast.media' 117 | ], 118 | 'sessionId': '5E2A9CCB-A70B-41FE-9202-B97D10D44889', 119 | 'statusText': 'Ready To Cast', 120 | 'transportId': 'web-3' 121 | } 122 | ], 123 | volume: { 124 | level: 1.0, 125 | muted: false 126 | } 127 | } 128 | })) 129 | } 130 | }) 131 | } 132 | 133 | module.exports.MockChromecast = MockChromecast 134 | --------------------------------------------------------------------------------