music_blender 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +67 -0
- data/ID3TAGS.txt +26 -0
- data/LICENSE +20 -0
- data/README.md +41 -0
- data/Rakefile +27 -0
- data/bin/blend +5 -0
- data/db/migrate/001_create_root_folders.rb +10 -0
- data/db/migrate/002_create_tracks.rb +15 -0
- data/db/migrate/003_rename_root_folders_table_to_music_folders.rb +6 -0
- data/db/migrate/004_create_artists.rb +11 -0
- data/db/migrate/005_add_missing_column_to_tracks.rb +5 -0
- data/db/schema.rb +47 -0
- data/lib/music_blender.rb +26 -0
- data/lib/music_blender/artist.rb +6 -0
- data/lib/music_blender/bootstrap.rb +32 -0
- data/lib/music_blender/db_adapter.rb +58 -0
- data/lib/music_blender/id3_adapter.rb +70 -0
- data/lib/music_blender/music_folder.rb +51 -0
- data/lib/music_blender/player.rb +83 -0
- data/lib/music_blender/player_monitor.rb +77 -0
- data/lib/music_blender/shell.rb +59 -0
- data/lib/music_blender/track.rb +68 -0
- data/lib/music_blender/version.rb +3 -0
- data/music_blender.gemspec +28 -0
- data/test/factories/artists.rb +7 -0
- data/test/factories/music_folders.rb +15 -0
- data/test/factories/tracks.rb +12 -0
- data/test/music/point1sec.mp3 +0 -0
- data/test/music/subfolder/insubfolder.mp3 +0 -0
- data/test/music/test1.txt +0 -0
- data/test/music/test2.txt +0 -0
- data/test/test_helper.rb +53 -0
- data/test/unit/artist_test.rb +9 -0
- data/test/unit/bootstrap_test.rb +32 -0
- data/test/unit/db_adapter_test.rb +37 -0
- data/test/unit/id3_adapter_test.rb +42 -0
- data/test/unit/music_folder_test.rb +92 -0
- data/test/unit/player_monitor_test.rb +70 -0
- data/test/unit/player_test.rb +76 -0
- data/test/unit/shell_test.rb +71 -0
- data/test/unit/track_test.rb +48 -0
- metadata +250 -0
@@ -0,0 +1,51 @@
|
|
1
|
+
module MusicBlender
|
2
|
+
class MusicFolder < ActiveRecord::Base
|
3
|
+
has_many :tracks
|
4
|
+
|
5
|
+
def self.current
|
6
|
+
@current ||= MusicFolder.find_or_create_by(path: music_path)
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.music_path
|
10
|
+
MUSIC_PATH
|
11
|
+
end
|
12
|
+
|
13
|
+
def pick_a_track
|
14
|
+
tracks.
|
15
|
+
except_missing.
|
16
|
+
except_recently_played.
|
17
|
+
except_by_recently_played_artists.
|
18
|
+
by_weighted_random.
|
19
|
+
last
|
20
|
+
end
|
21
|
+
|
22
|
+
def load_tracks
|
23
|
+
relative_paths.each do |relative_path|
|
24
|
+
track = tracks.find_or_create_by(:relative_path => relative_path)
|
25
|
+
track.import_id3_tag_attributes!
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def update_missing_flags
|
30
|
+
tracks.each do |track|
|
31
|
+
track.update_column(:missing, ! File.exists?(track.full_path))
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
#######
|
36
|
+
private
|
37
|
+
#######
|
38
|
+
|
39
|
+
def relative_paths
|
40
|
+
Array.new.tap do |files|
|
41
|
+
Dir["#{path}/**/*.mp3"].each do |file_path|
|
42
|
+
files << relative_path(file_path)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def relative_path(absolute_path)
|
48
|
+
absolute_path.gsub(/#{self.path}\//,'')
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module MusicBlender
|
2
|
+
class Player
|
3
|
+
|
4
|
+
PLAYING_STATUS_CODE_MAP = {
|
5
|
+
-1 => 'Started',
|
6
|
+
0 => 'Stopped',
|
7
|
+
1 => 'Paused',
|
8
|
+
2 => 'Resumed',
|
9
|
+
}
|
10
|
+
|
11
|
+
METHODS_DELEGATED_TO_MONITOR = [
|
12
|
+
:frames,
|
13
|
+
:frames_remaining,
|
14
|
+
:playing,
|
15
|
+
:seconds,
|
16
|
+
:seconds_remaining,
|
17
|
+
:song_name,
|
18
|
+
:stop_pause_status
|
19
|
+
]
|
20
|
+
|
21
|
+
attr_reader :current_track, :logger, :stdin, :stdout, :stderr
|
22
|
+
|
23
|
+
METHODS_DELEGATED_TO_MONITOR.each do |delegated_method_name|
|
24
|
+
define_method(delegated_method_name) do
|
25
|
+
monitor.send(delegated_method_name)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize
|
30
|
+
@stdin, @stdout, @stderr, @wait_thread =
|
31
|
+
Open3.popen3('mpg123 --rva-mix -R')
|
32
|
+
@logger = Logger.new("#{BLENDER_ROOT}/log/player.log",'daily')
|
33
|
+
Thread.new { monitor.run }
|
34
|
+
end
|
35
|
+
|
36
|
+
def play
|
37
|
+
set_current_track_last_played_at
|
38
|
+
stdin.puts "LOAD #{pick_a_track.full_path}"
|
39
|
+
end
|
40
|
+
|
41
|
+
def pause
|
42
|
+
stdin.puts 'PAUSE'
|
43
|
+
end
|
44
|
+
|
45
|
+
def stop
|
46
|
+
stdin.puts 'STOP'
|
47
|
+
end
|
48
|
+
|
49
|
+
def quit
|
50
|
+
stdin.puts 'QUIT'
|
51
|
+
end
|
52
|
+
|
53
|
+
def status_string
|
54
|
+
PLAYING_STATUS_CODE_MAP[stop_pause_status]
|
55
|
+
end
|
56
|
+
|
57
|
+
#######
|
58
|
+
private
|
59
|
+
#######
|
60
|
+
|
61
|
+
def set_current_track_last_played_at
|
62
|
+
if current_track
|
63
|
+
current_track.update_column(:last_played_at, Time.now)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def pick_a_track
|
68
|
+
music_folder.pick_a_track.tap do |track|
|
69
|
+
@current_track = track
|
70
|
+
logger.debug("Picked Next Track: #{track.id} - #{track.full_path}")
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def monitor
|
75
|
+
@monitor ||= PlayerMonitor.new(self)
|
76
|
+
end
|
77
|
+
|
78
|
+
def music_folder
|
79
|
+
MusicFolder.current
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module MusicBlender
|
2
|
+
class PlayerMonitor
|
3
|
+
attr_accessor :frames, :frames_remaining, :player, :playing, :seconds, :seconds_remaining,
|
4
|
+
:song_name, :stop_pause_status
|
5
|
+
|
6
|
+
def initialize(player)
|
7
|
+
@player = player
|
8
|
+
end
|
9
|
+
|
10
|
+
def run
|
11
|
+
loop do
|
12
|
+
process_output_line(stdout.readline) while stdout.ready?
|
13
|
+
sleep 0.001
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
#######
|
18
|
+
private
|
19
|
+
#######
|
20
|
+
|
21
|
+
def stdout
|
22
|
+
player.stdout
|
23
|
+
end
|
24
|
+
|
25
|
+
def stderr
|
26
|
+
player.stderr
|
27
|
+
end
|
28
|
+
|
29
|
+
def process_output_line(line)
|
30
|
+
if line.match(/^@F (.+)/)
|
31
|
+
process_frame_message($1)
|
32
|
+
elsif line.match(/^@I (.+)/)
|
33
|
+
process_information_message($1)
|
34
|
+
elsif line.match(/^@P ([0-3])/)
|
35
|
+
process_stop_pause_status($1.to_i)
|
36
|
+
end
|
37
|
+
rescue Exception => e
|
38
|
+
logger.error("Monitor failed to process line: #{line}")
|
39
|
+
logger.error("Exception -> #{e.class}: #{e.message}.")
|
40
|
+
e.backtrace.each do |backtrace_line|
|
41
|
+
logger.error(backtrace_line)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def process_frame_message(message)
|
46
|
+
self.frames, self.frames_remaining, self.seconds, self.seconds_remaining =
|
47
|
+
message.split(/\s/).map { |value| value.to_f }
|
48
|
+
end
|
49
|
+
|
50
|
+
def process_information_message(message)
|
51
|
+
@song_name = message
|
52
|
+
@stop_pause_status = -1
|
53
|
+
@playing = true
|
54
|
+
end
|
55
|
+
|
56
|
+
def process_stop_pause_status(status)
|
57
|
+
@stop_pause_status = status
|
58
|
+
case status
|
59
|
+
when 0 then #STOPPED
|
60
|
+
@playing = false
|
61
|
+
play_next_track if seconds_remaining < 1
|
62
|
+
when 1 then #PAUSED
|
63
|
+
@playing = false
|
64
|
+
when 2 then #RESUMED
|
65
|
+
@playing = true
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def play_next_track
|
70
|
+
player.play
|
71
|
+
end
|
72
|
+
|
73
|
+
def logger
|
74
|
+
player.logger
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module MusicBlender
|
2
|
+
class Shell
|
3
|
+
|
4
|
+
COMMANDS = {
|
5
|
+
exit: ->() { player.stop; player.quit; throw(:exited, 'Exited Successfully'); },
|
6
|
+
info: ->() { print_info },
|
7
|
+
pause: ->() { player.pause },
|
8
|
+
play: ->() { player.play },
|
9
|
+
quit: ->() { player.quit },
|
10
|
+
rate: ->(rating) { update_rating(rating) },
|
11
|
+
stop: ->() { player.stop },
|
12
|
+
}.with_indifferent_access
|
13
|
+
|
14
|
+
def run
|
15
|
+
loop do
|
16
|
+
print 'mmp> '
|
17
|
+
execute(*next_command_with_args)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
#######
|
22
|
+
private
|
23
|
+
#######
|
24
|
+
|
25
|
+
def next_command_with_args
|
26
|
+
gets.strip.split(/\s+/)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.player
|
30
|
+
@player ||= Player.new
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.print_info
|
34
|
+
puts player.current_track.full_path
|
35
|
+
puts player.current_track.title
|
36
|
+
puts player.current_track.artist.name
|
37
|
+
puts "Rating: #{player.current_track.rating}"
|
38
|
+
puts "Last Played: #{player.current_track.last_played_at}"
|
39
|
+
puts "Seconds: #{player.seconds} (#{player.seconds_remaining})"
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.update_rating(rating)
|
43
|
+
player.current_track.update_attribute(:rating,rating)
|
44
|
+
puts "Rating Updated To: #{player.current_track.rating}"
|
45
|
+
end
|
46
|
+
|
47
|
+
def execute(command, *args)
|
48
|
+
if COMMANDS.has_key?(command)
|
49
|
+
args.any? ? COMMANDS[command].call(*args) : COMMANDS[command].call
|
50
|
+
else
|
51
|
+
puts "Unrecognized Command: #{command}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def player
|
56
|
+
self.class.player
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module MusicBlender
|
2
|
+
class Track < ActiveRecord::Base
|
3
|
+
|
4
|
+
validates_uniqueness_of :relative_path, :scope => :music_folder_id
|
5
|
+
validates_numericality_of :rating, :greater_than => 0, :only_integer => true
|
6
|
+
|
7
|
+
before_validation :import_id3_tag_attributes, :on => :create
|
8
|
+
|
9
|
+
before_save :persist_rating_to_id3_tag, :on => :update, :if => :rating_changed?
|
10
|
+
|
11
|
+
belongs_to :artist
|
12
|
+
belongs_to :music_folder
|
13
|
+
|
14
|
+
scope :by_weighted_random, ->() {
|
15
|
+
select(%Q{*, ((strftime('%s','now') - strftime('%s', ifnull(last_played_at,created_at)))/#{1.month.seconds.to_f})*rating*random() AS weighted_random}).order('weighted_random')
|
16
|
+
}
|
17
|
+
|
18
|
+
scope :except_recently_played, ->() {
|
19
|
+
where(['last_played_at IS NULL OR last_played_at < ?', unscoped.most_recent((count*0.1)+1).last.last_played_at])
|
20
|
+
}
|
21
|
+
|
22
|
+
scope :except_by_recently_played_artists, ->() {
|
23
|
+
where(['artist_id NOT IN (?)', unscoped.most_recent((count*0.02)+1).select(:artist_id).map(&:artist_id)])
|
24
|
+
}
|
25
|
+
|
26
|
+
scope :except_missing, ->() { where(:missing => false) }
|
27
|
+
scope :most_recent, ->(number) { order('last_played_at DESC').limit(number) }
|
28
|
+
|
29
|
+
def full_path
|
30
|
+
"#{music_folder.path}/#{relative_path}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def import_id3_tag_attributes!
|
34
|
+
import_id3_tag_attributes
|
35
|
+
save
|
36
|
+
end
|
37
|
+
|
38
|
+
#######
|
39
|
+
private
|
40
|
+
#######
|
41
|
+
|
42
|
+
def import_id3_tag_attributes
|
43
|
+
self.title = id3_title
|
44
|
+
self.artist = Artist.find_or_create_by(:name => id3_artist)
|
45
|
+
self.rating = id3_rating
|
46
|
+
end
|
47
|
+
|
48
|
+
def id3_title
|
49
|
+
id3_adapter.title
|
50
|
+
end
|
51
|
+
|
52
|
+
def id3_artist
|
53
|
+
id3_adapter.artist
|
54
|
+
end
|
55
|
+
|
56
|
+
def id3_rating
|
57
|
+
id3_adapter.rating
|
58
|
+
end
|
59
|
+
|
60
|
+
def persist_rating_to_id3_tag
|
61
|
+
id3_adapter.set_rating(rating.to_s)
|
62
|
+
end
|
63
|
+
|
64
|
+
def id3_adapter
|
65
|
+
@id3_adapter ||= Id3Adapter.new(full_path,rating)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require File.expand_path('../lib/music_blender/version', __FILE__)
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = "music_blender"
|
5
|
+
s.version = MusicBlender::VERSION
|
6
|
+
s.platform = Gem::Platform::RUBY
|
7
|
+
s.authors = ["Jonathan S. Garvin"]
|
8
|
+
s.email = ["jon@5valleys.com"]
|
9
|
+
s.homepage = "https://github.com/jsgarvin/music_blender"
|
10
|
+
s.summary = %q{Simple MP3 player written in Ruby.}
|
11
|
+
s.description = %q{A simple MP3 player written in Ruby. Depends on mpg123.}
|
12
|
+
|
13
|
+
s.add_dependency('activerecord')
|
14
|
+
s.add_dependency('sqlite3')
|
15
|
+
s.add_dependency('taglib-ruby')
|
16
|
+
|
17
|
+
s.add_development_dependency('assert_difference')
|
18
|
+
s.add_development_dependency('factory_girl')
|
19
|
+
s.add_development_dependency('fakefs')
|
20
|
+
s.add_development_dependency('mocha')
|
21
|
+
s.add_development_dependency('pry')
|
22
|
+
s.add_development_dependency('simplecov')
|
23
|
+
|
24
|
+
s.files = `git ls-files`.split("\n")
|
25
|
+
s.test_files = `git ls-files -- test/*`.split("\n")
|
26
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
27
|
+
s.require_paths = ["lib"]
|
28
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
FactoryGirl.define do
|
2
|
+
|
3
|
+
factory :music_folder, :class => MusicBlender::MusicFolder do
|
4
|
+
path '/some/music/path/'
|
5
|
+
|
6
|
+
factory :music_folder_with_tracks do
|
7
|
+
after(:create) do |music_folder|
|
8
|
+
3.times { music_folder.tracks << create(:track) }
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
|
@@ -0,0 +1,12 @@
|
|
1
|
+
FactoryGirl.define do
|
2
|
+
|
3
|
+
factory :track, :class => MusicBlender::Track do
|
4
|
+
association :artist
|
5
|
+
association :music_folder
|
6
|
+
sequence(:title) { |counterx| "Factory Generated #{counterx}" }
|
7
|
+
sequence(:relative_path) { |counter| "/some/path/#{counter}.mp3" }
|
8
|
+
sequence(:last_played_at) { |counter| counter.hours.ago }
|
9
|
+
rating 5
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
Binary file
|
Binary file
|
File without changes
|
File without changes
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
require 'simplecov'
|
4
|
+
require 'minitest/autorun'
|
5
|
+
require 'factory_girl'
|
6
|
+
require 'pry'
|
7
|
+
require 'assert_difference'
|
8
|
+
|
9
|
+
SimpleCov.start
|
10
|
+
|
11
|
+
require 'music_blender'
|
12
|
+
|
13
|
+
FactoryGirl.find_definitions
|
14
|
+
module MusicBlender
|
15
|
+
MUSIC_PATH = "#{BLENDER_ROOT}/test/music"
|
16
|
+
|
17
|
+
class MiniTest::Unit::TestCase
|
18
|
+
include AssertDifference
|
19
|
+
include FactoryGirl::Syntax::Methods
|
20
|
+
|
21
|
+
ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ':memory:')
|
22
|
+
silence_stream(STDOUT) { load("#{BLENDER_ROOT}/db/schema.rb") }
|
23
|
+
|
24
|
+
def before_setup
|
25
|
+
super
|
26
|
+
capture_stdout
|
27
|
+
Track.any_instance.stubs(:persist_rating_to_id3_tag)
|
28
|
+
Track.any_instance.stubs(:id3_adapter => OpenStruct.new(:title => 'foo', :artist => 'bar', :rating => 42))
|
29
|
+
end
|
30
|
+
|
31
|
+
def after_teardown
|
32
|
+
super
|
33
|
+
release_stdout
|
34
|
+
end
|
35
|
+
|
36
|
+
#Capture STDOUT from program for testing and not cluttering test output
|
37
|
+
def capture_stdout
|
38
|
+
@stdout = $stdout
|
39
|
+
$stdout = StringIO.new
|
40
|
+
end
|
41
|
+
|
42
|
+
def release_stdout
|
43
|
+
$stdout = @stdout
|
44
|
+
end
|
45
|
+
|
46
|
+
#Redirect intentional puts from within tests to the real STDOUT for troublshooting purposes.
|
47
|
+
def puts(*args)
|
48
|
+
@stdout.puts(*args)
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
53
|
+
require 'mocha/setup'
|