├── .gitignore ├── Gemfile ├── lib ├── play │ ├── templates │ │ ├── search.mustache │ │ ├── play_history.mustache │ │ ├── album_songs.mustache │ │ ├── artist_songs.mustache │ │ ├── now_playing.mustache │ │ ├── show_song.mustache │ │ ├── layout.mustache │ │ ├── index.mustache │ │ ├── song.mustache │ │ └── profile.mustache │ ├── views │ │ ├── layout.rb │ │ ├── index.rb │ │ ├── search.rb │ │ ├── play_history.rb │ │ ├── artist_songs.rb │ │ ├── profile.rb │ │ ├── album_songs.rb │ │ ├── now_playing.rb │ │ └── show_song.rb │ ├── history.rb │ ├── vote.rb │ ├── core_ext │ │ └── hash.rb │ ├── album.rb │ ├── artist.rb │ ├── office.rb │ ├── user.rb │ ├── client.rb │ ├── library.rb │ ├── song.rb │ ├── app │ │ └── api.rb │ └── app.rb └── play.rb ├── test ├── test_client.rb ├── test_play.rb ├── test_artist.rb ├── test_library.rb ├── test_user.rb ├── test_album.rb ├── test_office.rb ├── helper.rb ├── spec │ └── mini.rb ├── test_app.rb ├── test_song.rb └── test_api.rb ├── config.ru ├── play.yml.example ├── bin └── play ├── db └── migrate │ └── 01_create_schema.rb ├── Gemfile.lock ├── public └── css │ └── base.css ├── play.gemspec ├── Rakefile └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | db/*.sqlite3 2 | pkg/ 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /lib/play/templates/search.mustache: -------------------------------------------------------------------------------- 1 | {{#songs}} 2 | {{>song}} 3 | {{/songs}} 4 | -------------------------------------------------------------------------------- /lib/play/templates/play_history.mustache: -------------------------------------------------------------------------------- 1 | {{#songs}} 2 | {{>song}} 3 | {{/songs}} 4 | -------------------------------------------------------------------------------- /lib/play/templates/album_songs.mustache: -------------------------------------------------------------------------------- 1 |
2 | {{#songs}} 3 | {{>song}} 4 | {{/songs}} 5 |
6 | -------------------------------------------------------------------------------- /lib/play/templates/artist_songs.mustache: -------------------------------------------------------------------------------- 1 |
2 | {{#songs}} 3 | {{>song}} 4 | {{/songs}} 5 |
6 | -------------------------------------------------------------------------------- /lib/play/views/layout.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class Layout < Mustache 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/play/history.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class History < ActiveRecord::Base 3 | belongs_to :song 4 | 5 | validates_presence_of :song 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/play/vote.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class Vote < ActiveRecord::Base 3 | belongs_to :song 4 | belongs_to :user 5 | belongs_to :artist 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/play/core_ext/hash.rb: -------------------------------------------------------------------------------- 1 | # From Sinatra's symbolize_keys port. 2 | class Hash 3 | def symbolize_keys 4 | self.inject({}) { |h,(k,v)| h[k.to_sym] = v; h } 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/play/views/index.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class Index < Layout 4 | def title 5 | "Queue" 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/play/views/search.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class Search < Layout 4 | def title 5 | @search 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/play/templates/now_playing.mustache: -------------------------------------------------------------------------------- 1 |

2 | {{artist_name}}: {{song_title}} 3 |

4 | 5 |

Yeah, this page should be fixed.

6 | -------------------------------------------------------------------------------- /lib/play/views/play_history.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class PlayHistory < Layout 4 | def title 5 | "History" 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/play/views/artist_songs.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class ArtistSongs < Layout 4 | def title 5 | @artist.name 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/play/views/profile.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class Profile < Layout 4 | def title 5 | @user ? @user.name : "Profile" 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/play/views/album_songs.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class AlbumSongs < Layout 4 | def title 5 | "#{@artist.name}: #{@album.name}" 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/test_client.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "Client" do 4 | setup do 5 | end 6 | 7 | test "volume" do 8 | Client.stubs(:system).returns(true) 9 | Client.volume(1) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + '/lib/play') 2 | 3 | require 'play' 4 | require 'omniauth/oauth' 5 | oauth = Play.config 6 | 7 | use Rack::Session::Cookie 8 | use OmniAuth::Strategies::GitHub, oauth['gh_key'], oauth['gh_secret'] 9 | 10 | run Play::App 11 | -------------------------------------------------------------------------------- /lib/play/templates/show_song.mustache: -------------------------------------------------------------------------------- 1 |
2 | {{#users}} 3 | 4 | 5 | 6 | {{/users}} 7 | 8 | Played {{plays}} times. 9 |
10 | -------------------------------------------------------------------------------- /lib/play/views/now_playing.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class NowPlaying < Layout 4 | def title 5 | "now playing" 6 | end 7 | 8 | def artist_name 9 | @song.artist_name 10 | end 11 | 12 | def song_title 13 | @song.title 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/test_play.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "Play" do 4 | setup do 5 | end 6 | 7 | test "path" do 8 | Play.expects(:config).returns({'path' => '/tmp/play'}) 9 | assert_equal '/tmp/play', Play.path 10 | end 11 | 12 | test "now playing" do 13 | @song = Play::Song.create 14 | @song.play! 15 | assert @song, Play.now_playing 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/play/album.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class Album < ActiveRecord::Base 3 | has_many :songs 4 | belongs_to :artist 5 | 6 | # Queue up an entire ALBUM! 7 | # 8 | # user - the User who is requesting the album to be queued 9 | # 10 | # Returns nothing. 11 | def enqueue!(user) 12 | songs.each{ |song| song.enqueue!(user) } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/play/views/show_song.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | module Views 3 | class ShowSong < Layout 4 | def title 5 | "#{@song.title} by #{@song.artist.name}" 6 | end 7 | 8 | def users 9 | @song.votes.group(:user_id).collect do |vote| 10 | vote.user 11 | end 12 | end 13 | 14 | def plays 15 | @song.votes.count 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/test_artist.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "Artist" do 4 | fixtures do 5 | @artist = Play::Artist.create(:name => "Justice") 6 | @song = Play::Song.create(:title => "Stress", :artist => @artist) 7 | @user = User.create 8 | end 9 | 10 | test "enqueueing ten songs" do 11 | Song.any_instance.expects(:enqueue!).with(@user).times(1) 12 | @artist.enqueue!(@user) 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /test/test_library.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "Library" do 4 | fixtures do 5 | end 6 | 7 | test "imports a song" do 8 | Library.import_song('/tmp/path') 9 | assert_equal 1, Play::Song.count 10 | end 11 | 12 | test "fs_songs" do 13 | FileUtils.mkdir_p '/tmp/play' 14 | FileUtils.touch '/tmp/play/song_1' 15 | FileUtils.touch '/tmp/play/song_2' 16 | Play.stubs(:path).returns('/tmp/play') 17 | assert_equal 2, Library.fs_songs.size 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/test_user.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "User" do 4 | fixtures do 5 | @user = Play::User.create(:email => "holman@example.com") 6 | end 7 | 8 | test "vote for" do 9 | @user.vote_for(Play::Song.create) 10 | assert_equal 1, @user.votes.count 11 | end 12 | 13 | test "gravatar_id" do 14 | assert_equal "54e4ab9ced3fd1f3f5b20ab2f8201b73", @user.gravatar_id 15 | end 16 | 17 | test "votes count" do 18 | @user.vote_for(Play::Song.create) 19 | assert_equal 1, @user.votes_count 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/play/artist.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class Artist < ActiveRecord::Base 3 | has_many :songs 4 | has_many :albums 5 | has_many :votes 6 | 7 | # Queue up an artist. This will grab ten random tracks for this artist and 8 | # queue 'em up. 9 | # 10 | # user - the User who is requesting the artist be queued 11 | # 12 | # Returns nothing. 13 | def enqueue!(user) 14 | songs.shuffle[0..9].collect do |song| 15 | song.enqueue!(user) 16 | song 17 | end 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/play/templates/layout.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Play: {{title}} 5 | 6 | 7 | 8 | 9 |

{{title}}

10 | 11 | 20 | 21 | {{{yield}}} 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/test_album.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "Album" do 4 | fixtures do 5 | @artist = Play::Artist.create(:name => "Justice") 6 | @album = Album.create(:name => 'Cross', :artist => @artist) 7 | @song = Play::Song.create(:title => "Stress", 8 | :artist => @artist, 9 | :album => @album) 10 | @user = User.create 11 | end 12 | 13 | test "enqueueing songs" do 14 | Song.any_instance.expects(:enqueue!).with(@user).times(1) 15 | @album.enqueue!(@user) 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /lib/play/templates/index.mustache: -------------------------------------------------------------------------------- 1 | {{#songs}} 2 | {{>song}} 3 |
4 | {{#current_votes}} 5 | {{#user}} 6 | 7 | 8 | 9 | {{/user}} 10 | {{/current_votes}} 11 |
12 | {{/songs}} 13 | 14 | {{^songs}} 15 |
16 | The queue is empty. Quick, play some Ace of Base; no one's looking. 17 |
18 | {{/songs}} 19 | -------------------------------------------------------------------------------- /lib/play/templates/song.mustache: -------------------------------------------------------------------------------- 1 |
2 | 3 | {{#queued?}} 4 | - 5 | {{/queued?}} 6 | {{^queued?}} 7 | + 8 | {{/queued?}} 9 | 10 | 11 | 12 | {{artist_name}} 13 | 14 | 15 | 16 | {{title}} 17 | 18 | 19 | {{album_name}} 20 | 21 |
22 | -------------------------------------------------------------------------------- /play.yml.example: -------------------------------------------------------------------------------- 1 | # This file should get moved to: 2 | # ~/.play.yml 3 | # 4 | # Also you should fill it out. 5 | 6 | path: /path/to/your/music/yo 7 | gh_key: github_oauth_key 8 | gh_secret: github_oauth_secret 9 | office_url: http://example.com/office.macs 10 | 11 | # Database stuffs that we'll pass on to ActiveRecord. Change if you want to. If 12 | # you don't want to, you are probably a handsome-looking gentlemen or a 13 | # gorgeous lady who knows what they want out of life. 14 | db: 15 | database: play 16 | adapter: mysql2 17 | user: user 18 | password: password 19 | reconnect: true 20 | # db: 21 | # database: db/play.sqlite3 22 | # adapter: sqlite3 23 | -------------------------------------------------------------------------------- /lib/play/templates/profile.mustache: -------------------------------------------------------------------------------- 1 |
2 | {{#user}} 3 |
4 | 5 |
6 | 7 |
8 | @{{login}}
9 | Queued up {{votes_count}} songs.
10 |
11 | 12 | {{/user}} 13 |
14 | 15 |
16 |

Favorite Artists

17 | 24 |
25 | -------------------------------------------------------------------------------- /test/test_office.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "Office" do 4 | setup do 5 | end 6 | 7 | test "url returns config office_url" do 8 | Play.expects(:config).returns({'office_url' => 'http://zachholman.com'}) 9 | assert_equal 'http://zachholman.com', Play::Office.url 10 | end 11 | 12 | test "user string returns a string o' user data" do 13 | object = "lol" 14 | object.stubs(:read).returns("holman,kneath") 15 | Play::Office.stubs(:open).returns(object) 16 | assert_equal "holman,kneath", Play::Office.user_string 17 | end 18 | 19 | test "users are returned based on office string" do 20 | holman = Play::User.create(:office_string => 'holman') 21 | kneath = Play::User.create(:office_string => 'kneath') 22 | 23 | Play::Office.stubs(:user_string).returns("holman,defunkt") 24 | assert_equal [holman], Play::Office.users 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | 3 | begin 4 | require 'rubygems' 5 | require 'redgreen' 6 | require 'leftright' 7 | rescue LoadError 8 | end 9 | 10 | require 'rack/test' 11 | require 'mocha' 12 | require 'spec/mini' 13 | require 'running_man' 14 | require 'running_man/active_record_block' 15 | 16 | ENV['RACK_ENV'] = 'test' 17 | 18 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 19 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 20 | 21 | require 'play' 22 | include Play 23 | 24 | ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', 25 | :database => ":memory:") 26 | ActiveRecord::Migration.verbose = false 27 | ActiveRecord::Migrator.migrate("db/migrate") 28 | 29 | RunningMan.setup_on ActiveSupport::TestCase, :ActiveRecordBlock 30 | 31 | def parse_json(json) 32 | Yajl.load(json, :symbolize_keys => true) 33 | end 34 | -------------------------------------------------------------------------------- /test/spec/mini.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # test/spec/mini 5 3 | # http://gist.github.com/307649 4 | # chris@ozmm.org 5 | # 6 | def context(*args, &block) 7 | return super unless (name = args.first) && block 8 | require 'test/unit' 9 | klass = Class.new(defined?(ActiveSupport::TestCase) ? ActiveSupport::TestCase : Test::Unit::TestCase) do 10 | def self.test(name, &block) 11 | define_method("test_#{name.to_s.gsub(/\W/,'_')}", &block) if block 12 | end 13 | def self.xtest(*args) end 14 | def self.context(*args, &block) instance_eval(&block) end 15 | def self.setup(&block) 16 | define_method(:setup) { self.class.setups.each { |s| instance_eval(&s) } } 17 | setups << block 18 | end 19 | def self.setups; @setups ||= [] end 20 | def self.teardown(&block) define_method(:teardown, &block) end 21 | end 22 | (class << klass; self end).send(:define_method, :name) { name.gsub(/\W/,'_') } 23 | klass.class_eval &block 24 | end 25 | -------------------------------------------------------------------------------- /lib/play/office.rb: -------------------------------------------------------------------------------- 1 | require 'open-uri' 2 | 3 | module Play 4 | class Office 5 | # The users currently present in the office. 6 | # 7 | # Returns an Array of User objects. 8 | def self.users 9 | string_cache = user_string 10 | return unless string_cache 11 | users = [] 12 | string_cache.split(',').each do |string| 13 | users << User.find_by_office_string(string.downcase) 14 | end 15 | users.compact 16 | end 17 | 18 | # Hits the URL that we'll use to identify users. 19 | # 20 | # Returns a String of users (hopefully in comma-separated format). 21 | def self.user_string 22 | open(url).read 23 | rescue Exception 24 | nil 25 | end 26 | 27 | # The URL we can check to come up with the list of users in the office. 28 | # 29 | # Returns the String configuration value for `office_url`. 30 | def self.url 31 | Play.config['office_url'] 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /bin/play: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib]) 4 | 5 | require 'play' 6 | require 'optparse' 7 | 8 | parser = OptionParser.new do |opts| 9 | opts.banner = "Usage: play [options] COMMAND" 10 | 11 | opts.separator "" 12 | opts.separator "Options:" 13 | 14 | opts.on("--migrate", "Setup the database") do 15 | ActiveRecord::Base.establish_connection(Play.config['db']) 16 | ActiveRecord::Migrator.migrate("#{File.dirname(__FILE__)}/db/migrate") 17 | end 18 | 19 | opts.on("-d", "--detach", "Start the music server") do 20 | ENV['RACK_ENV'] = 'production' 21 | pid = fork { Play::Client.loop } 22 | Process.detach(pid) 23 | end 24 | 25 | opts.on("-s", "--stop", "Stop the music server") do 26 | Play::Client.stop 27 | end 28 | 29 | opts.on("-w", "--web", "Run the web instance") do 30 | system("rackup -p 5050 #{File.dirname(__FILE__)}/../config.ru") 31 | end 32 | 33 | opts.on("-p", "--path", "Pause the music server") do |path| 34 | Play::Client.pause 35 | end 36 | 37 | opts.on("-i", "--import", "Import new songs") do |import| 38 | Play::Library.import_songs 39 | exit 40 | end 41 | 42 | opts.on("-h", "--help", "Show this message") do 43 | puts opts 44 | exit 45 | end 46 | 47 | end 48 | 49 | parser.parse! 50 | -------------------------------------------------------------------------------- /db/migrate/01_create_schema.rb: -------------------------------------------------------------------------------- 1 | class CreateSchema < ActiveRecord::Migration 2 | def self.up 3 | create_table :songs do |t| 4 | t.string :title 5 | t.string :path 6 | t.integer :artist_id 7 | t.integer :album_id 8 | t.integer :playcount 9 | t.boolean :queued, :default => false 10 | t.boolean :now_playing, :default => false 11 | t.timestamps 12 | end 13 | 14 | create_table :artists do |t| 15 | t.string :name 16 | t.timestamps 17 | end 18 | 19 | create_table :albums do |t| 20 | t.string :name 21 | t.integer :artist_id 22 | t.timestamps 23 | end 24 | 25 | create_table :users do |t| 26 | t.string :login 27 | t.string :name 28 | t.string :email 29 | t.string :office_string 30 | t.string :alias 31 | end 32 | 33 | create_table :votes do |t| 34 | t.integer :song_id 35 | t.integer :user_id 36 | t.integer :artist_id 37 | t.boolean :active, :default => true 38 | t.timestamps 39 | end 40 | 41 | create_table :histories do |t| 42 | t.integer :song_id 43 | t.integer :artist_id 44 | t.timestamps 45 | end 46 | end 47 | 48 | def self.down 49 | drop_table :songs 50 | drop_table :artists 51 | drop_table :albums 52 | drop_table :queues 53 | drop_table :votes 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/test_app.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | include Rack::Test::Methods 3 | 4 | def app 5 | Play::App 6 | end 7 | 8 | context "App" do 9 | fixtures do 10 | @artist = Artist.create(:name => 'Justice') 11 | @song = Song.create(:title => 'Stress', :artist => @artist) 12 | end 13 | 14 | setup do 15 | @user = Play::User.new(:login => 'holman', 16 | :email => 'holman@example.com', 17 | :office_string => 'holman') 18 | Play::App.any_instance.stubs(:current_user).returns(@user) 19 | end 20 | 21 | test "/" do 22 | get '/' 23 | assert last_response.body.include?("@holman") 24 | assert last_response.body.include?("queue is empty") 25 | end 26 | 27 | test "/login" do 28 | get '/login' 29 | assert last_response.redirect? 30 | end 31 | 32 | test "/auth/github/callback" do 33 | Play::User.expects(:authenticate).with('trololol').returns(@user) 34 | auth = { 'omniauth.auth' => {'user_info' => 'trololol'} } 35 | get "/auth/github/callback", nil, auth 36 | assert last_response.redirect? 37 | end 38 | 39 | test "/now_playing" do 40 | Play.expects(:now_playing).returns(@song) 41 | get "/now_playing" 42 | assert last_response.body.include?("Stress") 43 | end 44 | 45 | # test "/add existing song" do 46 | # @song = Play::Song.new(:title => 'Stress') 47 | # Play::Song.any_instance.expects(:enqueue!) 48 | # get "/add/#{@song.id}" 49 | # end 50 | # 51 | # test "/remove existing song" do 52 | # @song = Play::Song.new(:title => 'Stress') 53 | # Play::Song.any_instance.expects(:dequeue!) 54 | # get "/remove/#{@song.id}" 55 | # end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /lib/play/user.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class User < ActiveRecord::Base 3 | has_many :votes 4 | 5 | # Let the user vote for a particular song. 6 | # 7 | # song - the Song that the user wants to vote up 8 | # 9 | # Returns the Vote object. 10 | def vote_for(song) 11 | votes.create(:song => song, :artist => song.artist) 12 | end 13 | 14 | # The count of the votes for this user. Used for Mustache purposes. 15 | # 16 | # Returns the Integer number of votes. 17 | def votes_count 18 | votes.count 19 | end 20 | 21 | # Queries the database for a user's favorite artists. It's culled just from 22 | # the historical votes of that user. 23 | # 24 | # Returns an Array of five Artist objects. 25 | def favorite_artists 26 | Artist.includes(:votes). 27 | where("votes.user_id = ?",id). 28 | group("votes.artist_id"). 29 | order("count(votes.artist_id) desc"). 30 | limit(5). 31 | all 32 | end 33 | 34 | # The MD5 hash of the user's email account. Used for showing their 35 | # Gravatar. 36 | # 37 | # Returns the String MD5 hash. 38 | def gravatar_id 39 | Digest::MD5.hexdigest(email) 40 | end 41 | 42 | # Authenticates a user. This will either select the existing user account, 43 | # or if it doesn't exist yet, create it on the system. 44 | # 45 | # auth - the Hash representation returned by OmniAuth after 46 | # authenticating 47 | # 48 | # Returns the User account. 49 | def self.authenticate(auth) 50 | if user = User.where(:login => auth['nickname']).first 51 | user 52 | else 53 | user = User.create(:login => auth['nickname'], 54 | :name => auth['name'], 55 | :email => auth['email']) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/play.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__)) 2 | 3 | require "rubygems" 4 | require "bundler/setup" 5 | 6 | require 'active_record' 7 | require 'audioinfo' 8 | require 'sinatra/base' 9 | require 'mustache/sinatra' 10 | require 'digest' 11 | require 'yajl' 12 | 13 | require 'play/core_ext/hash' 14 | 15 | require 'play/app' 16 | require 'play/artist' 17 | require 'play/album' 18 | require 'play/client' 19 | require 'play/history' 20 | require 'play/library' 21 | require 'play/office' 22 | require 'play/song' 23 | require 'play/views/layout' 24 | require 'play/user' 25 | require 'play/vote' 26 | 27 | module Play 28 | 29 | VERSION = '0.0.4' 30 | 31 | # The path to your music library. All of the music underneath this directory 32 | # will be added to the internal library. 33 | # 34 | # path - a String absolute path to your music 35 | # 36 | # Returns nothing. 37 | def self.path=(path) 38 | @path = path 39 | end 40 | 41 | # The path of the music library on-disk. 42 | # 43 | # Returns a String absolute path on the local file system. 44 | def self.path 45 | config['path'] 46 | end 47 | 48 | # The song that's currently playing. 49 | # 50 | # Returns the Song object from the database that's currently playing. 51 | def self.now_playing 52 | Song.where(:now_playing => true).first 53 | end 54 | 55 | # The path to play.yml. 56 | # 57 | # Returns the String path to the configuration file. 58 | def self.config_path 59 | "#{ENV['HOME']}/.play.yml" 60 | end 61 | 62 | # The configuration object for Play. 63 | # 64 | # Returns the Hash containing the configuration for Play. This includes: 65 | # 66 | # path - the String path to where your music is located 67 | # gh_key - the Client ID from your GitHub app's OAuth settings 68 | # gh_secret - the Client Secret from your GitHub app's OAuth settings 69 | # office_url - the URL to an endpoint where we can see who's in your office 70 | def self.config 71 | YAML::load(File.open(config_path)) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/play/client.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class Client 3 | # The main event loop for an audio client. It will loop through each song 4 | # in the queue, unless it's paused. 5 | # 6 | # Returns nothing. 7 | def self.loop 8 | while true 9 | Signal.trap("INT") { exit! } 10 | 11 | if paused? 12 | sleep(1) 13 | else 14 | system("afplay", Song.play_next_in_queue.path) 15 | end 16 | end 17 | end 18 | 19 | # The temp file we use to signify whether Play should be paused. 20 | # 21 | # Returns the String path of the pause file. 22 | def self.pause_path 23 | '/tmp/play_is_paused' 24 | end 25 | 26 | # "Pauses" a client by stopping currently playing songs and setting up the 27 | # pause temp file. 28 | # 29 | # Returns nothing. 30 | def self.pause 31 | paused? ? `rm -f #{pause_path}` : `touch #{pause_path}` 32 | `killall afplay > /dev/null 2>&1` 33 | end 34 | 35 | # Are we currently paused? 36 | # 37 | # Returns the Boolean value of whether we're paused. 38 | def self.paused? 39 | File.exist?(pause_path) 40 | end 41 | 42 | # Are we currently playing? Look at the process list and check it out. 43 | # 44 | # Returns true if we're playing, false if we aren't. 45 | def self.playing? 46 | `ps aux | grep afplay | grep -v grep | wc -l | tr -d ' '`.chomp != '0' 47 | end 48 | 49 | # Stop the music, and stop the music server. 50 | # 51 | # Returns nothing. 52 | def self.stop 53 | `killall afplay > /dev/null 2>&1` 54 | `kill \`ps ax | grep "play -d" | cut -d ' ' -f 1\`` 55 | end 56 | 57 | # Set the volume level of the client. 58 | # 59 | # number - The Integer volume level. This should be a number between 0 60 | # and 10, with "0" being "muted" and "10" being "real real loud" 61 | # 62 | # Returns nothing. 63 | def self.volume(number) 64 | system "osascript -e 'set volume #{number}' 2>/dev/null" 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/play/library.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class Library 3 | # Search a directory and return all of the files in it, recursively. 4 | # 5 | # Returns an Array of String file paths. 6 | def self.fs_songs 7 | `find "#{Play.path}" -type f ! -name '.*'`.split("\n") 8 | end 9 | 10 | # Imports an array of songs into the database. 11 | # 12 | # Returns nothing. 13 | def self.import_songs 14 | fs_songs.each do |path| 15 | import_song(path) 16 | end 17 | end 18 | 19 | # Imports a song into the database. This will identify a file's artist and 20 | # albums, run through the associations, and so on. It should be idempotent, 21 | # so you should be able to run it repeatedly on the same set of files and 22 | # not screw anything up. 23 | # 24 | # path - the String path to the music file on-disk 25 | # 26 | # Returns the imported (or found) Song. 27 | def self.import_song(path) 28 | artist_name,title,album_name = fs_get_artist_and_title_and_album(path) 29 | artist = Artist.find_or_create_by_name(artist_name) 30 | song = Song.where(:path => path).first 31 | 32 | if !song 33 | album = Album.where(:artist_id => artist.id, :name => album_name).first || 34 | Album.create(:artist_id => artist.id, :name => album_name) 35 | Song.create(:path => path, 36 | :artist => artist, 37 | :album => album, 38 | :title => title) 39 | end 40 | end 41 | 42 | # Splits a music file up into three constituent parts: artist, title, 43 | # album. 44 | # 45 | # path - the String path to the music file on-disk 46 | # 47 | # Returns an Array with three String elements: the artist, the song title, 48 | # and the album. 49 | def self.fs_get_artist_and_title_and_album(path) 50 | AudioInfo.open(path) do |info| 51 | return info.artist.try(:strip), 52 | info.title.try(:strip), 53 | info.album.try(:strip) 54 | end 55 | rescue AudioInfoError 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | play (0.0.4) 5 | SystemTimer 6 | activerecord 7 | mustache 8 | mysql2 9 | oa-oauth 10 | rack (~> 1.2.2) 11 | ruby-audioinfo 12 | sinatra 13 | sqlite3 14 | yajl-ruby 15 | 16 | GEM 17 | remote: http://rubygems.org/ 18 | specs: 19 | SystemTimer (1.2.3) 20 | activemodel (3.0.7) 21 | activesupport (= 3.0.7) 22 | builder (~> 2.1.2) 23 | i18n (~> 0.5.0) 24 | activerecord (3.0.7) 25 | activemodel (= 3.0.7) 26 | activesupport (= 3.0.7) 27 | arel (~> 2.0.2) 28 | tzinfo (~> 0.3.23) 29 | activesupport (3.0.7) 30 | addressable (2.2.5) 31 | apetag (1.1.2) 32 | cicphash (>= 1.0.0) 33 | arel (2.0.9) 34 | builder (2.1.2) 35 | cicphash (1.0.0) 36 | faraday (0.6.1) 37 | addressable (~> 2.2.4) 38 | multipart-post (~> 1.1.0) 39 | rack (< 2, >= 1.1.0) 40 | flacinfo-rb (0.4) 41 | i18n (0.5.0) 42 | mocha (0.9.12) 43 | mp4info (1.7.3) 44 | multi_json (1.0.1) 45 | multi_xml (0.2.2) 46 | multipart-post (1.1.0) 47 | mustache (0.99.3) 48 | mysql2 (0.2.7) 49 | oa-core (0.2.5) 50 | oa-oauth (0.2.5) 51 | faraday (~> 0.6.1) 52 | multi_json (~> 1.0.0) 53 | multi_xml (~> 0.2.2) 54 | oa-core (= 0.2.5) 55 | oauth (~> 0.4.0) 56 | oauth2 (~> 0.4.1) 57 | oauth (0.4.4) 58 | oauth2 (0.4.1) 59 | faraday (~> 0.6.1) 60 | multi_json (>= 0.0.5) 61 | rack (1.2.2) 62 | ruby-audioinfo (0.1.7) 63 | apetag (= 1.1.2) 64 | flacinfo-rb (>= 0.4) 65 | mp4info (>= 1.7.3) 66 | ruby-mp3info (>= 0.6.3) 67 | ruby-ogginfo (>= 0.3.1) 68 | wmainfo-rb (>= 0.5) 69 | ruby-mp3info (0.6.13) 70 | ruby-ogginfo (0.6.5) 71 | running_man (0.2.1) 72 | sinatra (1.2.6) 73 | rack (~> 1.1) 74 | tilt (< 2.0, >= 1.2.2) 75 | sqlite3 (1.3.3) 76 | tilt (1.3) 77 | tzinfo (0.3.27) 78 | wmainfo-rb (0.6) 79 | yajl-ruby (0.8.2) 80 | 81 | PLATFORMS 82 | ruby 83 | 84 | DEPENDENCIES 85 | mocha 86 | play! 87 | running_man 88 | -------------------------------------------------------------------------------- /test/test_song.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | context "Song" do 4 | fixtures do 5 | @song = Play::Song.create 6 | @user = Play::User.create 7 | end 8 | 9 | test "artist_name" do 10 | @song.artist = Play::Artist.new(:name => "Justice") 11 | assert_equal "Justice", @song.artist_name 12 | end 13 | 14 | test "album_name" do 15 | @song.album = Play::Album.new(:name => "A Cross the Universe") 16 | assert_equal "A Cross the Universe", @song.album_name 17 | end 18 | 19 | test "enqueue queues it up" do 20 | @song.enqueue! @user 21 | assert @song.queued 22 | end 23 | 24 | test "enqueue adds a user vote" do 25 | @song.enqueue! @user 26 | assert_equal 1, Play::Vote.count 27 | end 28 | 29 | test "dequeue" do 30 | @song.dequeue! @user 31 | assert !@song.queued 32 | end 33 | 34 | test "plays" do 35 | assert_equal 0, Play::Song.where(:now_playing => true).count 36 | @song.play! 37 | assert_equal 1, Play::Song.where(:now_playing => true).count 38 | end 39 | 40 | test "play next in queue adds a new history" do 41 | @song.update_attribute(:queued, true) 42 | Play::Song.play_next_in_queue 43 | assert_equal 1, Play::History.count 44 | end 45 | 46 | test "play next in queue sets the song as playing" do 47 | @song.update_attribute(:queued, true) 48 | Play::Song.play_next_in_queue 49 | @song.reload 50 | assert @song.now_playing? 51 | end 52 | 53 | test "play next in queue dequeues the song" do 54 | @song.update_attribute(:queued, true) 55 | Play::Song.play_next_in_queue 56 | @song.reload 57 | assert !@song.queued? 58 | end 59 | 60 | test "play next in queue returns the song" do 61 | @song.update_attribute(:queued, true) 62 | assert_equal @song, Play::Song.play_next_in_queue 63 | end 64 | 65 | test "generates a song for the office" do 66 | @artist1 = Play::Artist.create 67 | @song1 = Play::Song.create(:artist => @artist1) 68 | @artist2 = Play::Artist.create 69 | @song2 = Play::Song.create(:artist => @artist2) 70 | @user = Play::User.create 71 | 72 | users = [@user] 73 | @user.stubs(:favorite_artists).returns([@artist1]) 74 | Play::Office.expects(:users).returns(users) 75 | assert_equal @song1, Play::Song.office_song 76 | end 77 | 78 | end 79 | -------------------------------------------------------------------------------- /public/css/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | dark: 485460 4 | 3D72AA 5 | 5E7894 6 | 438FDF 7 | D0E4FA 8 | */ 9 | 10 | html{ 11 | font-family: Helvetica; 12 | } 13 | body{ 14 | margin: 0; 15 | padding: 0; 16 | color: #5E7894; 17 | } 18 | h1{ 19 | margin: 1em 10px 0 10px; 20 | padding: 0; 21 | letter-spacing: -3px; 22 | color: #000; 23 | } 24 | a{ 25 | color: #5E7894; 26 | text-decoration: none; 27 | } 28 | a:hover{ 29 | color: #000; 30 | } 31 | 32 | .navigation{ 33 | text-align: right; 34 | padding: 2px 10px; 35 | font-weight: bold; 36 | letter-spacing: -1px; 37 | font-size: 1.4em; 38 | } 39 | .navigation a{ 40 | color: #485460; 41 | padding-left: 10px; 42 | text-decoration: none; 43 | } 44 | .navigation form{ 45 | display: inline; 46 | } 47 | .navigation input{ 48 | vertical-align: top; 49 | width: 250px; 50 | } 51 | .content{ 52 | padding: 10px; 53 | background-color: #D0E4FA; 54 | } 55 | .gravatar{ 56 | -webkit-border-radius: 5px; 57 | } 58 | 59 | /* 60 | * SONGS 61 | */ 62 | .songs{ 63 | 64 | } 65 | .song{ 66 | letter-spacing: -1px; 67 | background-color: #D0E4FA; 68 | border-top: 2px solid #fff; 69 | padding: .5em 10px; 70 | font-size: 1.25em; 71 | font-weight: bold; 72 | } 73 | .song a:hover{ 74 | color: #000 !important; 75 | } 76 | .song .artist{ 77 | font-size: 3em; 78 | letter-spacing: -5px; 79 | } 80 | .song .artist a{ 81 | color: #485460; 82 | } 83 | .song .title{ 84 | padding-left: 10px; 85 | font-size: 2.2em; 86 | letter-spacing: -4px; 87 | } 88 | .song .album{ 89 | margin-left: 10px; 90 | font-size: 1.1em; 91 | letter-spacing: -2px; 92 | } 93 | .song .album a{ 94 | color: #438FDF; 95 | } 96 | .song .controls{ 97 | float: right; 98 | font-size: .75em; 99 | margin: -.5em -5px; 100 | text-transform: uppercase; 101 | } 102 | .votes{ 103 | background-color: #485460; 104 | padding: 5px 5px 0px 5px; 105 | } 106 | .votes img{ 107 | border: 1px solid #5E7894; 108 | } 109 | .votes img:hover{ 110 | border: 1px solid #D0E4FA; 111 | } 112 | 113 | /* 114 | * PROFILE 115 | */ 116 | .profile_box{ 117 | margin-right: 10px; 118 | float: left; 119 | font-size: 1.1em; 120 | } 121 | .bio{ 122 | padding: 15px 0; 123 | } 124 | .favorite_artists{ 125 | margin: 0 15px; 126 | } 127 | .favorite_artists h2{ 128 | color: #485460; 129 | } 130 | -------------------------------------------------------------------------------- /lib/play/song.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class Song < ActiveRecord::Base 3 | belongs_to :artist 4 | belongs_to :album 5 | has_many :votes 6 | has_many :histories 7 | 8 | scope :queue, select("songs.*,(select count(song_id) from votes where song_id=songs.id and active=1) as song_count"). 9 | where(:queued => true). 10 | order("song_count desc, updated_at") 11 | 12 | # The name of the artist. Used for Mustache purposes. 13 | # 14 | # Returns the String name of the artist. 15 | def artist_name 16 | artist.name 17 | end 18 | 19 | # The name of the album. Used for Mustache purposes. 20 | # 21 | # Returns the String name of the album. 22 | def album_name 23 | album.name 24 | end 25 | 26 | # The current votes for a song. A song may have many historical votes, 27 | # which is well and good, but here we're only concerned for the current 28 | # round of whether it's voted for. 29 | # 30 | # Returns and Array of Vote objects. 31 | def current_votes 32 | votes.where(:active => true).all 33 | end 34 | 35 | # Queue up a song. 36 | # 37 | # user - the User who is requesting the song to be queued 38 | # 39 | # Returns the result of the user's vote for that song. 40 | def enqueue!(user) 41 | self.queued = true 42 | save 43 | user.vote_for(self) 44 | end 45 | 46 | # Remove a song from the queue 47 | # 48 | # user - the User who is requesting the song be removed 49 | # 50 | # Returns true if removed properly, false otherwise. 51 | def dequeue!(user=nil) 52 | self.queued = false 53 | save 54 | end 55 | 56 | # Update the metadata surrounding playing a song. 57 | # 58 | # Returns a Boolean of whether we've saved the song. 59 | def play! 60 | Song.update_all(:now_playing => false) 61 | self.now_playing = true 62 | votes.update_all(:active => false) 63 | save 64 | end 65 | 66 | # Pull a magic song from a hat, depending on who's in the office. 67 | # 68 | # Returns a Song that's pulled from the favorite artists of the users 69 | # currently located in the office. 70 | def self.office_song 71 | users = Play::Office.users 72 | if !users.empty? 73 | artist = users.collect(&:favorite_artists).flatten.shuffle.first 74 | end 75 | 76 | if artist 77 | artist.songs.shuffle.first 78 | else 79 | Play::Song.order("rand()").first 80 | end 81 | end 82 | 83 | # Plays the next song in the queue. Updates the appropriate metainformation 84 | # in surrounding tables. Will pull an office favorite if there's nothing in 85 | # the queue currently. 86 | # 87 | # Returns the Song that was selected next to be played. 88 | def self.play_next_in_queue 89 | song = queue.first 90 | song ||= office_song 91 | Play::History.create(:song => song) 92 | song.play! 93 | song.dequeue! 94 | song 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/play/app/api.rb: -------------------------------------------------------------------------------- 1 | module Play 2 | class App < Sinatra::Base 3 | get "/api/now_playing" do 4 | song = Play.now_playing 5 | music_response(song) 6 | end 7 | 8 | post "/api/user/add_alias" do 9 | user = User.find_by_login(params[:login]) 10 | if user 11 | user.alias = params[:alias] 12 | { :success => user.save }.to_json 13 | else 14 | error "Couldn't find that user. Crap." 15 | end 16 | end 17 | 18 | post "/api/import" do 19 | if Play::Library.import_songs 20 | { :success => 'true' }.to_json 21 | else 22 | error "Had some problems importing into Play. Uh-oh." 23 | end 24 | end 25 | 26 | post "/api/add_song" do 27 | api_authenticate 28 | artist = Play::Artist.find_by_name(params[:artist_name]) 29 | if artist 30 | song = artist.songs.find_by_title(params[:song_title]) 31 | if song 32 | song.enqueue!(api_user) 33 | music_response(song) 34 | else 35 | error("Sorry, but we couldn't find that song.") 36 | end 37 | else 38 | error("Sorry, but we couldn't find that artist.") 39 | end 40 | end 41 | 42 | post "/api/add_artist" do 43 | api_authenticate 44 | artist = Play::Artist.find_by_name(params[:artist_name]) 45 | if artist 46 | {:song_titles => artist.enqueue!(api_user).collect(&:title), 47 | :artist_name => artist.name}.to_json 48 | else 49 | error("Sorry, but we couldn't find that artist.") 50 | end 51 | end 52 | 53 | post "/api/add_album" do 54 | api_authenticate 55 | album = Play::Album.find_by_name(params[:name]) 56 | if album 57 | album.enqueue!(api_user) 58 | {:artist_name => album.artist.name, 59 | :album_name => album.name}.to_json 60 | else 61 | error("Sorry, but we couldn't find that album.") 62 | end 63 | end 64 | 65 | post "/api/remove" do 66 | error "This hasn't been implemented yet. Whoops." 67 | end 68 | 69 | get "/api/search" do 70 | songs = case params[:facet] 71 | when 'artist' 72 | artist = Artist.find_by_name(params[:q]) 73 | artist ? artist.songs : nil 74 | when 'song' 75 | Song.where(:title => params[:q]) 76 | end 77 | 78 | songs ? {:song_titles => songs.collect(&:title)}.to_json : error("Search. Problem?") 79 | end 80 | 81 | post "/api/volume" do 82 | if Play::Client.volume(params[:level]) 83 | { :success => 'true' }.to_json 84 | else 85 | error "There's a problem adjusting the volume." 86 | end 87 | end 88 | 89 | post "/api/pause" do 90 | if Play::Client.pause 91 | { :success => 'true' }.to_json 92 | else 93 | error "There's a problem pausing." 94 | end 95 | end 96 | 97 | def api_user 98 | Play::User.find_by_login(params[:user_login]) || 99 | Play::User.find_by_alias(params[:user_login]) 100 | end 101 | 102 | def api_authenticate 103 | if api_user 104 | true 105 | else 106 | halt error("You must supply a valid `user_login` in your requests.") 107 | end 108 | end 109 | 110 | def error(msg) 111 | { :error => msg }.to_json 112 | end 113 | 114 | def music_response(song) 115 | { 116 | 'artist_name' => song.artist_name, 117 | 'song_title' => song.title, 118 | 'album_name' => song.album_name 119 | }.to_json 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/play/app.rb: -------------------------------------------------------------------------------- 1 | require 'play/app/api' 2 | 3 | module Play 4 | class App < Sinatra::Base 5 | register Mustache::Sinatra 6 | 7 | dir = File.dirname(File.expand_path(__FILE__)) 8 | 9 | set :public, "#{dir}/../../public" 10 | set :static, true 11 | set :mustache, { 12 | :namespace => Play, 13 | :templates => "#{dir}/templates", 14 | :views => "#{dir}/views" 15 | } 16 | 17 | def current_user 18 | session['user_id'].blank? ? nil : User.find_by_id(session['user_id']) 19 | end 20 | 21 | configure :development,:production do 22 | # This should use Play.config eventually, but there's some weird loading 23 | # problems right now with this file. So it goes. Dupe it for now. 24 | config = YAML::load(File.open("#{ENV['HOME']}/.play.yml")) 25 | ActiveRecord::Base.establish_connection(config['db']) 26 | end 27 | 28 | configure :test do 29 | ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', 30 | :database => ":memory:") 31 | end 32 | 33 | before do 34 | if current_user 35 | @login = current_user.login 36 | else 37 | if ['production','test'].include?(ENV['RACK_ENV']) 38 | redirect '/login' unless request.path_info =~ /\/login/ || 39 | request.path_info =~ /\/auth/ || 40 | request.path_info =~ /\/api/ 41 | else 42 | # This will create a test user for you locally in development. 43 | session['user_id'] = User.create(:login => 'user', 44 | :email => 'play@example.com').id 45 | end 46 | end 47 | end 48 | 49 | get "/" do 50 | @songs = Song.queue.all 51 | mustache :index 52 | end 53 | 54 | get "/login" do 55 | redirect '/auth/github' 56 | end 57 | 58 | get '/auth/:name/callback' do 59 | auth = request.env['omniauth.auth'] 60 | @user = User.authenticate(auth['user_info']) 61 | session['user_id'] = @user.id 62 | redirect '/' 63 | end 64 | 65 | get "/now_playing" do 66 | @song = Play.now_playing 67 | mustache :now_playing 68 | end 69 | 70 | get "/add/:id" do 71 | @song = Song.find(params[:id]) 72 | @song.enqueue!(current_user) 73 | redirect '/' 74 | end 75 | 76 | get "/remove/:id" do 77 | @song = Song.find(params[:id]) 78 | @song.dequeue!(current_user) 79 | redirect '/' 80 | end 81 | 82 | get "/artist/*/album/*" do 83 | @artist = Artist.where(:name => params[:splat].first).first 84 | @album = @artist.albums.where(:name => params[:splat].last).first 85 | @songs = @album.songs 86 | mustache :album_songs 87 | end 88 | 89 | get "/artist/*" do 90 | @artist = Artist.where(:name => params[:splat].first).first 91 | @songs = @artist.songs 92 | mustache :artist_songs 93 | end 94 | 95 | get "/song/:id" do 96 | @song = Song.find(params[:id]) 97 | mustache :show_song 98 | end 99 | 100 | get "/search" do 101 | @search = params[:q] 102 | artist = Artist.where("LOWER(name) = ?", @search.downcase).first 103 | redirect "/artist/#{URI.escape(artist.name)}" if artist 104 | @songs = Song.where("title LIKE ?", "%#{@search}%").limit(100).all 105 | mustache :search 106 | end 107 | 108 | get "/history" do 109 | @songs = History.limit(100).order('created_at desc').collect(&:song) 110 | mustache :play_history 111 | end 112 | 113 | get "/:login" do 114 | @user = User.where(:login => params[:login]).first 115 | mustache :profile 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/test_api.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | include Rack::Test::Methods 3 | 4 | def app 5 | Play::App 6 | end 7 | 8 | context "Api" do 9 | fixtures do 10 | @artist = Play::Artist.create(:name => "Justice") 11 | @album = Play::Album.create(:name => "Cross", :artist => @artist) 12 | @song = Play::Song.create(:title => "Stress", 13 | :artist => @artist, 14 | :album => @album) 15 | @user = Play::User.create(:login => 'holman', :alias => 'zach') 16 | end 17 | 18 | test "/api/now_playing" do 19 | Play.expects(:now_playing).returns(@song) 20 | get "/api/now_playing" 21 | now_playing = parse_json(last_response.body.strip) 22 | assert_equal @artist.name, now_playing[:artist_name] 23 | assert_equal @song.title, now_playing[:song_title] 24 | assert_equal @album.name, now_playing[:album_name] 25 | end 26 | 27 | test "/api requires user_login" do 28 | post "/api/add_song", {} 29 | user = parse_json(last_response.body.strip) 30 | assert user[:error].include?("must supply a valid `user_login`") 31 | end 32 | 33 | test "user login check also works for aliases" do 34 | post "/api/add_song", {:user_login => 'zach'} 35 | user = parse_json(last_response.body.strip) 36 | assert !user[:error].include?("must supply a valid `user_login`") 37 | end 38 | 39 | test "/api/add_song" do 40 | post "/api/add_song", { :song_title => @song.title, 41 | :artist_name => @artist.name, 42 | :user_login => @user.login } 43 | song = parse_json(last_response.body.strip) 44 | assert_equal @song.title, song[:song_title] 45 | end 46 | 47 | test "/api/add_song without a found artist" do 48 | post "/api/add_song", { :song_title => 'Stress', 49 | :artist_name => "Ace of Base", 50 | :user_login => @user.login } 51 | resp = parse_json(last_response.body.strip) 52 | assert resp[:error].include?("find that artist") 53 | end 54 | 55 | test "/api/add_song without a found song" do 56 | post "/api/add_song", { :song_title => "I Saw the Sign", 57 | :artist_name => @artist.name, 58 | :user_login => @user.login } 59 | resp = parse_json(last_response.body.strip) 60 | assert resp[:error].include?("find that song") 61 | end 62 | 63 | test "/api/add_artist" do 64 | post "/api/add_artist", { :artist_name => @artist.name, 65 | :user_login => @user.login } 66 | song = parse_json(last_response.body.strip) 67 | assert_equal @artist.name, song[:artist_name] 68 | assert_equal [@song.title], song[:song_titles] 69 | end 70 | 71 | test "/api/add_album" do 72 | post "/api/add_album", { :name => @album.name, 73 | :user_login => @user.login } 74 | response = parse_json(last_response.body.strip) 75 | assert_equal @artist.name, response[:artist_name] 76 | assert_equal @album.name, response[:album_name] 77 | end 78 | 79 | test "/api/remove" do 80 | post "/api/remove" 81 | resp = parse_json(last_response.body.strip) 82 | assert resp[:error].include?("hasn't been implemented") 83 | end 84 | 85 | test "/api/search artist" do 86 | get "/api/search", { :q => @artist.name, 87 | :facet => 'artist' } 88 | resp = parse_json(last_response.body.strip) 89 | assert resp[:song_titles].include?(@song.title) 90 | end 91 | 92 | test "/api/search song" do 93 | get "/api/search", { :q => @song.title, 94 | :facet => 'song' } 95 | resp = parse_json(last_response.body.strip) 96 | assert resp[:song_titles].include?(@song.title) 97 | end 98 | 99 | test "/api/user/add_alias" do 100 | post "/api/user/add_alias", { :login => @user.login, :alias => 'zach' } 101 | resp = parse_json(last_response.body.strip) 102 | assert 'true', resp[:success] 103 | assert_equal 'zach', User.first.alias 104 | end 105 | 106 | test "/api/import" do 107 | Library.stubs(:import_songs).returns('true') 108 | post "/api/import" 109 | resp = parse_json(last_response.body.strip) 110 | assert_equal 'true', resp[:success] 111 | end 112 | 113 | test "/api/volume" do 114 | Play::Client.expects(:volume).with('3').returns(true) 115 | post "/api/volume", {:level => 3} 116 | resp = parse_json(last_response.body.strip) 117 | assert_equal 'true', resp[:success] 118 | end 119 | 120 | test "/api/volume with a float" do 121 | Play::Client.expects(:volume).with("2.5").returns(true) 122 | post "/api/volume", {:level => '2.5'} 123 | resp = parse_json(last_response.body.strip) 124 | assert_equal 'true', resp[:success] 125 | end 126 | 127 | test "/api/pause" do 128 | Play::Client.expects(:pause).returns(true) 129 | post "/api/pause" 130 | resp = parse_json(last_response.body.strip) 131 | assert_equal 'true', resp[:success] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /play.gemspec: -------------------------------------------------------------------------------- 1 | ## This is the rakegem gemspec template. Make sure you read and understand 2 | ## all of the comments. Some sections require modification, and others can 3 | ## be deleted if you don't need them. Once you understand the contents of 4 | ## this file, feel free to delete any comments that begin with two hash marks. 5 | ## You can find comprehensive Gem::Specification documentation, at 6 | ## http://docs.rubygems.org/read/chapter/20 7 | Gem::Specification.new do |s| 8 | s.specification_version = 2 if s.respond_to? :specification_version= 9 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 10 | s.rubygems_version = '1.3.5' 11 | 12 | ## Leave these as is they will be modified for you by the rake gemspec task. 13 | ## If your rubyforge_project name is different, then edit it and comment out 14 | ## the sub! line in the Rakefile 15 | s.name = 'play' 16 | s.version = '0.0.4' 17 | s.date = '2011-05-07' 18 | s.rubyforge_project = 'play' 19 | 20 | ## Make sure your summary is short. The description may be as long 21 | ## as you like. 22 | s.summary = "Your company's dj. ►" 23 | s.description = "We want to play music at our office. Everyone has their own 24 | library on their own machines, and everyone except for me plays shitty music. 25 | Play is designed to make office music more palatable." 26 | 27 | ## List the primary authors. If there are a bunch of authors, it's probably 28 | ## better to set the email to an email list or something. If you don't have 29 | ## a custom homepage, consider using your GitHub URL or the like. 30 | s.authors = ["Zach Holman"] 31 | s.email = 'github.com@zachholman.com' 32 | s.homepage = 'https://github.com/holman/play' 33 | 34 | ## This gets added to the $LOAD_PATH so that 'lib/NAME.rb' can be required as 35 | ## require 'NAME.rb' or'/lib/NAME/file.rb' can be as require 'NAME/file.rb' 36 | s.require_paths = %w[lib] 37 | 38 | ## If your gem includes any executables, list them here. 39 | s.executables = ["play"] 40 | s.default_executable = 'play' 41 | 42 | ## Specify any RDoc options here. You'll want to add your README and 43 | ## LICENSE files to the extra_rdoc_files list. 44 | #s.rdoc_options = ["--charset=UTF-8"] 45 | #s.extra_rdoc_files = %w[README LICENSE] 46 | 47 | ## List your runtime dependencies here. Runtime dependencies are those 48 | ## that are needed for an end user to actually USE your code. 49 | s.add_dependency('rack', ["~>1.2.2"]) 50 | s.add_dependency('sinatra') 51 | s.add_dependency('activerecord') 52 | s.add_dependency('sqlite3') 53 | s.add_dependency('mustache') 54 | s.add_dependency('SystemTimer') 55 | s.add_dependency('ruby-audioinfo') 56 | s.add_dependency('oa-oauth') 57 | s.add_dependency('yajl-ruby') 58 | s.add_dependency('mysql2') 59 | 60 | ## List your development dependencies here. Development dependencies are 61 | ## those that are only needed during development 62 | s.add_development_dependency('running_man') 63 | s.add_development_dependency('mocha') 64 | 65 | ## Leave this section as-is. It will be automatically generated from the 66 | ## contents of your Git repository via the gemspec task. DO NOT REMOVE 67 | ## THE MANIFEST COMMENTS, they are used as delimiters by the task. 68 | # = MANIFEST = 69 | s.files = %w[ 70 | Gemfile 71 | Gemfile.lock 72 | README.md 73 | Rakefile 74 | bin/play 75 | config.ru 76 | db/migrate/01_create_schema.rb 77 | lib/play.rb 78 | lib/play/album.rb 79 | lib/play/app.rb 80 | lib/play/app/api.rb 81 | lib/play/artist.rb 82 | lib/play/client.rb 83 | lib/play/core_ext/hash.rb 84 | lib/play/history.rb 85 | lib/play/library.rb 86 | lib/play/office.rb 87 | lib/play/song.rb 88 | lib/play/templates/album_songs.mustache 89 | lib/play/templates/artist_songs.mustache 90 | lib/play/templates/index.mustache 91 | lib/play/templates/layout.mustache 92 | lib/play/templates/now_playing.mustache 93 | lib/play/templates/play_history.mustache 94 | lib/play/templates/profile.mustache 95 | lib/play/templates/search.mustache 96 | lib/play/templates/show_song.mustache 97 | lib/play/templates/song.mustache 98 | lib/play/user.rb 99 | lib/play/views/album_songs.rb 100 | lib/play/views/artist_songs.rb 101 | lib/play/views/index.rb 102 | lib/play/views/layout.rb 103 | lib/play/views/now_playing.rb 104 | lib/play/views/play_history.rb 105 | lib/play/views/profile.rb 106 | lib/play/views/search.rb 107 | lib/play/views/show_song.rb 108 | lib/play/vote.rb 109 | play.gemspec 110 | play.yml.example 111 | public/css/base.css 112 | test/helper.rb 113 | test/spec/mini.rb 114 | test/test_album.rb 115 | test/test_api.rb 116 | test/test_app.rb 117 | test/test_artist.rb 118 | test/test_client.rb 119 | test/test_library.rb 120 | test/test_office.rb 121 | test/test_play.rb 122 | test/test_song.rb 123 | test/test_user.rb 124 | ] 125 | # = MANIFEST = 126 | 127 | ## Test files will be grabbed from the file list. Make sure the path glob 128 | ## matches what you actually use. 129 | s.test_files = s.files.select { |path| path =~ /^test\/test_.*\.rb/ } 130 | end 131 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | require 'date' 4 | 5 | ############################################################################# 6 | # 7 | # Helper functions 8 | # 9 | ############################################################################# 10 | 11 | def name 12 | @name ||= Dir['*.gemspec'].first.split('.').first 13 | end 14 | 15 | def version 16 | line = File.read("lib/#{name}.rb")[/^\s*VERSION\s*=\s*.*/] 17 | line.match(/.*VERSION\s*=\s*['"](.*)['"]/)[1] 18 | end 19 | 20 | def date 21 | Date.today.to_s 22 | end 23 | 24 | def rubyforge_project 25 | name 26 | end 27 | 28 | def gemspec_file 29 | "#{name}.gemspec" 30 | end 31 | 32 | def gem_file 33 | "#{name}-#{version}.gem" 34 | end 35 | 36 | def replace_header(head, header_name) 37 | head.sub!(/(\.#{header_name}\s*= ').*'/) { "#{$1}#{send(header_name)}'"} 38 | end 39 | 40 | ############################################################################# 41 | # 42 | # Standard tasks 43 | # 44 | ############################################################################# 45 | 46 | task :default => :test 47 | 48 | require 'rake/testtask' 49 | Rake::TestTask.new(:test) do |test| 50 | test.libs << 'lib' << 'test' 51 | test.pattern = 'test/**/test_*.rb' 52 | test.verbose = true 53 | end 54 | 55 | desc "Generate RCov test coverage and open in your browser" 56 | task :coverage do 57 | require 'rcov' 58 | sh "rm -fr coverage" 59 | sh "rcov test/test_*.rb" 60 | sh "open coverage/index.html" 61 | end 62 | 63 | require 'rake/rdoctask' 64 | Rake::RDocTask.new do |rdoc| 65 | rdoc.rdoc_dir = 'rdoc' 66 | rdoc.title = "#{name} #{version}" 67 | rdoc.rdoc_files.include('README*') 68 | rdoc.rdoc_files.include('lib/**/*.rb') 69 | end 70 | 71 | desc "Open an irb session preloaded with this library" 72 | task :console do 73 | sh "irb -rubygems -r ./lib/#{name}.rb" 74 | end 75 | 76 | ############################################################################# 77 | # 78 | # Custom tasks (add your own tasks here) 79 | # 80 | ############################################################################# 81 | 82 | $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/lib') 83 | 84 | require 'yaml' 85 | 86 | task :environment do 87 | ENV['RACK_ENV'] ||= 'development' 88 | require 'lib/play' 89 | require "bundler/setup" 90 | end 91 | 92 | desc "Open an irb session preloaded with this library" 93 | task :console do 94 | sh "irb -rubygems -r ./lib/play" 95 | end 96 | 97 | namespace :db do 98 | task :create do 99 | config = YAML::load(File.open("#{ENV['HOME']}/.play.yml")) 100 | `mysql -u#{config['db']['user']} \ 101 | --password='#{config['db']['password']}' \ 102 | --execute=\'CREATE DATABASE #{config['db']['database']} CHARACTER SET utf8 COLLATE utf8_unicode_ci;'` 103 | end 104 | 105 | task :drop do 106 | config = YAML::load(File.open("#{ENV['HOME']}/.play.yml")) 107 | `mysql --user=#{config['db']['user']} \ 108 | --password='#{config['db']['password']}' \ 109 | --execute='DROP DATABASE #{config['db']['database']};'` 110 | end 111 | 112 | desc "Migrate the database through scripts in db/migrate." 113 | task :migrate => :environment do 114 | ActiveRecord::Base.establish_connection(Play.config['db']) 115 | ActiveRecord::Migrator.migrate("db/migrate") 116 | end 117 | end 118 | 119 | ############################################################################# 120 | # 121 | # Packaging tasks 122 | # 123 | ############################################################################# 124 | 125 | desc "Create tag v#{version} and build and push #{gem_file} to Rubygems" 126 | task :release => :build do 127 | unless `git branch` =~ /^\* master$/ 128 | puts "You must be on the master branch to release!" 129 | exit! 130 | end 131 | sh "git commit --allow-empty -a -m 'Release #{version}'" 132 | sh "git tag v#{version}" 133 | sh "git push origin master" 134 | sh "git push origin v#{version}" 135 | sh "gem push pkg/#{name}-#{version}.gem" 136 | end 137 | 138 | desc "Build #{gem_file} into the pkg directory" 139 | task :build => :gemspec do 140 | sh "mkdir -p pkg" 141 | sh "gem build #{gemspec_file}" 142 | sh "mv #{gem_file} pkg" 143 | end 144 | 145 | desc "Generate #{gemspec_file}" 146 | task :gemspec => :validate do 147 | # read spec file and split out manifest section 148 | spec = File.read(gemspec_file) 149 | head, manifest, tail = spec.split(" # = MANIFEST =\n") 150 | 151 | # replace name version and date 152 | replace_header(head, :name) 153 | replace_header(head, :version) 154 | replace_header(head, :date) 155 | #comment this out if your rubyforge_project has a different name 156 | replace_header(head, :rubyforge_project) 157 | 158 | # determine file list from git ls-files 159 | files = `git ls-files`. 160 | split("\n"). 161 | sort. 162 | reject { |file| file =~ /^\./ }. 163 | reject { |file| file =~ /^(rdoc|pkg)/ }. 164 | map { |file| " #{file}" }. 165 | join("\n") 166 | 167 | # piece file back together and write 168 | manifest = " s.files = %w[\n#{files}\n ]\n" 169 | spec = [head, manifest, tail].join(" # = MANIFEST =\n") 170 | File.open(gemspec_file, 'w') { |io| io.write(spec) } 171 | puts "Updated #{gemspec_file}" 172 | end 173 | 174 | desc "Validate #{gemspec_file}" 175 | task :validate do 176 | libfiles = Dir['lib/*'] - ["lib/#{name}.rb", "lib/#{name}"] 177 | unless libfiles.empty? 178 | puts "Directory `lib` should only contain a `#{name}.rb` file and `#{name}` dir." 179 | exit! 180 | end 181 | unless Dir['VERSION*'].empty? 182 | puts "A `VERSION` file at root level violates Gem best practices." 183 | exit! 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Play 2 | Play is your company's dj. 3 | 4 | ## Background 5 | 6 | We want to play music at our office. Everyone has their own library on their 7 | own machines, and everyone except for me plays shitty music. Play is designed 8 | to make office music more palatable. 9 | 10 | Play is **api-driven** and **web-driven**. All music is dropped on a central 11 | Mac system. Once it's available to Play, users can control what's being played. 12 | Users can either use a nice web view or the API, which lends itself for use on 13 | the command line or through Campfire. 14 | 15 | Play will play all the songs that are added to its Queue. Play will play the 16 | crap out of that Queue. And you know what? If there's nothing left in the 17 | Queue, Play will figure out who's in the office and play something that they'll 18 | like. 19 | 20 | No shit. 21 | 22 | ## Install 23 | 24 | The underlying tech of Play uses `afplay` to control music (for now), so you'll 25 | need a Mac. `afplay` is just a simple command-line wrapper to OS X's underlying 26 | music libraries, so it should come preinstalled with your Mac, and it should 27 | play anything iTunes can play. 28 | 29 | Play also expects MySQL to be installed. 30 | 31 | ### Install the gem 32 | 33 | Play itself is installed with a gem. 34 | 35 | gem install play 36 | 37 | ### Fill out ~/.play.yml 38 | 39 | You'll need to set up your configuration values, which we store in 40 | `~/.play.yml`. You can view the [example file on 41 | GitHub](https://github.com/holman/play/blob/master/play.yml.example). 42 | 43 | ### Set up your database 44 | 45 | This is a bit of a pain that we'll correct eventually. For now, create your 46 | MySQL database by hand. We expect the database to be called `play`, but it's 47 | really pulled from whatever you have in `~/.play.yml`. When that's set up, run: 48 | 49 | bin/play --migrate 50 | 51 | ### Set up your GitHub application 52 | 53 | Next, go to GitHub and [register a new OAuth 54 | application](https://github.com/account/applications/new). Users sign in with 55 | their GitHub account. Copy the Client ID and Client Secret into Play's 56 | `~/.play.yml` file. 57 | 58 | ### Set up your music folder 59 | 60 | Next, tell Play where to look for music. It's the `path` attribute in 61 | `~/.play.yml`. We'll then look at your path and import everything 62 | recursively when you run: 63 | 64 | play -i 65 | 66 | ## Play 67 | 68 | Once you're all set up, you can spin up the web app with: 69 | 70 | play -w 71 | 72 | You can hit the server at [localhost:5050](http://localhost:5050). Queue some 73 | hawt, hawt music up. We'll wait. 74 | 75 | Ready? Cool. The only thing left to do is actually start the music server. 76 | That's done with: 77 | 78 | play -d 79 | 80 | You'll detach it and put it in the background, where it will sit waiting for 81 | salacious music to play for you. When you want to kill it for reals, run: 82 | 83 | play -s 84 | 85 | For all the fun commands and stuff you can do, just run: 86 | 87 | play -h 88 | 89 | 90 | ## Set up your office (optional) 91 | 92 | This isn't a required step. If nothing's in the queue and Play has still been 93 | told to play something, it'll just play random music. But you can set it up so 94 | it will play a suitable artist for someone who's currently in the office. 95 | 96 | That particular step is left to the reader's imagination — here at GitHub we 97 | poll our router's ARP tables and update an internal application with MAC 98 | addresses — but all Play cares about is a URL that returns comma-separated 99 | string identifiers. We get that string by hitting the `office_url` in 100 | `~/.play.yml`. The string that's returned from that URL should look 101 | something like this: 102 | 103 | holman,kneath,defunkt 104 | 105 | That means those three handsome lads are in the office right now. Once we get 106 | that, we'll compare each of those with the users we have in our database. We do 107 | that by checking a user attribute called `office_string`, which is just a 108 | unique identifier to associate back to Play's user accounts. In this example, 109 | I'd log into my account and change my `office_string` to be "holman" so I could 110 | match up. It could be anything, though; we actually use MAC addresses here. 111 | 112 | ## API 113 | 114 | Play has a full API that you can use to do tons of fun stuff. In fact, the API 115 | is more feature-packed than the web UI. Because we're programmers. And baller. 116 | 117 | Check out the [API docs on the wiki](https://github.com/holman/play/wiki/API). 118 | 119 | ## Local development 120 | 121 | If you're going to hack on this locally, you can use the normal process for 122 | writing a Sintara app (`rackup`, `shotgun`, et cetera). The only thing we kind 123 | of do differently in Play is we disable the GitHub OAuth callback (since your 124 | development URL is different than it would be in production). 125 | 126 | To actually use it locally, we'll automatically create a user called `user` for 127 | you when you first access the app. That way you can actually mess around 128 | without having to hit GitHub or create a user manually. 129 | 130 | None of this happens if you launch Play with `bin/play -d`. 131 | 132 | ## Current Status 133 | 134 | This is pretty rough. For the most part it should run pretty reliably for you, 135 | but there's a bit of setup and configuration that I'd like to refine and do 136 | away with until it's "ready" for prime time. 137 | 138 | Once it's ready, pretty sure I'm going to make the most awesome screencast, and 139 | then it's balls-out from there. 140 | 141 | ## ♬ ★★★ 142 | 143 | This was created by [Zach Holman](http://zachholman.com). You can follow me on 144 | Twitter as [@holman](http://twitter.com). 145 | 146 | I usually find myself playing Justice, Kanye West, and Muse at the office. 147 | --------------------------------------------------------------------------------