music_blender 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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'
|