diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f433b9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.gem +*.rbc +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp +.sass-cache diff --git a/Gemfile b/Gemfile index 3740c46..b74cc2c 100644 --- a/Gemfile +++ b/Gemfile @@ -2,12 +2,15 @@ source 'https://rubygems.org' ruby '2.0.0' +# todo: get everything working before going whole hog with the gem +# gemspec + gem 'sinatra' gem 'sinatra-contrib' gem 'sinatra-asset-pipeline' gem 'foreman' - gem 'ruby-mpd', git: 'git@github.com:archSeer/ruby-mpd.git' +gem 'active_support', require: false group :development, :test do gem 'pry' diff --git a/Gemfile.lock b/Gemfile.lock index 2fc0d2f..028acc2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,9 @@ GIT GEM remote: https://rubygems.org/ specs: + active_support (3.0.0) + activesupport (= 3.0.0) + activesupport (3.0.0) backports (3.3.5) coderay (1.0.9) coffee-script (2.2.0) @@ -29,7 +32,7 @@ GEM method_source (~> 0.8) slop (~> 3.4) rack (1.5.2) - rack-protection (1.5.0) + rack-protection (1.5.1) rack rack-test (0.6.2) rack (>= 1.0) @@ -45,11 +48,11 @@ GEM sass (3.2.12) shotgun (0.9) rack (>= 1.0) - sinatra (1.4.3) + sinatra (1.4.4) rack (~> 1.4) rack-protection (~> 1.4) tilt (~> 1.3, >= 1.3.4) - sinatra-asset-pipeline (0.2.1) + sinatra-asset-pipeline (0.3.3) coffee-script rake sass @@ -65,12 +68,12 @@ GEM sinatra (~> 1.4.0) tilt (~> 1.3) slop (3.4.6) - sprockets (2.10.0) + sprockets (2.10.1) hike (~> 1.2) multi_json (~> 1.0) rack (~> 1.0) tilt (~> 1.1, != 1.3.0) - sprockets-helpers (1.0.1) + sprockets-helpers (1.1.0) sprockets (~> 2.0) sprockets-sass (1.0.2) sprockets (~> 2.0) @@ -86,6 +89,7 @@ PLATFORMS ruby DEPENDENCIES + active_support foreman pry rspec diff --git a/bin/mpd_client b/bin/mpd_client new file mode 100755 index 0000000..4b2bc0c --- /dev/null +++ b/bin/mpd_client @@ -0,0 +1,134 @@ +#!/usr/bin/env ruby + +ENV['RACK_ENV'] ||= 'development' + +require 'bundler' + +Bundler.setup +Bundler.require(:default, ENV['RACK_ENV']) + +require 'sinatra' +require 'sinatra/asset_pipeline' + +require 'sass' +require 'json' +require 'cgi' + +require 'active_support/core_ext/hash/slice' + +require File.expand_path('../lib/mpd_client', __dir__) + +module MPDClient + class Application < Sinatra::Base + + set server: 'thin' + + set :root, File.expand_path('../', __dir__).to_s + + set :assets_precompile, %w(app.js app.css *.png *.jpg *.svg *.eot *.ttf *.woff) + set :assets_prefix, ['assets'] + + register Sinatra::AssetPipeline + register Sinatra::Namespace + + MPDClient.connect! + + # TODO: Figure out why failing to supplying args breaks stuff + MPDClient.listen do + on :song do + each_conn do |conn| + json = { type: 'status', data: status }.to_json + conn << "data: #{json}\n\n" + end + end + + on :state do + each_conn do |conn| + json = { type: 'status', data: status }.to_json + conn << "data: #{json}\n\n" + end + end + + on :playlist do + each_conn do |conn| + json = { type: 'queue', data: Song.queue.map(&:to_h) }.to_json + conn << "data: #{json}\n\n" + end + end + + on :time do |elapsed, total| + each_conn do |conn| + json = { type: 'time', data: [elapsed, total] }.to_json + conn << "data: #{json}\n\n" + end + end + end + + get '/' do + erb :index + end + + namespace '/api' do + + get '/status' do + MPDClient.status.to_json + end + + get '/stream', provides: 'text/event-stream' do + stream :keep_open do |conn| + MPDClient.connect_user(conn) + conn.callback { MPDClient.disconnect_user(conn) } + end + end + + get '/albums' do + content_type 'application/json' + if params[:artist] + Album.by_artist(params[:artist]).sort.map(&:to_h).to_json + else + Album.all.to_json + end + end + + get '/artists' do + content_type 'application/json' + Artist.all.map(&:to_h).to_json + end + + get '/songs' do + content_type 'application/json' + if query = params.slice(:artist, :album) and !query.empty? + Song.by(**query).map(&:to_h).to_json + else + Song.all.sort.map(&:to_h).to_json + end + end + + get '/queue' do + content_type 'application/json' + { data: Song.queue.map(&:to_h) }.to_json + end + + put '/control/play' do + Control.play(params[:pos]) + end + + put '/control/:action' do + if Control.controls.include?(params[:action].to_sym) + Control.send(params[:action]) + else + not_found + end + end + + put '/control/volume/:value' do + content_type 'application/json' + Control.volume(params[:value]) + end + + end + + end +end + +MPDClient::Application.run! diff --git a/config.ru b/config.ru deleted file mode 100644 index 4d5bb55..0000000 --- a/config.ru +++ /dev/null @@ -1,6 +0,0 @@ -require 'bundler' - -Bundler.setup -require './mpd_client' - -run MPDClient diff --git a/lib/mpd_client.rb b/lib/mpd_client.rb new file mode 100644 index 0000000..92e486e --- /dev/null +++ b/lib/mpd_client.rb @@ -0,0 +1,43 @@ +require 'forwardable' +require 'ruby-mpd' +require 'set' + +require File.expand_path('mpd_client/class_to_proc', __dir__) + +module MPDClient + + autoload :Connection, File.expand_path('mpd_client/connection.rb', __dir__) + autoload :Song, File.expand_path('mpd_client/song.rb', __dir__) + autoload :Album, File.expand_path('mpd_client/album.rb', __dir__) + autoload :Artist, File.expand_path('mpd_client/artist.rb', __dir__) + autoload :Control, File.expand_path('mpd_client/control.rb', __dir__) + + MPD_HOST = ENV.fetch('MPD_HOST', 'localhost') + MPD_PORT = ENV.fetch('MPD_PORT', 6600) + + def self.connect! + @conn ||= Connection.new(MPD_HOST, MPD_PORT) + @conn.connect unless @conn.connected? + @conn + end + + def self.conn + @conn + end + + def self.status + self.conn.status + end + + def self.listen(&block) + self.conn.instance_eval(&block) + end + + def self.connect_user(conn) + self.conn.connected_users << conn + end + + def self.disconnect_user(conn) + self.conn.connected_users.delete(conn) + end +end diff --git a/lib/mpd_client/album.rb b/lib/mpd_client/album.rb new file mode 100644 index 0000000..676af23 --- /dev/null +++ b/lib/mpd_client/album.rb @@ -0,0 +1,48 @@ +module MPDClient + class Album + + include ClassToProc + include Enumerable + extend Forwardable + + delegate %i(artist genre) => :@first_song + + def initialize(album) + @songs = MPDClient::Song.by(album: album) + @first_song = @songs.first + end + + def each(&block) + @songs.each(&block) + end + + def title + @first_song.album + end + + def year + @first_song.year + end + + def to_h + { + title: title, + artist: artist, + genre: genre, + year: year, + songs: self.map(&:to_h) + } + end + + class << self + def all + MPDClient.conn.albums.sort.map(&self) + end + + def by_artist(artist) + MPDClient.conn.albums(artist).map(&self) + end + end + + end +end diff --git a/lib/mpd_client/artist.rb b/lib/mpd_client/artist.rb new file mode 100644 index 0000000..c75e500 --- /dev/null +++ b/lib/mpd_client/artist.rb @@ -0,0 +1,12 @@ +module MPDClient + class Artist + + include ClassToProc + + class << self + def all + MPDClient.conn.artists.sort.map(&self) + end + end + end +end diff --git a/lib/mpd_client/class_to_proc.rb b/lib/mpd_client/class_to_proc.rb new file mode 100644 index 0000000..8c4a2f2 --- /dev/null +++ b/lib/mpd_client/class_to_proc.rb @@ -0,0 +1,11 @@ +module MPDClient + module ClassToProc + + def self.included(receiver) + receiver.define_singleton_method :to_proc do + proc { |*args| new(*args) } + end + end + + end +end diff --git a/lib/mpd_client/connection.rb b/lib/mpd_client/connection.rb new file mode 100644 index 0000000..1a939e5 --- /dev/null +++ b/lib/mpd_client/connection.rb @@ -0,0 +1,16 @@ +module MPDClient + class Connection < MPD + + attr_accessor :connected_users + + def initialize(host, port, opts = {}) + @connected_users = Set.new + super host, port, opts.merge(callbacks: true) + end + + def each_conn(&block) + connected_users.each(&block) + end + + end +end diff --git a/lib/mpd_client/control.rb b/lib/mpd_client/control.rb new file mode 100644 index 0000000..f3259d3 --- /dev/null +++ b/lib/mpd_client/control.rb @@ -0,0 +1,35 @@ +module MPDClient + class Control + + class << self + def controls + %i(play stop next previous pause) + end + + def play(pos) + MPDClient.conn.play(pos) + end + + def stop + MPDClient.conn.stop + end + + def next + MPDClient.conn.next + end + + def previous + MPDClient.conn.previous + end + + def pause + MPDClient.conn.pause = !MPDClient.conn.paused? + end + + def volume(value) + MPDClient.conn.volume = value + end + end + + end +end diff --git a/lib/mpd_client/server.rb b/lib/mpd_client/server.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/mpd_client/song.rb b/lib/mpd_client/song.rb new file mode 100644 index 0000000..3e1e89a --- /dev/null +++ b/lib/mpd_client/song.rb @@ -0,0 +1,59 @@ +module MPDClient + class Song + + include ClassToProc + include Comparable + extend Forwardable + + delegate %i(id track artist album title time pos) => :@song + + def initialize(song, pos: nil) + @song = song + end + + def playing? + self == self.class.current_song + end + + def length + time + end + + def <=>(other) + [artist, album, title] == [other.artist, other.album, other.title] + end + + def to_h + { + id: id, + track: track, + artist: artist, + album: album, + title: title, + length: length, + pos: pos, + playing: playing? + } + end + + class << self + def by(**params) + params.delete_if {|_, v| v.nil? } + MPDClient.conn.where(params).map(&self) + end + + def all + MPDClient.conn.songs.map(&self) + end + + def queue + MPDClient.conn.queue.map(&self) + end + + def current_song + new(MPDClient.conn.current_song) + end + end + + end +end diff --git a/lib/mpd_client/version.rb b/lib/mpd_client/version.rb new file mode 100644 index 0000000..bde2900 --- /dev/null +++ b/lib/mpd_client/version.rb @@ -0,0 +1,3 @@ +module MPDClient + VERSION = "0.0.1" +end diff --git a/models/album.rb b/models/album.rb deleted file mode 100644 index 1897c23..0000000 --- a/models/album.rb +++ /dev/null @@ -1,24 +0,0 @@ -require './models/mpd_connection' - -class Album < Struct.new(:title, :artist, :genre, :year) - - def initialize(album) - first_song = MPDConnection.mpd.where(album: album).first - self.title = first_song.album - self.artist = first_song.artist - self.genre = first_song.genre - self.year = first_song.date - end - - def <=>(album) - year <=> album.year - end - - def self.all - MPDConnection.mpd.albums.sort.map { |album| self.new(album) } - end - - def self.by_artist(artist) - MPDConnection.mpd.albums(artist).map { |album| self.new(album) } - end -end diff --git a/models/artist.rb b/models/artist.rb deleted file mode 100644 index 0a365d4..0000000 --- a/models/artist.rb +++ /dev/null @@ -1,7 +0,0 @@ -require './models/mpd_connection' - -class Artist < Struct.new(:name) - def self.all - MPDConnection.mpd.artists.sort.map { |artist| self.new(artist) } - end -end diff --git a/models/control.rb b/models/control.rb deleted file mode 100644 index b313af2..0000000 --- a/models/control.rb +++ /dev/null @@ -1,33 +0,0 @@ -require './models/mpd_connection' - -class Control - class << self - def controls - [:play, :stop, :next, :previous, :pause] - end - - def play(pos = nil) - MPDConnection.mpd.play(pos) - end - - def stop - MPDConnection.mpd.stop - end - - def next - MPDConnection.mpd.next - end - - def previous - MPDConnection.mpd.previous - end - - def pause - MPDConnection.mpd.pause = !MPDConnection.mpd.paused? - end - - def volume(value) - MPDConnection.mpd.volume = value - end - end -end diff --git a/models/mpd_connection.rb b/models/mpd_connection.rb deleted file mode 100644 index b1f37fb..0000000 --- a/models/mpd_connection.rb +++ /dev/null @@ -1,11 +0,0 @@ -class MPDConnection - def self.mpd - @mpd ||= MPD.new('localhost', 6600, { callbacks: true }) - @mpd.connect unless @mpd.connected? - @mpd - end - - def self.status - self.mpd.status - end -end diff --git a/models/song.rb b/models/song.rb deleted file mode 100644 index 508334d..0000000 --- a/models/song.rb +++ /dev/null @@ -1,36 +0,0 @@ -require './models/mpd_connection' - -class Song < Struct.new(:id, :track, :artist, :album, :title, :length, :pos, :playing) - - def initialize(song, pos: nil, playing: false) - self.id = song.id - self.track = song.track - self.artist = song.artist - self.album = song.album - self.title = song.title - self.length = song.time - self.pos = pos - self.playing = playing - end - - def <=>(song) - title <=> song.title - end - - def self.queue - current_song = MPDConnection.mpd.status[:songid] - MPDConnection.mpd.queue.map { |song| self.new(song, playing: (song.id == current_song), pos: song.pos) } - end - - def self.all - MPDConnection.mpd.songs.map { |album| self.new(album) } - end - - def self.by_artist(artist) - MPDConnection.mpd.where(artist: artist).map { |song| self.new(song) } - end - - def self.by_album(artist, album) - MPDConnection.mpd.where(artist: artist, album: album).map { |song| self.new(song) } - end -end diff --git a/mpd_client.gemspec b/mpd_client.gemspec new file mode 100644 index 0000000..8bd4c84 --- /dev/null +++ b/mpd_client.gemspec @@ -0,0 +1,32 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'mpd_client/version' + +Gem::Specification.new do |spec| + spec.name = "mpd_client" + spec.version = MPDClient::VERSION + spec.authors = ["Dan Barber", "Lee Machin"] + spec.email = ["dan@new-bamboo.co.uk", "lee@new-bamboo.co.uk"] + spec.description = %q{Tasty web interface for MPD} + spec.summary = %q{Tasty web interface for MPD} + spec.homepage = "" + + spec.files = `git ls-files`.split($/) + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.require_paths = ["lib"] + + spec.add_development_dependency "bundler", "~> 1.3" + spec.add_development_dependency "rake" + spec.add_development_dependency "rspec" + spec.add_development_dependency "rspec-mocks" + spec.add_development_dependency "pry" + + spec.add_dependency "thin" + spec.add_dependency "sinatra" + spec.add_dependency "sinatra-contrib" + spec.add_dependency "sinatra-asset-pipeline" + spec.add_dependency "ruby-mpd" + spec.add_dependency "active_support" +end diff --git a/mpd_client.rb b/mpd_client.rb deleted file mode 100644 index 7d5a46e..0000000 --- a/mpd_client.rb +++ /dev/null @@ -1,113 +0,0 @@ -require 'bundler' -ENV['RACK_ENV'] ||= 'development' -Bundler.require(:default, ENV['RACK_ENV']) - -require 'sinatra/asset_pipeline' - -require 'sass' -require 'json' -require 'cgi' - -require './models/mpd_connection' -require './models/control' -require './models/album' -require './models/artist' -require './models/song' - -class MPDClient < Sinatra::Base - - set server: 'thin', connections: [] - - set :assets_precompile, %w(app.js app.css *.png *.jpg *.svg *.eot *.ttf *.woff) - set :assets_prefix, 'assets' - register Sinatra::AssetPipeline - - register Sinatra::Namespace - - get '/' do - erb :index - end - - def self.send_status - response = JSON({ type: 'status', data: MPDConnection.status }) - settings.connections.each { |out| out << "data: #{response}\n\n" } - end - - def self.send_queue - response = JSON({ type: 'queue', data: Song.queue.map(&:to_h) }) - settings.connections.each { |out| out << "data: #{response}\n\n" } - end - - def self.send_time(elapsed, total) - response = JSON({ type: 'time', data: [elapsed, total] }) - settings.connections.each { |out| out << "data: #{response}\n\n" } - end - - MPDConnection.mpd.on(:song) { |song| send_status } - MPDConnection.mpd.on(:state) { |state| send_status } - MPDConnection.mpd.on(:playlist) { |playlist| send_queue } - MPDConnection.mpd.on(:time) { |elapsed, total| send_time(elapsed, total) } - - namespace '/api' do - - get '/status' do - JSON MPDConnection.status - end - - get '/stream', provides: 'text/event-stream' do - stream :keep_open do |out| - settings.connections << out - out.callback { settings.connections.delete(out) } - end - end - - get '/albums' do - content_type 'application/json' - if params[:artist] - JSON Album.by_artist(CGI.unescape(params[:artist])).sort.map(&:to_h) - else - JSON Album.all.map(&:to_h) - end - end - - get '/artists' do - content_type 'application/json' - JSON Artist.all.map(&:to_h) - end - - get '/songs' do - content_type 'application/json' - if params[:artist] && params[:album] - JSON Song.by_album(CGI.unescape(params[:artist]), CGI.unescape(params[:album])).map(&:to_h) - elsif params[:artist] - JSON Song.by_artist(CGI.unescape(params[:artist])).map(&:to_h) - else - JSON Song.all.sort.map(&:to_h) - end - end - - get '/queue' do - content_type 'application/json' - JSON({ data: Song.queue.map(&:to_h) }) - end - - put '/control/play' do - Control.play(params[:pos]) - end - - put '/control/:action' do - if Control.controls.include?(params[:action].to_sym) - Control.send(params[:action]) - else - not_found - end - end - - put '/control/volume/:value' do - content_type 'application/json' - Control.volume(params[:value]) - end - - end - -end