├── .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 |
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 | [](https://github.com/feross/standard)
4 | [](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 |
--------------------------------------------------------------------------------