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.
Files changed (44) hide show
  1. data/.gitignore +5 -0
  2. data/Gemfile +2 -0
  3. data/Gemfile.lock +67 -0
  4. data/ID3TAGS.txt +26 -0
  5. data/LICENSE +20 -0
  6. data/README.md +41 -0
  7. data/Rakefile +27 -0
  8. data/bin/blend +5 -0
  9. data/db/migrate/001_create_root_folders.rb +10 -0
  10. data/db/migrate/002_create_tracks.rb +15 -0
  11. data/db/migrate/003_rename_root_folders_table_to_music_folders.rb +6 -0
  12. data/db/migrate/004_create_artists.rb +11 -0
  13. data/db/migrate/005_add_missing_column_to_tracks.rb +5 -0
  14. data/db/schema.rb +47 -0
  15. data/lib/music_blender.rb +26 -0
  16. data/lib/music_blender/artist.rb +6 -0
  17. data/lib/music_blender/bootstrap.rb +32 -0
  18. data/lib/music_blender/db_adapter.rb +58 -0
  19. data/lib/music_blender/id3_adapter.rb +70 -0
  20. data/lib/music_blender/music_folder.rb +51 -0
  21. data/lib/music_blender/player.rb +83 -0
  22. data/lib/music_blender/player_monitor.rb +77 -0
  23. data/lib/music_blender/shell.rb +59 -0
  24. data/lib/music_blender/track.rb +68 -0
  25. data/lib/music_blender/version.rb +3 -0
  26. data/music_blender.gemspec +28 -0
  27. data/test/factories/artists.rb +7 -0
  28. data/test/factories/music_folders.rb +15 -0
  29. data/test/factories/tracks.rb +12 -0
  30. data/test/music/point1sec.mp3 +0 -0
  31. data/test/music/subfolder/insubfolder.mp3 +0 -0
  32. data/test/music/test1.txt +0 -0
  33. data/test/music/test2.txt +0 -0
  34. data/test/test_helper.rb +53 -0
  35. data/test/unit/artist_test.rb +9 -0
  36. data/test/unit/bootstrap_test.rb +32 -0
  37. data/test/unit/db_adapter_test.rb +37 -0
  38. data/test/unit/id3_adapter_test.rb +42 -0
  39. data/test/unit/music_folder_test.rb +92 -0
  40. data/test/unit/player_monitor_test.rb +70 -0
  41. data/test/unit/player_test.rb +76 -0
  42. data/test/unit/shell_test.rb +71 -0
  43. data/test/unit/track_test.rb +48 -0
  44. 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,3 @@
1
+ module MusicBlender
2
+ VERSION = '0.0.1'
3
+ 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,7 @@
1
+ FactoryGirl.define do
2
+
3
+ factory :artist, :class => MusicBlender::Artist do
4
+ sequence(:name) { |counter| "Factory Generated #{counter}" }
5
+ end
6
+
7
+ 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
File without changes
File without changes
@@ -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'