├── .dockerignore ├── .gitignore ├── stream ├── soundcloud.js ├── youtube.js └── spotify.js ├── LICENCE ├── .env.example ├── tracks.html ├── player.js ├── search ├── search.js ├── spotify.js ├── soundcloud.js ├── search-service.js └── youtube.js ├── Dockerfile ├── index.js ├── index.html ├── package.json ├── app.css ├── readme.markdown └── routes.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tracks 3 | .DS_Store 4 | npm-debug.log 5 | *.mp3 6 | .env 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | tracks 4 | .DS_Store 5 | npm-debug.log 6 | *.mp3 7 | -------------------------------------------------------------------------------- /stream/soundcloud.js: -------------------------------------------------------------------------------- 1 | var request = require('hyperdirect')() 2 | 3 | module.exports = function (uri) { 4 | return request(uri + '?client_id=' + process.env.SOUNDCLOUD_CLIENT_ID) 5 | } 6 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright 2013 James Kyburz (james.kyburz@gmail.com) 2 | 3 | This project is free software released under the MIT license: 4 | http://www.opensource.org/licenses/mit-license.php (The MIT License) -------------------------------------------------------------------------------- /stream/youtube.js: -------------------------------------------------------------------------------- 1 | var stream = require('youtube-audio-stream') 2 | 3 | module.exports = function (uri) { 4 | uri = uri.match(/youtube.*/)[0] 5 | return stream(uri.match(/youtube.*/)[0]) 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=1338 2 | MAX_SEARCH_RESULTS=2 3 | SOURCES=spotify soundcloud youtube 4 | SOUNDCLOUD_CLIENT_ID= 5 | YOUTUBE_API_KEY= 6 | SPOTIFY_USER= 7 | SPOTIFY_PASSWORD= 8 | SERVICE_CREDENTIALS= 9 | REMOTE_SPEAKER= 10 | -------------------------------------------------------------------------------- /tracks.html: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /player.js: -------------------------------------------------------------------------------- 1 | var through = require('through2') 2 | var decoder = require('lame').Decoder() 3 | var request = require('hyperquest') 4 | var stream = require('./routes').stream 5 | 6 | process.on('message', function (uri) { 7 | var speakerUrl = process.env.REMOTE_SPEAKER 8 | var track = through() 9 | stream({params: [uri]}, track) 10 | track 11 | .pipe(decoder) 12 | .pipe(speakerUrl ? request.post(speakerUrl) : require('speaker')()) 13 | }) 14 | -------------------------------------------------------------------------------- /stream/spotify.js: -------------------------------------------------------------------------------- 1 | var Spotify = require('spotify-web') 2 | var through = require('through2') 3 | 4 | module.exports = function (uri) { 5 | var stream = through() 6 | Spotify.login(process.env.SPOTIFY_USER, process.env.SPOTIFY_PASSWORD, function (err, spotify) { 7 | if (err) throw err 8 | spotify.get(uri, function (err, track) { 9 | if (err) throw err 10 | track 11 | .play() 12 | .pipe(stream) 13 | .on('finish', spotify.disconnect.bind(spotify)) 14 | }) 15 | }) 16 | return stream 17 | } 18 | -------------------------------------------------------------------------------- /search/search.js: -------------------------------------------------------------------------------- 1 | module.exports = function (term, cb) { 2 | var results = {} 3 | var result = [] 4 | var sources = (process.env.SOURCES || 'youtube soundcloud spotify').split(' ') 5 | var pending = sources.length 6 | sources.forEach(search) 7 | function search (source) { 8 | require('./' + source)(term, complete) 9 | function complete (data) { 10 | results[source] = data 11 | if (!--pending) { 12 | sources.forEach(add) 13 | cb(result) 14 | } 15 | } 16 | function add (source) { 17 | result = result.concat(results[source]) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /search/spotify.js: -------------------------------------------------------------------------------- 1 | var search = require('./search-service') 2 | module.exports = function (term, cb) { 3 | var options = { 4 | callback: cb, 5 | parsingKeys: ['tracks', 'items', true], 6 | uri: 'https://api.spotify.com/v1/search?q=' + term + '&type=track', 7 | parseTrack: function (track) { 8 | var title = track.artists.map(function (x) { return x.name }).join(', ') 9 | title += ' ' + track.name 10 | var ms = track.duration_ms 11 | return { 12 | uri: track.uri, 13 | title: title, 14 | source: 'spotify', 15 | durationMs: ms, 16 | duration: search.msToTime(ms) 17 | } 18 | } 19 | } 20 | search(options) 21 | } 22 | -------------------------------------------------------------------------------- /search/soundcloud.js: -------------------------------------------------------------------------------- 1 | var search = require('./search-service') 2 | module.exports = function (term, cb) { 3 | var options = { 4 | callback: cb, 5 | parsingKeys: ['collection', true], 6 | uri: 'https://api.soundcloud.com/search.json?client_id=' + process.env.SOUNDCLOUD_CLIENT_ID + '&q=' + term + '&limit=4', 7 | parseTrack: function (track) { 8 | var ms = parseInt(track.duration, 10) 9 | if (track.kind === 'track' && track.stream_url) { 10 | return { 11 | uri: track.stream_url, 12 | title: track.title, 13 | source: 'soundcloud', 14 | durationMs: ms, 15 | duration: search.msToTime(ms) 16 | } 17 | } 18 | } 19 | } 20 | search(options) 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | from nodesource/trusty:5.0.0 2 | 3 | ENV FFMPEG_VERSION=2.8.1 \ 4 | X264_VERSION=snapshot-20151022-2245-stable 5 | 6 | RUN apt-get update \ 7 | && DEBIAN_FRONTEND=noninteractive apt-get install -y bzip2 libgnutlsxx27 libogg0 libjpeg8 libpng12-0 libasound2-dev alsa-utils \ 8 | libvpx1 libtheora0 libxvidcore4 libmpeg2-4 \ 9 | libvorbis0a libfaad2 libmp3lame0 libmpg123-0 libmad0 libopus0 libvo-aacenc0 wget \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | RUN mkdir -p /usr/src/app 13 | WORKDIR /usr/src/app 14 | COPY . /usr/src/app 15 | VOLUME ["/config"] 16 | ENV CONFIG "/config 17 | 18 | RUN npm install 19 | 20 | RUN mkdir -p /var/cache/ffmpeg/ 21 | ADD https://raw.githubusercontent.com/sameersbn/docker-ffmpeg/master/install.sh /var/cache/ffmpeg/install.sh 22 | RUN bash /var/cache/ffmpeg/install.sh 23 | 24 | CMD npm start 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var dotenv = require('dotenv') 2 | var http = require('http') 3 | var stack = require('stack') 4 | var routes = require('./routes') 5 | var route = require('tiny-route') 6 | var debug = require('debug')('index.js') 7 | 8 | if (!module.parent) { 9 | start() 10 | } else { 11 | module.exports = start 12 | } 13 | 14 | function start (configPath) { 15 | configPath = configPath || process.env.CONFIG || '.env' 16 | dotenv.load({ path: configPath }) 17 | http.createServer(stack( 18 | routes.authenticate, 19 | route.get(/^\/play\/(.*)/, routes.play), 20 | route.get(/^\/stream\/(.*)/, routes.stream), 21 | route.get('/favicon.ico', routes.emptyFavicon), 22 | route.get('/app.css', routes.appCss), 23 | route.post('/search', routes.search), 24 | route.get('/', routes.main) 25 | )).listen(process.env.PORT, started) 26 | 27 | function started () { 28 | debug('running on http://localhost:%s', process.env.PORT) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /search/search-service.js: -------------------------------------------------------------------------------- 1 | var hyperquest = require('hyperquest') 2 | var through = require('through2') 3 | var JSONStream = require('JSONStream') 4 | var maxResults = process.env.MAX_SEARCH_RESULTS 5 | 6 | module.exports = search 7 | 8 | function search (options) { 9 | var tracks = [] 10 | hyperquest(options.uri) 11 | .pipe(JSONStream.parse(options.parsingKeys)) 12 | .pipe(through.obj(function toTrack (track, enc, cb) { 13 | if (tracks.length < maxResults) { 14 | track = options.parseTrack(track) 15 | if (track) { 16 | tracks.push(track) 17 | } 18 | } 19 | cb() 20 | }, end) 21 | ) 22 | function end () { 23 | options.callback(tracks) 24 | } 25 | } 26 | 27 | search.maxResults = maxResults 28 | 29 | search.msToTime = function (ms) { 30 | var seconds = n(ms / 1000 % 60) 31 | var minutes = n(ms / 60000 % 60) 32 | var hours = n(ms / 3600000 % 24) 33 | 34 | function n (x) { 35 | x = parseInt(x, 10) 36 | return x < 10 ? '0' + x : x 37 | } 38 | 39 | return (hours + ':' + minutes + ':' + seconds).replace(/^00:/, '') 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundify", 3 | "description": "stream tracks to devices from multiple sources", 4 | "version": "2.0.60", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/jameskyburz/soundify.git" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "start": "node index", 12 | "prepublish": "standard", 13 | "docker:build": "docker build -t soundify .", 14 | "docker:run": "docker run -d --restart=always -v /opt/soundify:/config -p 1338:1338 -e CONFIG=/config/.env -e REMOTE_SPEAKER=$REMOTE_SPEAKER --name soundify soundify" 15 | }, 16 | "author": { 17 | "name": "James Kyburz", 18 | "email": "james.kyburz@gmail.com" 19 | }, 20 | "keywords": [ 21 | "music", 22 | "player", 23 | "streaming", 24 | "youtube", 25 | "soundcloud", 26 | "spotify" 27 | ], 28 | "dependencies": { 29 | "JSONStream": "1.3.1", 30 | "cookie-cutter": "0.2.0", 31 | "debug": "4.1.1", 32 | "dotenv": "4.0.0", 33 | "hyperdirect": "0.0.0", 34 | "hyperglue": "2.0.1", 35 | "hyperquest": "2.1.2", 36 | "lame": "1.2.4", 37 | "speaker": "0.3.0", 38 | "spotify-web": "git+http://git@github.com/fuzeman/node-spotify-web.git#feature/ping-pong-fix", 39 | "stack": "0.1.0", 40 | "through2": "2.0.3", 41 | "tiny-route": "2.1.2", 42 | "youtube-audio-stream": "0.0.47" 43 | }, 44 | "devDependencies": { 45 | "standard": "10.0.2" 46 | }, 47 | "license": "MIT" 48 | } 49 | -------------------------------------------------------------------------------- /app.css: -------------------------------------------------------------------------------- 1 | ul { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .track-duration { 7 | color: #17B22A; 8 | } 9 | 10 | .track-title { 11 | color: black; 12 | text-decoration: none; 13 | } 14 | 15 | .track-source { 16 | color: black; 17 | text-decoration: none; 18 | } 19 | 20 | .track-tap { 21 | color: black; 22 | text-decoration: none; 23 | } 24 | 25 | .tracks { 26 | list-style: none; 27 | margin-left:0; 28 | margin-top: 32px; 29 | overflow: scroll; 30 | -webkit-overflow-scrolling: touch; 31 | } 32 | 33 | .track a { 34 | text-decoration: none; 35 | } 36 | 37 | .track { 38 | text-decoration: none; 39 | width: 100%; 40 | font-size: 18px; 41 | display: inline-block; 42 | padding: 4px 10px 4px; 43 | color: #333333; 44 | text-align: center; 45 | vertical-align: middle; 46 | border-bottom: 1px solid #ccc; 47 | } 48 | 49 | .search-query, .register-name { 50 | position: fixed; 51 | top: 10px; 52 | left: 8px; 53 | width: 99%; 54 | width: calc(100% - 16px); 55 | -webkit-box-sizing: border-box; 56 | -moz-box-sizing: border-box; 57 | box-sizing: border-box; 58 | z-index: 100; 59 | outline: none; 60 | padding: 10px; 61 | } 62 | 63 | audio { 64 | margin-left: 50px; 65 | width: 100px; 66 | margin-bottom: 20px; 67 | } 68 | 69 | audio::-webkit-media-controls-play-button { 70 | margin-left: 4px; 71 | width: 30px; 72 | } 73 | 74 | audio::-webkit-media-controls-panel { 75 | width: 40px; 76 | border-radius: 18px; 77 | } 78 | -------------------------------------------------------------------------------- /search/youtube.js: -------------------------------------------------------------------------------- 1 | var search = require('./search-service') 2 | var through = require('through2') 3 | var request = require('hyperquest') 4 | var JSONStream = require('JSONStream') 5 | 6 | module.exports = function (term, cb) { 7 | var options = { 8 | callback: decorate(cb), 9 | parsingKeys: ['items', true], 10 | uri: 'https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&key=' + process.env.YOUTUBE_API_KEY + '&maxResults=' + search.maxResults + '&q=' + term, 11 | parseTrack: function (item) { 12 | return { 13 | id: item.id, 14 | uri: 'https://www.youtube.com/watch?v=' + item.id.videoId, 15 | title: item.snippet.title, 16 | source: 'youtube', 17 | durationMs: 0, 18 | duration: 0 19 | } 20 | } 21 | } 22 | search(options) 23 | } 24 | 25 | function decorate (cb) { 26 | return function (tracks) { 27 | var pending = tracks.length 28 | tracks.forEach(addDetail) 29 | function addDetail (item, i) { 30 | var url = 'https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=' + item.id.videoId + 31 | '&key=' + process.env.YOUTUBE_API_KEY 32 | return request(url).pipe(JSONStream.parse('items.*')).pipe( 33 | through.obj(function (data, enc, cb2) { 34 | pending-- 35 | tracks[i].duration = (data.contentDetails.duration || '').match(/\d+/g).join(':') 36 | if (!pending) cb(tracks) 37 | cb2() 38 | })) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /readme.markdown: -------------------------------------------------------------------------------- 1 | # soundify 2 | 3 | [![js-standard-style](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/feross/standard) 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/JamesKyburz/soundify.svg)](https://greenkeeper.io/) 5 | 6 | Search and play different music sources. 7 | 8 | So far youtube, soundcloud and spotify are supported. 9 | 10 | # usage 11 | 12 | ```javascript 13 | var soundify = require('soundify') 14 | soundify(configPath) // default .env 15 | ``` 16 | 17 | # config 18 | Here is where all your secret keys for the supported services are. 19 | 20 | see [config example] 21 | 22 | # stream 23 | 24 | Checkout [stream] for the supported implementations 25 | 26 | # search 27 | 28 | Checkout [search] for the supported implementations 29 | 30 | # Additional support or help 31 | 32 | Pull requests welcome! 33 | 34 | # docker 35 | 36 | If you don't want to compile ffmpeg et al. you can use docker. 37 | 38 | You need to setup your .env in the volume /opt/soundify 39 | ```sh 40 | REMOTE_SPEAKER=http://yourip:9000 npm run docker:run 41 | ``` 42 | 43 | Then you need to run [remote-speaker] 44 | 45 | # install 46 | 47 | ``` 48 | npm install soundify 49 | ``` 50 | 51 | # license 52 | 53 | MIT 54 | 55 | [config example]: https://github.com/JamesKyburz/soundify/blob/master/.env.example 56 | 57 | [stream]: https://github.com/JamesKyburz/soundify/tree/master/stream 58 | 59 | [search]: https://github.com/JamesKyburz/soundify/tree/master/search 60 | 61 | [remote-speaker]: https://github.com/jameskyburz/remote-speaker 62 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var hyperglue = require('hyperglue') 3 | var cookieCutter = require('cookie-cutter') 4 | var path = require('path') 5 | var indexHtml = fs.readFileSync(path.join(__dirname, '/index.html')) 6 | var tracksHtml = fs.readFileSync(path.join(__dirname, '/tracks.html')) 7 | var searchTrack = require('./search/search') 8 | var childProcess = require('child_process') 9 | var hyperquest = require('hyperquest') 10 | var debug = require('debug')('routes.js') 11 | var querystring = require('querystring') 12 | var player = null 13 | 14 | module.exports = { 15 | emptyFavicon: emptyFavicon, 16 | main: main, 17 | appCss: appCss, 18 | search: search, 19 | authenticate: authenticate, 20 | stream: stream, 21 | play: play 22 | } 23 | 24 | function emptyFavicon (q, r, next) { 25 | r.writeHead(200, {'Content-Type': 'image/x-icon'}) 26 | r.end() 27 | } 28 | 29 | function main (q, r, next) { 30 | var cookie = cookieCutter(q.headers.cookie) 31 | r.writeHead(200, {'Content-Type': 'text/html'}) 32 | var searchTerm = cookie.get('search-track') 33 | if (searchTerm) debug('search %s', searchTerm) 34 | if (searchTerm) return addTracksToResponse(searchTerm, r) 35 | fs.createReadStream(path.join(__dirname, '/index.html')).pipe(r) 36 | } 37 | 38 | function addTracksToResponse (searchTerm, r) { 39 | searchTerm = encodeURIComponent(searchTerm) 40 | searchTrack(searchTerm, function (tracks) { 41 | var trackResponse = hyperglue(tracksHtml, { 42 | '.track': tracks.map(function (x) { 43 | var playUrl = '/play/' + new Buffer(JSON.stringify(x)).toString('base64') 44 | var track = { 45 | '.track-title': x.title, 46 | '.track-duration': x.duration, 47 | '.track-source': x.source 48 | } 49 | track['a'] = {href: playUrl} 50 | track['.track-player'] = { 51 | _html: '

