music_blender 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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'