Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Comparing changes

Choose two branches to see what's changed or to start a new pull request. If you need to, you can also compare across forks.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also compare across forks.
base fork: smashwilson/websinger
base: 4712b00320
...
head fork: smashwilson/websinger
compare: eac5e6c694
Checking mergeability… Don't worry, you can still create the pull request.
  • 8 commits
  • 12 files changed
  • 0 commit comments
  • 1 contributor
View
10 app/controllers/application_controller.rb
@@ -1,17 +1,15 @@
-require 'mpg123player/client'
-
class ApplicationController < ActionController::Base
protect_from_forgery
before_filter :create_player_client
-
+
private
def create_player_client
- @player = Mpg123Player::Client.new
+ @player = Player.new
@player.ok?
-
+
@status = @player.status
@status.track = Track.find(@status.track_id) if @status.track_id
end
-
+
end
View
48 lib/mpg123player/client.rb → app/models/player.rb
@@ -1,42 +1,34 @@
-require 'socket'
-
require 'mpg123player/common'
require 'active_support/json'
-module Mpg123Player
-
-class Client
+# Non-ActiveRecord model. Manage status and communications with the mpg123 player process.
+class Player
+ include Mpg123Player
include Configurable
-
+
attr_reader :error
def initialize
configure
end
-
+
# Controls
-
+
def play ; command 'play' ; end
def pause ; command 'pause' ; end
+ def volume percent ; command 'volume', percent ; end
+ def restart ; command 'restart' ; end
+ def skip ; command 'skip' ; end
def stop ; command 'stop' ; end
def shutdown ; command 'shutdown' ; end
- # Connect to the server, issue a command, and disconnect.
- def command string
- unless Commands.include? string
- @error = "Invalid command: #{string}"
- return
- end
- socket = TCPSocket.new('localhost', @server_port)
- rescue e
- @error = "Connection failed: #{e}"
- else
- socket.puts string
- socket.close
+ # Issue a command to the server. Return the command object.
+ def command string, parameter = nil
+ PlayerCommand.create!(:action => string, :parameter => parameter)
end
-
+
# Status
-
+
def ok?
unless File.readable?(@pid_path)
@error = "Can't read the pid file at #{@pid_path}!"
@@ -53,11 +45,11 @@ def ok?
else
true
end
-
+
def status
@status ||= Status.from(JSON(status_json))
end
-
+
def status_json
if File.exist?(@status_path)
File.open(@status_path) { |f| f.gets(nil) }
@@ -65,19 +57,17 @@ def status_json
Status.stopped.to_json
end
end
-
+
def playing?
status.playback_state == 'playing'
end
-
+
def paused?
status.playback_state == 'paused'
end
-
+
def stopped?
status.playback_state == 'stopped'
end
end
-
-end
View
10 app/models/player_command.rb
@@ -0,0 +1,10 @@
+require 'mpg123player/common'
+
+class PlayerCommand < ActiveRecord::Base
+ validates :action, :inclusion => { :in => Mpg123Player::Commands }
+
+ def self.flush_queue
+ transaction { order(:created_at).map(&:destroy) }
+ end
+
+end
View
8 config/application.rb
@@ -1,12 +1,13 @@
require File.expand_path('../boot', __FILE__)
require 'rails/all'
+require File.dirname(__FILE__) + '/../lib/selective_logger'
if defined?(Bundler)
# If you precompile assets before deploying to production, use this line
- Bundler.require(*Rails.groups(:assets => %w(development test)))
+ # Bundler.require(*Rails.groups(:assets => %w(development test)))
# If you want your assets lazily compiled in production, use this line
- # Bundler.require(:default, :assets, Rails.env)
+ Bundler.require(:default, :assets, Rails.env)
end
module Websinger
@@ -44,5 +45,8 @@ class Application < Rails::Application
# Version of your assets, change this if you want to expire all your assets
config.assets.version = '1.0'
+
+ # Use the selective logger.
+ config.middleware.swap Rails::Rack::Logger, SelectiveLogger, :silenced => ['/player', '/playlist']
end
end
View
10 db/migrate/20120226160458_create_player_commands.rb
@@ -0,0 +1,10 @@
+class CreatePlayerCommands < ActiveRecord::Migration
+ def change
+ create_table :player_commands do |t|
+ t.string :action
+ t.string :parameter
+
+ t.timestamps
+ end
+ end
+end
View
9 db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended to check this file into your version control system.
-ActiveRecord::Schema.define(:version => 20110128212303) do
+ActiveRecord::Schema.define(:version => 20120226160458) do
create_table "enqueued_tracks", :force => true do |t|
t.integer "position"
@@ -20,6 +20,13 @@
t.datetime "updated_at"
end
+ create_table "player_commands", :force => true do |t|
+ t.string "action"
+ t.string "parameter"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
create_table "tracks", :force => true do |t|
t.string "title"
t.string "artist"
View
36 lib/mpg123player/common.rb
@@ -1,6 +1,6 @@
module Mpg123Player
-module Configuration
+module Configuration
class << self
def player_path ; @player_path || '/usr/bin/mpg123' ; end
def player_path= path ; @player_path = path ; end
@@ -27,7 +27,7 @@ def user= name ; @username = name ; end
module Configurable
attr_accessor :log_path, :error_log_path
-
+
def configure
@player_path = Configuration.player_path
@status_path = Configuration.status_path
@@ -38,35 +38,35 @@ def configure
end
end
-Commands = ['play', 'pause', 'stop', 'shutdown']
+Commands = %q{play pause jump volume restart skip stop shutdown}
class Status
attr_accessor :title, :artist, :album, :track_id
attr_accessor :seconds, :track_length, :volume
-
+
attr_accessor :track
-
+
attr_accessor :playback_state
-
+
def initialize
@playback_state = :playing
@seconds = 0
@volume = 0
end
-
+
def percent_complete
@track ? (@seconds / @track.length) * 100 : 0
end
-
+
def progress_s
@track ? "#{to_minutes_s @seconds} / #{to_minutes_s @track.length}" : '0:00 / 0:00'
end
-
+
def clear
@title = @artist = @album = @track_id = nil
@seconds = 0
end
-
+
def is_close_to? other
return false unless @title == other.title
return false unless @artist == other.artist
@@ -74,12 +74,12 @@ def is_close_to? other
return false unless @track_id == other.track_id
return false unless @volume == other.volume
return false unless @playback_state == other.playback_state
-
- return false unless (@seconds - other.seconds) < 1
-
+
+ return false unless (@seconds - other.seconds).abs < 1
+
true
end
-
+
def to_hash
{ :playback_state => @playback_state,
:artist => @artist,
@@ -91,23 +91,23 @@ def to_hash
:progress => progress_s,
:percent_complete => percent_complete }
end
-
+
protected
def to_minutes_s seconds
minutes = seconds.to_i / 60
"#{minutes}:#{(seconds - minutes * 60).to_i.to_s.rjust(2, '0')}"
end
-
+
public
-
+
def self.stopped
inst = new
inst.playback_state = :stopped
inst.clear
inst
end
-
+
def self.from hash
inst = new
hash.each do |k,v|
View
274 lib/mpg123player/player.rb
@@ -0,0 +1,274 @@
+# Requires an ActiveRecord environment.
+
+require 'io/wait'
+require 'logger'
+
+require 'mpg123player/common'
+require 'active_support/json'
+
+module Mpg123Player
+
+class Player
+ include Configurable
+ include ActiveSupport::BufferedLogger::Severity
+
+ attr_accessor :poll_time
+ attr_accessor :status, :last_status
+ attr_accessor :shutting_down
+
+ def initialize poll_time = 1, log_level = Logger::INFO
+ configure
+ check_paths
+
+ @poll_time = 1 # Seconds, may be fractional
+ @status = Status.new
+ @last_status = @status.dup
+ @shutting_down = false
+
+ # Initialize the logger.
+ @logger = Logger.new(@log_path, 1, 1024 * 1024)
+ @logger.level = log_level
+ end
+
+ # Lifecycle events.
+
+ # Open a pipe to the player executible and start the parsing loop. Only return once a shutdown command is processed.
+ def main_loop
+ @logger.info 'Starting player.'
+ @pipe = IO.popen("#{@player_path} -R -", 'w+')
+
+ # Record the player pid. Also, be sure that the player will be SIGTERM'd if we are.
+ File.open(@pid_path, 'w') { |f| f.puts @pipe.pid }
+ Signal.trap('TERM') { Process.kill 'TERM', @pipe.pid }
+
+ # Load the first track, or wait for the first track to be enqueued.
+ advance
+
+ # Cycle until a shutdown command is received.
+ until @shutting_down
+ process_line(@pipe.gets) if @pipe.ready?
+ process_command_queue
+ end
+
+ # Kill the mpg123 process.
+ Process.kill 'TERM', @pipe.pid
+
+ @logger.info 'Stopping player.'
+ @logger.close
+ end
+
+ # A track finished. Load the next enqueued track, waiting for one to be enqueued if the queue is empty.
+ def advance
+ e = EnqueuedTrack.top
+ while e.nil? && !@shutting_down
+ logger.debug 'Waiting for track'
+ sleep @poll_time
+ e = EnqueuedTrack.top
+ process_command_queue
+ end
+ load_track(e.track) unless @shutting_down
+ end
+
+ # Player process controls.
+
+ def load_track t, state = :playing
+ @status.track_id = t.id
+ @status.playback_state = state
+ @status.seconds = 0
+
+ command = state == :playing ? 'L' : 'LP'
+ execute "#{command} #{t.path}"
+
+ update_status
+ end
+
+ def play_action
+ execute 'P' if @status.playback_state != :playing
+ end
+
+ def pause_action
+ execute 'P' if @status.playback_state != :paused
+ end
+
+ # TODO remove when player controls are reorganized.
+ def stop_action
+ execute 'S' if @status.playback_state != :stopped
+ end
+
+ # Jump to an absolute track position in seconds.
+ def jump_action seconds
+ unless seconds =~ /[0-9]+/
+ @logger.error "Invalid jump offset: #{seconds}"
+ return
+ end
+ if [:playing, :paused].include?(@status.playback_state)
+ execute "J #{seconds}s"
+ @status.seconds = seconds.to_f
+ update_status
+ end
+ end
+
+ # Set player volume as a percent, 0 to 100.
+ def volume_action percent
+ unless percent =~ /100|[0-9]?[0-9]/
+ @logger.error "E Invalid volume: #{percent}"
+ return
+ end
+ execute "V #{percent}"
+ end
+
+ # Restart the current track.
+ def restart_action
+ execute "J 0"
+ update_status
+ end
+
+ # Skip to the next enqueued track, if one is present. Do nothing if the queue is empty.
+ def skip_action
+ e = EnqueuedTrack.top
+ load_track(e.track, @status.playback_state) unless e.nil?
+ end
+
+ # Unload the track and stop the current player.
+ def shutdown_action
+ execute 'Q'
+ @shutting_down = true
+ end
+
+ protected
+
+ # Verify that the locations specified for the pid and status files exist and are writable.
+ def check_paths
+ [@status_path, @pid_path].each do |path|
+ unless Dir.exist?(File.dirname(path)) && (! File.exist?(path) || File.writable?(path))
+ @logger.fatal <<MSG
+Unable to create the file: <#{path}>
+
+Please ensure that the directory exists and that your filesystem permissions are set appropriately, or
+change the locations with Mpg123Player::Configuration.
+MSG
+ raise
+ end
+ end
+ end
+
+ # Parse MPG123 remote interface output.
+ # For documentation, see http://mpg123.org/cgi-bin/viewvc.cgi/tags/1.2.1/doc/README.remote
+ def process_line line
+ @logger.debug line
+
+ # EOF from pipe.
+ return if line.nil?
+
+ # Startup version message.
+ return if line =~ /^@R MPG123/
+
+ # Stream information that we don't care about.
+ return if line =~ /^@S /
+
+ # Jump feedback.
+ return if line =~ /^@J/
+
+ # ID3v2 metadata tags
+ if md = /^@I ID3v2.([^:]+):(.+)/.match(line)
+ getter, setter = md[1], "#{md[1]}="
+ if @status.respond_to?(setter) && @status.respond_to?(getter)
+ @status.send(setter, md[2].strip) if @status.send(getter).nil?
+ update_status
+ else
+ @logger.debug "Ignoring tag: #{getter}"
+ end
+ return
+ end
+
+ # ID3 tag
+ if md = /^@I ID3:(.{30})(.{30})(.{30})/.match(line)
+ @status.title = md[1].strip
+ @status.artist = md[2].strip
+ @status.album = md[3].strip
+ update_status
+ return
+ end
+
+ # In the absense of parseable ID3 data just grab whatever
+ if md = /^@I (.+)/.match(line)
+ @status.title ||= md[1]
+ update_status
+ return
+ end
+
+ # Frame info (during playback)
+ if md = /^@F [0-9-]+ [0-9-]+ ([0-9.-]+) ([0-9.-]+)/.match(line)
+ @status.seconds = md[1].to_f
+ update_status
+ return
+ end
+
+ # Playing status changed
+ if md = /^@P (\d+)/.match(line)
+ transition_to_state([:stopped, :paused, :playing][md[1].to_i])
+ return
+ end
+
+ # Error
+ if md = /^@E (.+)/.match(line)
+ @logger.error md[1]
+ return
+ end
+
+ # Volume
+ if md = /^@V (\d+)/.match(line)
+ @status.volume = md[1].to_i
+ update_status
+ return
+ end
+
+ # Unparsed!
+ @logger.warn "UNPARSED #{line}"
+ end
+
+ # Handle all enqueued PlayerCommands.
+ def process_command_queue
+ Rails.logger.silence(WARN) do
+ PlayerCommand.flush_queue.each { |c| process_command c }
+ end
+ end
+
+ # Handle an incoming PlayerCommand.
+ def process_command command
+ @logger.info "Received command #{command.action} #{command.parameter}"
+ handler = method("#{command.action}_action")
+ case handler.arity
+ when 0 ; handler.call
+ when 1 ; handler.call(command.parameter)
+ else ; @logger.error "Action method #{command.action}_action has arity of #{handler.arity}"
+ end
+ end
+
+ # Invoke the appropriate callbacks depending on the current and previous player states.
+ def transition_to_state playback_state
+ former = @status.playback_state
+ if former != playback_state
+ @status.playback_state = playback_state
+ @status.clear if playback_state == :stopped
+ update_status
+ end
+ end
+
+ # Serialize the Status object to disk as JSON if it has changed significantly.
+ def update_status
+ unless @status.is_close_to? @last_status
+ File.open(@status_path, 'w') { |f| f.puts @status.to_json }
+ @last_status = @status.dup
+ end
+ end
+
+ # Send a command to the mpg123 pipe.
+ def execute string
+ @logger.debug "> #{string}"
+ @pipe.print "#{string}\n"
+ end
+
+end
+
+end
View
233 lib/mpg123player/server.rb
@@ -1,233 +0,0 @@
-require 'gserver'
-require 'thread'
-
-require 'mpg123player/common'
-require 'active_support/json'
-
-module Mpg123Player
-
-class Server < GServer
- include Configurable
-
- attr_accessor :stay_stopped, :shutting_down
- attr_accessor :status, :last_status, :last_track_id
-
- def initialize
- configure
-
- super(@server_port)
- stdlog = $stdout
-
- @mutex = Mutex.new
- @stay_stopped = false
- @shutting_down = false
- @status = Status.new
- @last_status = @status.dup
-
- @on_launch = Proc.new { }
- @on_stop = Proc.new { }
- @on_status = Proc.new { |status| }
-
- check_paths
- end
-
- # Configuration
-
- def advance &block
- @on_launch = block
- @on_stop = block
- end
-
- def on_status &block
- @on_status = block
- end
-
- # Controls
-
- def load_track track_path, track_id = nil
- @mutex.synchronize do
- @status.track_id = track_id
- @status.playback_state = :playing
- @pipe.puts "L #{track_path}"
- end
- end
-
- def play_track
- @mutex.synchronize do
- @stay_stopped = false
- @pipe.puts 'P' if @status.playback_state == :paused
- end
- end
-
- def pause_track
- @mutex.synchronize { @pipe.puts 'P' if @status.playback_state != :paused }
- end
-
- def stop_track
- @mutex.synchronize do
- @stay_stopped = true
- @pipe.puts 'S' if @status.playback_state != :stopped
- end
- end
-
- # Request handling
-
- def serve io
- case io.gets.chomp
- when 'play' ; play_track
- when 'pause' ; pause_track
- when 'stop' ; stop_track
- when 'shutdown' ; stop
- else io.puts 'E: Unrecognized command'
- end
- io.close
- end
-
- # State control
-
- def start
- File.open(@pid_path, 'w') { |f| f.puts Process.pid }
- start_player
- super
- @on_launch.call
- end
-
- def join
- super
- @parsing_thread.join
- end
-
- def stop
- File.delete(@pid_path, @status_path)
- @shutting_down = true
- super
- Process.kill 'TERM', @pipe.pid
- @pipe.close
- end
-
- #
- # Internal utilities.
- #
-
- protected
-
- # Verify that the locations specified for the pid and status files exists and is writable.
- def check_paths
- [@status_path, @pid_path].each do |path|
- unless Dir.exist?(File.dirname(path)) && (! File.exist?(path) || File.writable?(path))
- $stderr.puts <<MSG
-Unable to create the file: <#{path}>
-
-Please ensure that the directory exists and that your filesystem permissions are set appropriately, or
-change the locations with Mpg123Player::Configuration.
-MSG
- raise
- end
- end
- end
-
- # Open a pipe to the player executible. Launch the parsing loop in a background thread.
- def start_player
- @pipe = IO.popen("#{@player_path} -R -", 'w+')
- @parsing_thread = Thread.new { process_line(@pipe.gets) until @pipe.eof? || @shutting_down }
-
- Signal.trap('TERM') { Process.kill 'TERM', @pipe.pid }
- end
-
- # Parse MPG123 remote interface output.
- # For documentation, see http://mpg123.org/cgi-bin/viewvc.cgi/trunk/doc/README.remote
- def process_line line
-
- # Startup version message.
- return if line =~ /^@R MPG123/
-
- # Stream information that we don't care about.
- return if line =~ /^@S /
-
- # ID3v2 metadata tags
- if md = /^@I ID3v2.([^:]+):(.+)/.match(line)
- if @status.respond_to? md[1]
- @status.perform(md[1], md[2].strip)
- update_status
- else
- puts "Ignoring tag: #{md[1]}"
- end
- return
- end
-
- # ID3 tag
- if md = /^@I ID3:(.{30})(.{30})(.{30})/.match(line)
- @status.title = md[1].strip
- @status.artist = md[2].strip
- @status.album = md[3].strip
- update_status
- return
- end
-
- # ID3 optional metadata
- if md = /^@I ID3\.track:(.+)/.match(line)
- @status.track_number = md[1].to_i
- update_status
- return
- end
-
- # In the absense of parseable ID3 data
- if md = /^@I (.+)/.match(line)
- @status.title = md[1]
- update_status
- return
- end
-
- # Frame info (during playback)
- if md = /^@F [0-9-]+ [0-9-]+ ([0-9.-]+) ([0-9.-]+)/.match(line)
- @status.seconds = md[1].to_f
- update_status
- return
- end
-
- # Playing status changed
- if md = /^@P (\d+)/.match(line)
- transition_to_state([:stopped, :paused, :playing][md[1].to_i])
- return
- end
-
- # Error
- if md = /^@E (.+)/.match(line)
- puts "! #{md[1]}"
- return
- end
-
- # Volume
- if md = /^@V (\d+)/.match(line)
- @status.volume md[1].to_i
- return
- end
-
- # Unparsed!
- puts "UNPARSED #{line}"
- end
-
- # Invoke the appropriate callbacks depending on the current and previous player states.
- def transition_to_state playback_state
- former = @status.playback_state
- if former != playback_state
- @mutex.synchronize { @status.playback_state = playback_state }
- if playback_state == :stopped
- @last_track_id = @status.track_id
- @status.clear
- end
- update_status
- @on_stop.call if playback_state == :stopped
- end
- end
-
- def update_status
- unless @status.is_close_to? @last_status
- @on_status.call(@status)
- File.open(@status_path, 'w') { |f| f.puts @status.to_json }
- @last_status = @status.dup
- end
- end
-end
-
-end
View
22 lib/selective_logger.rb
@@ -0,0 +1,22 @@
+# A logger that can have its level elevated for specific actions, such as the player and playlist polling
+# requests.
+
+# Courtesy of: http://dennisreimann.de/blog/silencing-the-rails-log-on-a-per-action-basis/
+
+class SelectiveLogger < Rails::Rack::Logger
+ include ActiveSupport::BufferedLogger::Severity
+
+ def initialize app, opts = {}
+ @app = app
+ @opts = opts
+ @opts[:silenced] ||= []
+ end
+
+ def call env
+ if env['X-SILENCE-LOGGER'] || @opts[:silenced].include?(env['PATH_INFO'])
+ Rails.logger.silence(WARN) { @app.call(env) }
+ else
+ super(env)
+ end
+ end
+end
View
30 lib/tasks/player.rake
@@ -5,34 +5,10 @@ namespace :websinger do
desc 'The MP3 player daemon. Should be managed by init.'
task :player => :environment do
- require 'mpg123player/server'
+ require 'mpg123player/player'
- server = Mpg123Player::Server.new
- POLL_TIME = 1 # seconds (may be fractional)
-
- # Redirect stdout and stderr to log files.
- $stdout = File.open(server.log_path, 'a')
- $stderr = File.open(server.error_log_path, 'a')
-
- server.advance do
- if server.stay_stopped
- # Re-enqueue the previously playing track.
- t = Track.find(server.last_track_id)
- EnqueuedTrack.enqueue(t, :top)
- end
- e = nil
- e = EnqueuedTrack.top unless server.stay_stopped
- while e.nil? && ! server.shutting_down
- e = EnqueuedTrack.top unless server.stay_stopped
- sleep POLL_TIME
- end
- server.load_track e.track.path, e.track.id unless server.shutting_down
- end
-
- puts 'Starting server...'
- server.start
- server.join
- puts 'Shutting down...'
+ player = Mpg123Player::Player.new
+ player.main_loop
end
end
View
19 test/unit/player_command_test.rb
@@ -0,0 +1,19 @@
+require 'test_helper'
+
+class PlayerCommandTest < ActiveSupport::TestCase
+ test "ensure valid actions" do
+ assert PlayerCommand.create(:action => 'play').valid?
+ assert PlayerCommand.create(:action => 'pause').valid?
+ assert !PlayerCommand.create(:action => 'huuurf').valid?
+ end
+
+ test "clear command queue" do
+ c1 = PlayerCommand.create!(:action => 'play')
+ c2 = PlayerCommand.create!(:action => 'volume', :parameter => '90')
+ c3 = PlayerCommand.create!(:action => 'pause')
+
+ queue = PlayerCommand.flush_queue
+ assert PlayerCommand.all.empty?
+ assert_equal ['play', 'volume', 'pause'], queue.map(&:action)
+ end
+end

No commit comments for this range

Something went wrong with that request. Please try again.