diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b411ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +cache/* +settings/* +node_modules/* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbc48ea --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +

+ CrispyFi +

+
+*CrispyFi* is a local pseudo-bot for [Slack](http://slack.com) that controls what the people in your office have to listen to. It's comprised of a small Node.js-based server that interfaces with Spotify and is controlled by chat commands in your Slack chatroom of choice. It's designed to run on a Raspberry Pi connected to your sound system, but should work on any system that supports [node-spotify](http://node-spotify.com) and volume control using `amixer` (not required, though). Sound is streamed to your device's default audio output and thus can work with the integrated audio jack, HDMI output or additional sound boards like the HiFiBerry. + +## Local pseudo-bot? +In contrast to many existing Slack integrations, you're supposed to run CrispyFi locally in your office where it can access your sound hardware. Also, it's not really an IRC-style bot since it doesn't connect to your chat rooms itself, but rather listens to simple HTTP requests and replies accordingly. If you issue these commands from Slack, you'll even get comments back. + +## Requirements +There are three main prerequisites for CrispyFi to do his job: + +* Node.js installed on your Raspberry. [This link](http://joshondesign.com/2013/10/23/noderpi) should get you there in a few minutes. +* A Spotify account. CrispyFi doesn't care for your MP3s. +* A Spotify API-Key. You need to apply for one, which you can do [here](https://devaccount.spotify.com/my-account/keys/). + +## Installation +1. Once you have everything you need, clone this repo into a directory of your choice. +2. Copy your `spotify_appkey.key` into the installation directory. +3. Edit `config.json` according to your needs. Most of it is self-explanatory, but we'll go into details later on. +4. `npm install` +5. `npm start` or `node index`, whatever works for you. + +That's it, your personal CrispyFi instance is now ready to serve requests. There's some additional steps you might want to take, though, (e.g. integrating it with Slack), so read on! + +## Configuration +All the important bits are controlled by `config.json` in the project's root path. Below is a code example with comments which *you need to remove* when you copy/pase it, since comments are not allowed in JSON files. + +``` +{ + "spotify": { + "username": "Spotify Username", + "password": "Spotify Password", + + // Playlists need to be pre-configured for use here. + // Create playlists using the Spotify app, then right-click + // them and copy their URIs. The names in here don't have to + // match the actual Playlist's name but are used as arguments, + // so you might want to keep them short and simple. + // A playlist named "default" is required! + "playlists": { + "default": "spotify:user:crispymtn:playlist:1xUTFaq10nzODhwkwLMo2l", + "metal": "spotify:user:crispymtn:playlist:6IkIC1Rq6bMkhsO0sj9GGs", + "hiphop": "spotify:user:crispymtn:playlist:71SBbMfSyMHRAIxl8fKEbI" + } + }, + "auth": { + // Slack generates one token per integration, so you can just put them + // all in here. We don't check the token per integraion, but rather + // just whether it's included in this list. + "tokens": [ + "slack-token-one", + "slack-token-two" + ] + }, +} +``` + +## How does it work? +By default, CrispyFi listens on port 8000 and provides a single HTTP endpoint. To issue orders to it, you have to POST to the endpoint and provide an authentication token as well as a command. The format of this is a bit non-standard (i.e. no token in the header) since we built it to be mainly used in combination with Slack's outgoing webhooks. You should probably create an outgoing webhook first and familiarize yourself with the semantics, but the short version is you need a POST body with the following fields: + +``` +token= +text= +``` + +Currently the following trigger words are available: + +* `play [Spotify URI]` - Starts/resumes playback if no URI is provided. If a URI is given, immedtiately switches to the linked track. +* `pause` - Pauses playback at the current time. +* `stop` - Stops playback and resets to the beginning of the current track. +* `skip` - Skips (or shuffles) to the next track in the playlist. +* `shuffle` - Toggles shuffle on or off. +* `vol [up|down|0..10]` Turns the volume either up/down one notch or directly to a step between 0 (mute) and 10 (full blast). Also goes to eleven. +* `list [list name]` - When provided with an argument, switches to this playlist. Otherwise, shows all available playlists. +* `status` - Shows the currently playing song, playlist and whether you're shuffling or not. +* `help` - Shows a list of commands with a short explaantion. + +If you're using Slack integrations, simply create an outgoing webhook to `http://your-crispyfi-url/handle` that listens to the appropriate trigger words. See below for an example screenshot of our setup. To disable certain funtions, just remove the trigger word. + +![Slack integration](http://i.imgur.com/Tye5R2W.png) + +## Getting your groove on(line) +Since your Pi will probably be behind a firewall/some sort of NAT or have a dynamic IP, you'll have difficulties tying Slack's webhooks to its IP address. We're currently using [ngrok](http://ngrok.com) to get around that, mainly because it's awesome and makes developing web services much easier. Also, using ngrok you avoid the hassle of updating some other service's configuration whenever your IP changes, instead you have to run a small binary all the time. YMMV, so use whatever you're comfortable with (but give ngrok a try). + +## Used Software +CrispyFi builds upon [ApiServer](https://github.com/kilianc/node-apiserver) by killianc and includes a pre-compiled version of FrontierPsychiatrist's [node-spotify](https://github.com/FrontierPsychiatrist/node-spotify) since our Raspberry Pi stoically refused to compile the extension itself. The according license is redistributed as per the terms of the MIT License and can be found in the file `licenses/node-spotify` in the project's root directory. + +We extend our everlasting gratitude to both of you! + +## License +This software is released under the MIT license, which can be found under `licenses/crispyfi`. diff --git a/config.json b/config.json new file mode 100644 index 0000000..39e2df4 --- /dev/null +++ b/config.json @@ -0,0 +1,17 @@ +{ + "spotify": { + "username": "Spotify Username", + "password": "Spotify Password", + "playlists": { + "default": "spotify:user:crispymtn:playlist:1xUTFaq10nzODhwkwLMo2l", + "metal": "spotify:user:crispymtn:playlist:6IkIC1Rq6bMkhsO0sj9GGs", + "hiphop": "spotify:user:crispymtn:playlist:71SBbMfSyMHRAIxl8fKEbI" + } + }, + "auth": { + "tokens": [ + "slack-token-one", + "slack-token-two" + ] + } +} diff --git a/examples/init-d.sh b/examples/init-d.sh new file mode 100755 index 0000000..48c3eb2 --- /dev/null +++ b/examples/init-d.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# +# An init.d script for running a Node.js process as a service using Forever as +# the process monitor. For more configuration options associated with Forever, +# see: https://github.com/nodejitsu/forever +# +# This was written for Debian distributions such as Ubuntu, but should still +# work on RedHat, Fedora, or other RPM-based distributions, since none of the +# built-in service functions are used. So information is provided for both. +# +### BEGIN INIT INFO +# Provides: crispyfi +# Required-Start: $network +# Required-Stop: +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: CrispyFi - The crispymtn music player for your office. +# Description: CrispyFi - The crispymtn music player for your office. +### END INIT INFO +# +# Based on: +# https://gist.github.com/3748766 +# https://github.com/hectorcorrea/hectorcorrea.com/blob/master/etc/forever-initd-hectorcorrea.sh +# https://www.exratione.com/2011/07/running-a-nodejs-server-as-a-service-using-forever/ +# +# The example environment variables below assume that Node.js is installed by +# building from source with the standard settings. +# +# It should be easy enough to adapt to the paths to be appropriate to a package +# installation, but note that the packages available in the default repositories +# are far behind the times. Most users will be building from source to get a +# suitably recent Node.js version. +# +# An application name to display in echo text. +# NAME="My Application" +# The full path to the directory containing the node and forever binaries. +# NODE_BIN_DIR="/usr/local/node/bin" +# Set the NODE_PATH to the Node.js main node_modules directory. +# NODE_PATH="/usr/local/lib/node_modules" +# The application startup Javascript file path. +# APPLICATION_PATH="/home/user/my-application/start-my-application.js" +# Process ID file path. +# PIDFILE="/var/run/my-application.pid" +# Log file path. +# LOGFILE="/var/log/my-application.log" +# Forever settings to prevent the application spinning if it fails on launch. +# MIN_UPTIME="5000" +# SPIN_SLEEP_TIME="2000" + +NAME="crispyfi" +NODE_BIN_DIR="/usr/bin/node/bin" +NODE_PATH="/usr/bin/node/lib/node_modules" +APPLICATION_PATH="/opt/crispyfi/index.js" +PIDFILE="/var/run/crispyfi.pid" +LOGFILE="/var/log/crispyfi.log" +MIN_UPTIME="5000" +SPIN_SLEEP_TIME="2000" + +# Add node to the path for situations in which the environment is passed. +PATH=$NODE_BIN_DIR:$PATH +# Export all environment variables that must be visible for the Node.js +# application process forked by Forever. It will not see any of the other +# variables defined in this script. +export NODE_PATH=$NODE_PATH + +start() { + echo "Starting $NAME" + # We're calling forever directly without using start-stop-daemon for the + # sake of simplicity when it comes to environment, and because this way + # the script will work whether it is executed directly or via the service + # utility. + # + # The minUptime and spinSleepTime settings stop Forever from thrashing if + # the application fails immediately on launch. This is generally necessary to + # avoid loading development servers to the point of failure every time + # someone makes an error in application initialization code, or bringing down + # production servers the same way if a database or other critical service + # suddenly becomes inaccessible. + # + # The pidfile contains the child process pid, not the forever process pid. + # We're only using it as a marker for whether or not the process is + # running. + # + # Note that redirecting the output to /dev/null (or anywhere) is necessary + # to make this script work if provisioning the service via Chef. + forever \ + --pidFile $PIDFILE \ + -a \ + -l $LOGFILE \ + --minUptime $MIN_UPTIME \ + --spinSleepTime $SPIN_SLEEP_TIME \ + start $APPLICATION_PATH 2>&1 > /dev/null & + RETVAL=$? +} + +stop() { + if [ -f $PIDFILE ]; then + echo "Shutting down $NAME" + # Tell Forever to stop the process. + forever stop $APPLICATION_PATH 2>&1 > /dev/null + # Get rid of the pidfile, since Forever won't do that. + rm -f $PIDFILE + RETVAL=$? + else + echo "$NAME is not running." + RETVAL=0 + fi +} + +restart() { + stop + start +} + +status() { + # On Ubuntu this isn't even necessary. To find out whether the service is + # running, use "service my-application status" which bypasses this script + # entirely provided you used the service utility to start the process. + # + # The commented line below is the obvious way of checking whether or not a + # process is currently running via Forever, but in recent Forever versions + # when the service is started during Chef provisioning a dead pipe is left + # behind somewhere and that causes an EPIPE exception to be thrown. + # forever list | grep -q "$APPLICATION_PATH" + # + # So instead we add an extra layer of indirection with this to bypass that + # issue. + echo `forever list` | grep -q "$APPLICATION_PATH" + if [ "$?" -eq "0" ]; then + echo "$NAME is running." + RETVAL=0 + else + echo "$NAME is not running." + RETVAL=3 + fi +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + status) + status + ;; + restart) + restart + ;; + *) + echo "Usage: {start|stop|status|restart}" + exit 1 + ;; +esac +exit $RETVAL diff --git a/index.js b/index.js new file mode 100644 index 0000000..582d706 --- /dev/null +++ b/index.js @@ -0,0 +1,24 @@ +// Enable CoffeeScript +require('coffee-script/register'); +// Application config +var Config = require('./config.json'); + +// The Server +var ApiServer = require('apiserver'); +var server = new ApiServer({ port: 8000 }); +// Control modules used by the ApiServer +var SlackInterface = require('./lib/slack_interface/index')(); + +// Parse POST Payloads +server.use(ApiServer.payloadParser()); +// Add control modules +server.addModule('1', 'slack_interface', SlackInterface); +// Routing +server.router.addRoutes([ + ["/handle", "1/slack_interface#handle"] +]); + + +// Let's go +server.listen(); +console.info('Server running. Yay!'); diff --git a/lib/auth_handler.coffee b/lib/auth_handler.coffee new file mode 100644 index 0000000..ae883ae --- /dev/null +++ b/lib/auth_handler.coffee @@ -0,0 +1,23 @@ +class AuthHandler + constructor: (auth_data) -> + @auth_data = auth_data + + validate: (request, response) -> + @command = null + @argument = null + return false unless (request.body?.text? && request.body?.token?) + + if request.body.token in @auth_data.tokens + parts = request.body.text.split ' ' + if parts.length > 0 + @command = parts[0] + @argument = parts[1] if parts.length > 1 + return true + + response.serveJSON null, { + httpStatusCode: 401, + } + return false + +module.exports = (auth_data) -> + return new AuthHandler(auth_data) diff --git a/lib/slack_interface/handler.coffee b/lib/slack_interface/handler.coffee new file mode 100644 index 0000000..85f0a67 --- /dev/null +++ b/lib/slack_interface/handler.coffee @@ -0,0 +1,61 @@ +class SlackInterfaceHandler + constructor: (auth, spotify, volume) -> + @auth = auth + @spotify = spotify + @volume = volume + + @endpoints = + handle: + post: (request, response) => + request.resume() + request.once "end", => + return if !@auth.validate(request, response) + + # console.info "Received command: #{@auth.command} with argument: #{@auth.argument}" + + reply_data = { ok: true } + + switch @auth.command + when 'play' then @spotify.play @auth.argument + when 'pause' then @spotify.pause() + when 'stop' then @spotify.stop() + when 'skip' then @spotify.skip() + when 'reconnect' then @spotify.reconnect() + + when 'shuffle' + @spotify.toggle_shuffle() + reply_data['text'] = if @spotify.shuffle then "ERRYDAY I'M SHUFFLING." else "I am no longer shuffling. Thanks for ruining my fun." + + when 'vol' + switch @auth.argument + when "up" then @volume.up() + when "down" then @volume.down() + else @volume.set @auth.argument + + when 'list' + if @auth.argument? + @spotify.set_playlist @auth.argument + else + str = 'Currently available playlists:' + for key of @spotify.config.playlists + str += "\n*#{key}*" + reply_data['text'] = str + + when 'status' + shuffleword = if @spotify.shuffle then '' else ' not' + reply_data['text'] = "You are currently letting your ears feast on the beautiful tunes titled *#{@spotify.current_track_name}* from *#{@spotify.current_track_artists}*.\nYour currently selected playlist, which you are#{shuffleword} shuffling through, is named *#{@spotify.current_playlist.name}*." + + when 'help' + reply_data['text'] = "You seem lost. Maybe trying one of these commands will help you out:\n*play* [Spotify-URI] - Starts or resumes playback. If you provide a Spotify-URI it will be played immediately.\n*stop* - Stops playback.\n*pause* - Pauses playback (can be resumed using *play*).\n*skip*: Skips to the next track.\n*list* [listname] - Switches to the specified Spotify-Playlist. If no list name is provided, all available lists will be shown. Playlists need to be configured beforehand, please check the project's readme for details.\n*vol* [up|down|0-10] - Sets the output volume. Either goes up or down one notch or directly to a level ranging from 0 to 10 (inclusive). 0 is mute." + + + response.serveJSON reply_data + return + return + + + +module.exports = (auth, spotify, volume) -> + handler = new SlackInterfaceHandler(auth, spotify, volume) + return handler.endpoints + diff --git a/lib/slack_interface/index.coffee b/lib/slack_interface/index.coffee new file mode 100644 index 0000000..fc9c1da --- /dev/null +++ b/lib/slack_interface/index.coffee @@ -0,0 +1,26 @@ +module.exports = () -> + Config = require '../../config.json' + os = require 'os' + path = require 'path' + + # Path to Spotify's AppKey + root_dir = path.dirname require.main.filename + appkey_path = path.resolve root_dir, 'spotify_appkey.key' + + # libspotify-bindings for node + if os.arch() == 'arm' + Spotify = require "../spotify/pi/spotify" + else + Spotify = require "../spotify/mac/spotify" + + # Request authentication. + # I'd love to use a "proper" ApiServer-middleware, but we need the request's body to do this, which + # requires us to resume() the request and wait for a callback, which is impractical in a middleware. + AuthHandler = require('../auth_handler')(Config.auth); + VolumeHandler = require('../volume_handler')(); + SpotifyHandler = require('../spotify_handler')({ + spotify: Spotify({ appkeyFile: appkey_path }) + config: Config.spotify + }) + + return require('./handler')(AuthHandler, SpotifyHandler, VolumeHandler) diff --git a/lib/spotify/mac/LICENSE.txt b/lib/spotify/mac/LICENSE.txt new file mode 100644 index 0000000..4b4547f --- /dev/null +++ b/lib/spotify/mac/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) <2014> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/spotify/mac/metadataUpdater.js b/lib/spotify/mac/metadataUpdater.js new file mode 100644 index 0000000..615bad3 --- /dev/null +++ b/lib/spotify/mac/metadataUpdater.js @@ -0,0 +1,50 @@ +/** + * Saves collections of objects that should be checked for their isLoaded flag in the metadata_updated + * callback along with a callback to use on them. + * { + * objects: [], + * callback: function(object)... + * } + **/ +var notLoadedObjects = []; + +/** + * This method will be attached to the metadata_updated callback. It will + * iterate over all objects in the notLoadedObjects array and check of they have been loaded with this call. + * If so the provided callback is called. All objects that are not loaded will be saved to notLoadedObjects again. + **/ +function metadataUpdated() { + var length = notLoadedObjects.length; + for(var i = 0; i < length; i++) { + var toUpdate = notLoadedObjects.shift(); + var newQueueItem = { objects: [], callback: toUpdate.callback }; + toUpdate.objects.forEach(function(object) { + if(object.isLoaded) { + toUpdate.callback(object); + } else { + newQueueItem.objects.push(object); + } + }); + if(newQueueItem.objects.length > 0) { + notLoadedObjects.push(newQueueItem); + } + } +} + +/** + * Creates a new entry in notLoadedObjects containing all objects in the parameter objects along with the callback. + **/ +function waitForLoaded(objects, callback) { + var notLoaded = { + objects: objects, + callback: callback + }; + if(notLoaded.objects.length > 0) { + notLoadedObjects.push(notLoaded); + } +} + +module.exports = { + waitForLoaded: waitForLoaded, + metadataUpdated: metadataUpdated +}; \ No newline at end of file diff --git a/lib/spotify/mac/nodespotify.node b/lib/spotify/mac/nodespotify.node new file mode 100755 index 0000000..7df7e7a Binary files /dev/null and b/lib/spotify/mac/nodespotify.node differ diff --git a/lib/spotify/mac/spotify.js b/lib/spotify/mac/spotify.js new file mode 100644 index 0000000..fe7139c --- /dev/null +++ b/lib/spotify/mac/spotify.js @@ -0,0 +1,43 @@ +var _spotify = require('./nodespotify'); +var metadataUpdater = require('./metadataUpdater'); + +function addMethodsToPrototypes(sp) { + sp.internal.protos.Playlist.prototype.getTracks = function() { + var out = new Array(this.numTracks); + for(var i = 0; i < this.numTracks; i++) { + out[i] = this.getTrack(i); + } + return out; + } + sp.internal.protos.PlaylistContainer.prototype.getPlaylists = function () { + var out = new Array(this.numPlaylists); + for(var i = 0; i < this.numPlaylists; i++) { + out[i] = this.getPlaylist(i); + } + return out; + } +} + +var beefedupSpotify = function(options) { + var spotify = _spotify(options); + addMethodsToPrototypes(spotify); + spotify.version = '0.6.0'; + + spotify.on = function(callbacks) { + if(callbacks.metadataUpdated) { + var userCallback = callbacks.metadataUpdated; + callbacks.metadataUpdated = function() { + userCallback(); + metadataUpdater.metadataUpdated(); + } + } else { + callbacks.metadataUpdated = metadataUpdater.metadataUpdated; + } + spotify._on(callbacks); + } + + spotify.waitForLoaded = metadataUpdater.waitForLoaded; + return spotify; +} + +module.exports = beefedupSpotify; diff --git a/lib/spotify/pi/LICENSE.txt b/lib/spotify/pi/LICENSE.txt new file mode 100644 index 0000000..4b4547f --- /dev/null +++ b/lib/spotify/pi/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) <2014> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/spotify/pi/metadataUpdater.js b/lib/spotify/pi/metadataUpdater.js new file mode 100644 index 0000000..615bad3 --- /dev/null +++ b/lib/spotify/pi/metadataUpdater.js @@ -0,0 +1,50 @@ +/** + * Saves collections of objects that should be checked for their isLoaded flag in the metadata_updated + * callback along with a callback to use on them. + * { + * objects: [], + * callback: function(object)... + * } + **/ +var notLoadedObjects = []; + +/** + * This method will be attached to the metadata_updated callback. It will + * iterate over all objects in the notLoadedObjects array and check of they have been loaded with this call. + * If so the provided callback is called. All objects that are not loaded will be saved to notLoadedObjects again. + **/ +function metadataUpdated() { + var length = notLoadedObjects.length; + for(var i = 0; i < length; i++) { + var toUpdate = notLoadedObjects.shift(); + var newQueueItem = { objects: [], callback: toUpdate.callback }; + toUpdate.objects.forEach(function(object) { + if(object.isLoaded) { + toUpdate.callback(object); + } else { + newQueueItem.objects.push(object); + } + }); + if(newQueueItem.objects.length > 0) { + notLoadedObjects.push(newQueueItem); + } + } +} + +/** + * Creates a new entry in notLoadedObjects containing all objects in the parameter objects along with the callback. + **/ +function waitForLoaded(objects, callback) { + var notLoaded = { + objects: objects, + callback: callback + }; + if(notLoaded.objects.length > 0) { + notLoadedObjects.push(notLoaded); + } +} + +module.exports = { + waitForLoaded: waitForLoaded, + metadataUpdated: metadataUpdated +}; \ No newline at end of file diff --git a/lib/spotify/pi/nodespotify.node b/lib/spotify/pi/nodespotify.node new file mode 100755 index 0000000..01166f2 Binary files /dev/null and b/lib/spotify/pi/nodespotify.node differ diff --git a/lib/spotify/pi/spotify.js b/lib/spotify/pi/spotify.js new file mode 100644 index 0000000..fe7139c --- /dev/null +++ b/lib/spotify/pi/spotify.js @@ -0,0 +1,43 @@ +var _spotify = require('./nodespotify'); +var metadataUpdater = require('./metadataUpdater'); + +function addMethodsToPrototypes(sp) { + sp.internal.protos.Playlist.prototype.getTracks = function() { + var out = new Array(this.numTracks); + for(var i = 0; i < this.numTracks; i++) { + out[i] = this.getTrack(i); + } + return out; + } + sp.internal.protos.PlaylistContainer.prototype.getPlaylists = function () { + var out = new Array(this.numPlaylists); + for(var i = 0; i < this.numPlaylists; i++) { + out[i] = this.getPlaylist(i); + } + return out; + } +} + +var beefedupSpotify = function(options) { + var spotify = _spotify(options); + addMethodsToPrototypes(spotify); + spotify.version = '0.6.0'; + + spotify.on = function(callbacks) { + if(callbacks.metadataUpdated) { + var userCallback = callbacks.metadataUpdated; + callbacks.metadataUpdated = function() { + userCallback(); + metadataUpdater.metadataUpdated(); + } + } else { + callbacks.metadataUpdated = metadataUpdater.metadataUpdated; + } + spotify._on(callbacks); + } + + spotify.waitForLoaded = metadataUpdater.waitForLoaded; + return spotify; +} + +module.exports = beefedupSpotify; diff --git a/lib/spotify_handler.coffee b/lib/spotify_handler.coffee new file mode 100644 index 0000000..cf36ccb --- /dev/null +++ b/lib/spotify_handler.coffee @@ -0,0 +1,185 @@ +class SpotifyHandler + constructor: (options) -> + @spotify = options.spotify + @config = options.config + + @connect_timeout = null + @connected = false + + # "playing" in this context means actually playing music or being currently paused (but NOT stopped). + # This is an important distinction regarding the functionality of @spotify.player.resume(). + @playing = false + # Whether we're SHUFFLING ERRYDAY or not. + @shuffle = false + # Current state + @current_track = null + @current_track_index = 0 + @current_track_name = null + @current_track_artists = null + @current_playlist = { + name: null, + playlist: null + } + + @spotify.on + ready: @spotify_connected.bind(@) + logout: @spotify_disconnected.bind(@) + @spotify.player.on + endOfTrack: @skip.bind(this) + + # And off we got + @connect() + + + # Connects to Spotify. + connect: -> + @spotify.login @config.username, @config.password, false, false + + + # Called after we have successfully connected to Spotify. + # Clears the connect-timeout and grabs the default Playlist (or resumes playback if another playlist was set). + spotify_connected: -> + @connected = true + clearTimeout @connect_timeout + @connect_timeout = null + if @current_playlist.name? + @play() + else + @set_playlist 'default' + return + + + # Called after the handler has lost its connection to Spotify. + # Attempts to re-connect every 2.5s. + spotify_disconnected: -> + @connected = false + @connect_timeout = setTimeout (() => @connect), 2500 + return + + + # Called after the current playlist has been updated. + # Simply replaces the current playlist-instance with the new one and re-bind events. + # Player-internal state (number of tracks in the playlist, current index, etc.) is updated on @get_next_track(). + update_playlist: (err, playlist, tracks, position) -> + @current_playlist.playlist = playlist + @current_playlist.playlist.on + tracksAdded: @update_playlist.bind(this) + tracksRemoved: @update_playlist.bind(this) + return + + + # Pauses playback at the current time. Can be resumed by calling @play(). + pause: -> + @spotify.player.pause() + return + + + # Stops playback. This does not just pause, but returns to the start of the current track. + # This state can not be changed by simply calling @spotify.player.resume(), because reasons. + # Call @play() to start playing again. + stop: -> + @playing = false + @spotify.player.stop() + return + + + # Plays the next track in the playlist + skip: -> + @play @get_next_track() + return + + + # Toggles shuffle on and off. MAGIC! + toggle_shuffle: -> + @shuffle = !@shuffle + + + # Either starts playing the current track (or next one, if none is set) or immediately + # plays the provided track or link. + play: (track_or_link=null) -> + # If a track is given, immediately switch to it + if track_or_link? + switch typeof track_or_link + # We got a link from Slack + when 'string' + # Links from Slack are encased like this: + # So we remove everything that is neither char, number or a colon. + track_or_link = track_or_link.replace /[^0-9a-zA-Z:]/g, '' + new_track = @spotify.createFromLink track_or_link + # If the track was somehow invalid, don't do anything + return if !new_track? + # We also use this to internally trigger playback of already-loaded tracks + when 'object' + new_track = track_or_link + # Other input is simply disregarded + else + return + # If we are already playing, simply resume + else if @playing + return @spotify.player.resume() + # Last resort: We are currently neither playing not have stopped a track. So we grab the next one. + else if !new_track + new_track = @get_next_track() + + # We need to check whether the track has already completely loaded. + if new_track? && new_track.isLoaded + @_play_callback new_track + else if new_track? + @spotify.waitForLoaded [new_track], (track) => + @_play_callback new_track + return + + + # Handles the actual playback once the track object has been loaded from Spotify + _play_callback: (track) -> + @current_track = track + @current_track_name = @current_track.name + @current_track_artists = @current_track.artists.map((artist) -> + artist.name + ).join ", " + + @spotify.player.play @current_track + @playing = true + return + + + # Gets the next track from the playlist. + get_next_track: -> + if @shuffle + @current_track_index = Math.floor(Math.random() * @current_playlist.playlist.numTracks) + else + @current_track_index = ++@current_track_index % @current_playlist.playlist.numTracks + @current_playlist.playlist.getTrack(@current_track_index) + + + # Changes the current playlist and starts playing. + # Since the playlist might have loaded before we can attach our callback, the actual playlist-functionality + # is extracted to _set_playlist_callback which we call either directly or delayed once it has loaded. + set_playlist: (name = 'default') -> + if @config.playlists[name]? + playlist = @spotify.createFromLink @config.playlists[name] + if playlist && playlist.isLoaded + @_set_playlist_callback name, playlist + else if playlist + @spotify.waitForLoaded [playlist], (playlist) => + @_set_playlist_callback name, playlist + return + return + + + # The actual handling of the new playlist once it has been loaded. + _set_playlist_callback: (name, playlist) -> + @current_playlist.name = name + @current_playlist.playlist = playlist + @current_playlist.playlist.on + tracksAdded: @update_playlist.bind(this) + tracksRemoved: @update_playlist.bind(this) + @current_track_index = 0 + @play @current_playlist.playlist.getTrack @current_track_index + return + + + +# export things +module.exports = (options) -> + return new SpotifyHandler(options) diff --git a/lib/volume_handler.coffee b/lib/volume_handler.coffee new file mode 100644 index 0000000..57f3219 --- /dev/null +++ b/lib/volume_handler.coffee @@ -0,0 +1,44 @@ +# Sets The Raspberry's output volume using "amixer sset PCM,0 %" +# Effective range is from 80 upwards, so this maps 0 to 0 and a 1-10 range to 80-100 respectively. +# Also allows to set the volume to 11 and 9000 (and everything else above 10, for that matter). +class VolumeHandler + constructor: (initial_step = 5) -> + @exec = require('child_process').exec + @set initial_step + + # Changes the output Volume. + # This requires a command-line call, so we pre-process the argument accordingly. + # Takes numbers from 0 to 10 (inclusive). + set: (step) -> + step = @validate_step step + vol = @step_to_volume step + @exec('amixer sset PCM,0 ' + vol + '%', (error, stdout, stderr) -> ) + @current_step = step + # console.info "Set current volume to #{vol}% / Step #{@current_step}" + + up: () -> + @set @current_step+1 + + down: () -> + @set @current_step-1 + + # Makes sure the step is a number between 0 and 10 + validate_step: (step) -> + # Sanity check. There is probably a much more elegant way to do this, tips are welcome. + step = parseInt step + if isNaN(step) + step = 0 + + return 0 if step <= 0 + return 10 if step >= 10 + return step + + # Maps a given step to an actual volume percentage + step_to_volume: (step) -> + return 100 if step >= 10 + return 0 if step <= 0 + return 80 + (2 * step) + +# export things +module.exports = (initial_volume = 5) -> + return new VolumeHandler(initial_volume) diff --git a/licenses/crispyfi b/licenses/crispyfi new file mode 100644 index 0000000..e01b344 --- /dev/null +++ b/licenses/crispyfi @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014, Dieter Koch (dk@crispymtn.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/licenses/node-spotify b/licenses/node-spotify new file mode 100644 index 0000000..4b4547f --- /dev/null +++ b/licenses/node-spotify @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) <2014> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/package.json b/package.json new file mode 100644 index 0000000..22c9c7f --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "crispyfi", + "version": "1.0.0", + "description": "The crispymtn music player for your local office.", + "homepage": "https://github.com/crispymtn/jukebox", + "main": "index.js", + "author": "Dieter Koch (dk@crispymtn.com)", + "license": "MIT", + "dependencies": { + "apiserver": "^0.3.1", + "coffee-script": "^1.8.0" + } +}