' 52 | } 53 | return track 54 | }) 55 | }).innerHTML 56 | r.end( 57 | hyperglue(indexHtml, { 58 | '.search-query': { 59 | value: decodeURIComponent(searchTerm) 60 | }, 61 | '#content': {_html: trackResponse} 62 | }).innerHTML 63 | ) 64 | }) 65 | } 66 | 67 | function appCss (q, r, next) { 68 | r.writeHead(200, {'Content-Type': 'text/css'}) 69 | return fs.createReadStream(path.join(__dirname, '/app.css')).pipe(r) 70 | } 71 | 72 | function search (q, r, next) { 73 | var formData = '' 74 | q 75 | .on('data', data) 76 | .on('end', end) 77 | 78 | function data (x) { 79 | formData += x.toString() 80 | } 81 | 82 | function end () { 83 | var track = querystring.parse(formData).track 84 | r.writeHead(302, {'Location': '/', 'Set-Cookie': 'search-track=' + encodeURIComponent(track) + '; path=/; HttpOnly'}) 85 | r.end() 86 | } 87 | } 88 | 89 | function authenticate (q, r, next) { 90 | if (!process.env.SERVICE_CREDENTIALS) return next() 91 | var credentials = q.headers.authorization 92 | if (credentials) { 93 | credentials = new Buffer(credentials.slice(6), 'base64').toString() 94 | if (credentials === process.env.SERVICE_CREDENTIALS) return next() 95 | } 96 | r.writeHead(401, {'WWW-Authenticate': 'Basic'}) 97 | r.end('authentication required') 98 | } 99 | 100 | function play (q, r, next) { 101 | var remotePlayer = process.env.REMOTE_PLAYER 102 | if (remotePlayer) { 103 | hyperquest(remotePlayer + '/' + q.params[0]) 104 | } else { 105 | if (player) player.kill() 106 | player = childProcess.fork(path.join(__dirname, '/player')) 107 | player.send(q.params[0]) 108 | } 109 | r.writeHead(302, {'Location': '/'}) 110 | r.end() 111 | } 112 | 113 | function stream (q, r, next) { 114 | var track = JSON.parse(new Buffer(q.params[0], 'base64').toString()) 115 | debug('playing %s', track.uri) 116 | require('./stream/' + track.source)(track.uri).pipe(r) 117 | } 118 | --------------------------------------------------------------------------------