hearken 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +1 -0
- data/.rvmrc +1 -0
- data/Gemfile +2 -4
- data/README.rdoc +48 -0
- data/Rakefile +5 -0
- data/bin/hearken +7 -0
- data/hearken.gemspec +9 -0
- data/lib/hearken.rb +1 -5
- data/lib/hearken/cli.rb +24 -0
- data/lib/hearken/command.rb +35 -0
- data/lib/hearken/command/enqueue.rb +12 -0
- data/lib/hearken/command/flush.rb +7 -0
- data/lib/hearken/command/list.rb +29 -0
- data/lib/hearken/command/recent.rb +31 -0
- data/lib/hearken/command/reload.rb +7 -0
- data/lib/hearken/command/restart.rb +7 -0
- data/lib/hearken/command/scrobbling.rb +14 -0
- data/lib/hearken/command/search.rb +25 -0
- data/lib/hearken/command/setup_scrobbling.rb +7 -0
- data/lib/hearken/command/show_properties.rb +9 -0
- data/lib/hearken/command/shuffle.rb +13 -0
- data/lib/hearken/command/start.rb +7 -0
- data/lib/hearken/command/status.rb +7 -0
- data/lib/hearken/command/stop.rb +7 -0
- data/lib/hearken/console.rb +34 -0
- data/lib/hearken/debug.rb +16 -0
- data/lib/hearken/indexing.rb +7 -0
- data/lib/hearken/indexing/audio_traverser.rb +19 -0
- data/lib/hearken/indexing/executor.rb +33 -0
- data/lib/hearken/indexing/ffmpeg_file.rb +51 -0
- data/lib/hearken/indexing/file.rb +13 -0
- data/lib/hearken/indexing/indexer.rb +28 -0
- data/lib/hearken/indexing/parser.rb +7 -0
- data/lib/hearken/indexing/persistant_traverser.rb +32 -0
- data/lib/hearken/indexing/persisted_traverser.rb +30 -0
- data/lib/hearken/library.rb +46 -0
- data/lib/hearken/player.rb +91 -0
- data/lib/hearken/preferences.rb +29 -0
- data/lib/hearken/queue.rb +26 -0
- data/lib/hearken/range_expander.rb +30 -0
- data/lib/hearken/scrobbler.rb +76 -0
- data/lib/hearken/tagged.rb +15 -0
- data/lib/hearken/track.rb +38 -0
- data/lib/hearken/version.rb +2 -2
- data/media/applause.mp3 +0 -0
- data/spec/hearken/command/enqueue_spec.rb +24 -0
- data/spec/hearken/command/list_spec.rb +31 -0
- data/spec/hearken/command/reload_spec.rb +20 -0
- data/spec/hearken/command/shuffle_spec.rb +27 -0
- data/spec/hearken/player_spec.rb +37 -0
- data/spec/hearken/range_expander_spec.rb +28 -0
- data/spec/spec_helper.rb +5 -0
- metadata +136 -8
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'hearken/indexing/file'
|
3
|
+
|
4
|
+
class Hearken::Indexing::AudioTraverser
|
5
|
+
attr_reader :current
|
6
|
+
EXTS = %w{m4a mp3 ogg wma}.map {|e| '.'+e }
|
7
|
+
|
8
|
+
def initialize path
|
9
|
+
@path = Pathname.new path
|
10
|
+
end
|
11
|
+
|
12
|
+
def each
|
13
|
+
@path.find { |child| yield Hearken::Indexing::File.new child, @path if is_audio? child }
|
14
|
+
end
|
15
|
+
private
|
16
|
+
def is_audio? current
|
17
|
+
current.file? and EXTS.include? current.extname.downcase
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'hearken/tagged'
|
2
|
+
|
3
|
+
class String
|
4
|
+
def escape char
|
5
|
+
gsub(char, "\\#{char}")
|
6
|
+
end
|
7
|
+
|
8
|
+
def escape2 char
|
9
|
+
split(char).join("\\#{char}")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module Hearken::Indexing::Executor
|
14
|
+
include Hearken::Tagged
|
15
|
+
|
16
|
+
def extract_file_attributes path
|
17
|
+
@path = path.to_s
|
18
|
+
@timestamp = path.timestamp
|
19
|
+
end
|
20
|
+
|
21
|
+
def clean_path path
|
22
|
+
path.escape(" ").escape2("'").escape("!").escape2("`").escape("(").escape(")").escape2("&").escape2(";")
|
23
|
+
end
|
24
|
+
|
25
|
+
def execute command
|
26
|
+
debug command
|
27
|
+
`#{command} 2>&1`
|
28
|
+
end
|
29
|
+
|
30
|
+
def debug message
|
31
|
+
puts message if ENV['DEBUG']
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'hearken/indexing/executor'
|
2
|
+
|
3
|
+
class Hearken::Indexing::FfmpegFile
|
4
|
+
include Hearken::Indexing::Executor
|
5
|
+
|
6
|
+
def initialize path
|
7
|
+
extract_file_attributes path
|
8
|
+
content = execute "ffmpeg -i #{clean_path @path}"
|
9
|
+
state = :draining
|
10
|
+
@meta = {}
|
11
|
+
content.each_line do |line|
|
12
|
+
l = line.chomp
|
13
|
+
case l
|
14
|
+
when " Metadata:"
|
15
|
+
state = :metadata
|
16
|
+
else
|
17
|
+
if state == :metadata
|
18
|
+
begin
|
19
|
+
m = / *: */.match l
|
20
|
+
@meta[m.pre_match.strip] = m.post_match.strip if m
|
21
|
+
rescue ArgumentError => e
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
@title = @meta['TIT2'] || @meta['title']
|
28
|
+
@album = @meta['TALB'] || @meta ['album']
|
29
|
+
@artist = @meta['TPE1'] || @meta['TPE2'] || @meta['artist']
|
30
|
+
@albumartist = @meta['TSO2'] || @meta['album_artist']
|
31
|
+
@time = to_duration @meta['Duration']
|
32
|
+
@date = @meta['TDRC'] || @meta['TYER'] || @meta['date']
|
33
|
+
@track = @meta['TRCK'] || @meta['track']
|
34
|
+
@puid = @meta['MusicIP PUID']
|
35
|
+
@mbartistid = @meta['MusicBrainz Artist Id']
|
36
|
+
@mbalbumid = @meta['MusicBrainz Album Id']
|
37
|
+
@mbalbumartistid = @meta['MusicBrainz Album Artist Id']
|
38
|
+
@asin = @meta['ASIN']
|
39
|
+
end
|
40
|
+
|
41
|
+
def method_missing method
|
42
|
+
@meta[method.to_s]
|
43
|
+
end
|
44
|
+
private
|
45
|
+
def to_duration s
|
46
|
+
return nil unless s
|
47
|
+
first, *rest = s.split ','
|
48
|
+
hours, minutes, seconds = first.split ':'
|
49
|
+
seconds.to_i + (minutes.to_i * 60) + (hours.to_i * 60 * 60)
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'splat'
|
2
|
+
require 'hearken/indexing/persistant_traverser'
|
3
|
+
|
4
|
+
class Hearken::Indexing::Indexer
|
5
|
+
def initialize path
|
6
|
+
@path = path
|
7
|
+
end
|
8
|
+
|
9
|
+
def execute
|
10
|
+
start = Time.now
|
11
|
+
count = 0
|
12
|
+
traverser = Hearken::Indexing::PersistantTraverser.new @path, Hearken::Indexing::PATH
|
13
|
+
|
14
|
+
traverser.each do |audio_file|
|
15
|
+
count += 1
|
16
|
+
show_progress start, count if count % 1000 == 0
|
17
|
+
end
|
18
|
+
|
19
|
+
show_progress start, count
|
20
|
+
|
21
|
+
(File.dirname(__FILE__)+'/../../../media/applause.mp3').to_player
|
22
|
+
end
|
23
|
+
private
|
24
|
+
def show_progress start, count
|
25
|
+
elapsed = Time.now-start
|
26
|
+
$stderr.puts "Processed #{count} audio files in #{elapsed} seconds (#{elapsed/count} per file)"
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'hearken/indexing/persisted_traverser'
|
2
|
+
require 'hearken/indexing/audio_traverser'
|
3
|
+
require 'hearken/indexing/parser'
|
4
|
+
|
5
|
+
class Hearken::Indexing::PersistantTraverser
|
6
|
+
def initialize audio_path, store_path
|
7
|
+
@audio_path, @store_path = audio_path, store_path
|
8
|
+
end
|
9
|
+
|
10
|
+
def each
|
11
|
+
with_existing_entries do |existing_entries, persisted|
|
12
|
+
Hearken::Indexing::AudioTraverser.new(@audio_path).each do |path|
|
13
|
+
existing = existing_entries[path.to_s]
|
14
|
+
existing = nil if existing and existing.timestamp != path.timestamp
|
15
|
+
existing = nil if existing and existing.no_tag_fields?
|
16
|
+
track = existing || Hearken::Indexing::Parser.parse(path)
|
17
|
+
persisted.append track
|
18
|
+
yield track
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
private
|
23
|
+
def with_existing_entries
|
24
|
+
persisted = Hearken::Indexing::PersistedTraverser.new @store_path
|
25
|
+
entries = {}
|
26
|
+
persisted.each do |entry|
|
27
|
+
entries[entry.path] = entry
|
28
|
+
end
|
29
|
+
persisted.clear
|
30
|
+
yield entries, persisted
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'hearken/track'
|
2
|
+
|
3
|
+
class Hearken::Indexing::PersistedTraverser
|
4
|
+
include Enumerable
|
5
|
+
|
6
|
+
def initialize path
|
7
|
+
@path = path
|
8
|
+
end
|
9
|
+
|
10
|
+
def each
|
11
|
+
File.open @path do |file|
|
12
|
+
while line = file.gets
|
13
|
+
row = line.chomp.split '<->'
|
14
|
+
track = Hearken::Track.new
|
15
|
+
Hearken::Tagged::FIELDS.each {|field| track.send "#{field}=", row.shift }
|
16
|
+
yield track
|
17
|
+
end
|
18
|
+
end if File.exist? @path
|
19
|
+
end
|
20
|
+
|
21
|
+
def clear
|
22
|
+
File.open @path, 'w'
|
23
|
+
end
|
24
|
+
|
25
|
+
def append track
|
26
|
+
File.open(@path, 'a') do |file|
|
27
|
+
file.puts track.to_a.join('<->')
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'hearken/track'
|
2
|
+
|
3
|
+
class Hearken::Library
|
4
|
+
FILE_FIELDS = %w{path timestamp}
|
5
|
+
TAG_FIELDS = %w{album track title artist time date albumartist puid mbartistid mbalbumid mbalbumartistid asin}
|
6
|
+
FIELDS = FILE_FIELDS + TAG_FIELDS
|
7
|
+
|
8
|
+
include Hearken::Debug
|
9
|
+
attr_reader :tracks
|
10
|
+
|
11
|
+
def initialize preferences
|
12
|
+
end
|
13
|
+
|
14
|
+
def count
|
15
|
+
@tracks.count
|
16
|
+
end
|
17
|
+
|
18
|
+
def row id
|
19
|
+
@tracks[id]
|
20
|
+
end
|
21
|
+
|
22
|
+
def path row
|
23
|
+
row.path
|
24
|
+
end
|
25
|
+
|
26
|
+
def with_track id
|
27
|
+
yield @tracks[id]
|
28
|
+
end
|
29
|
+
|
30
|
+
def reload
|
31
|
+
s = Time.now.to_i
|
32
|
+
path = File.expand_path('~')+'/.music'
|
33
|
+
@tracks = []
|
34
|
+
File.open path do |file|
|
35
|
+
id = 0
|
36
|
+
while line = file.gets
|
37
|
+
row = line.chomp.split '<->'
|
38
|
+
track = Hearken::Track.new id
|
39
|
+
FIELDS.each {|field| track.send "#{field}=", row.shift }
|
40
|
+
@tracks << track
|
41
|
+
id += 1
|
42
|
+
end
|
43
|
+
end if File.exist? path
|
44
|
+
puts "Reloaded db with #{@tracks.size} tracks in #{Time.now.to_i-s} seconds"
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'splat'
|
2
|
+
|
3
|
+
require 'hearken/queue'
|
4
|
+
require 'hearken/scrobbler'
|
5
|
+
require 'hearken/library'
|
6
|
+
|
7
|
+
module Hearken
|
8
|
+
class Player
|
9
|
+
include Queue
|
10
|
+
attr_reader :library, :scrobbler
|
11
|
+
attr_accessor :scrobbling, :matches
|
12
|
+
|
13
|
+
def initialize preferences
|
14
|
+
@scrobbler = Scrobbler.new preferences
|
15
|
+
@scrobbling = true
|
16
|
+
@library = Library.new preferences
|
17
|
+
@library.reload
|
18
|
+
end
|
19
|
+
|
20
|
+
def c text,colour
|
21
|
+
text.to_s.foreground colour
|
22
|
+
end
|
23
|
+
|
24
|
+
def status
|
25
|
+
if @pid
|
26
|
+
track = self.current
|
27
|
+
puts "Since #{c Time.at(track.started), :cyan}\n\t#{track}"
|
28
|
+
played = Time.now.to_i-track.started
|
29
|
+
puts "#{c played, :yellow} seconds (#{c track.time.to_i-played, :yellow} remaining)" if track.time
|
30
|
+
else
|
31
|
+
puts 'not playing'.foreground(:yellow)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def current
|
36
|
+
(@pid and File.exist?('current_song')) ? YAML.load_file('current_song') : nil
|
37
|
+
end
|
38
|
+
|
39
|
+
def register track
|
40
|
+
track.started = Time.now.to_i
|
41
|
+
File.open('current_song', 'w') {|f| f.print track.to_yaml }
|
42
|
+
end
|
43
|
+
|
44
|
+
def start
|
45
|
+
if @pid
|
46
|
+
puts "Already started (pid #{@pid})"
|
47
|
+
return
|
48
|
+
end
|
49
|
+
@pid = fork do
|
50
|
+
player_pid = nil
|
51
|
+
Signal.trap('TERM') do
|
52
|
+
Process.kill 'TERM', player_pid if player_pid
|
53
|
+
exit
|
54
|
+
end
|
55
|
+
total_tracks = @library.count
|
56
|
+
loop do
|
57
|
+
id = dequeue || (rand * total_tracks).to_i
|
58
|
+
row = @library.row id
|
59
|
+
unless row
|
60
|
+
puts "track with id #{id} did not exist"
|
61
|
+
next
|
62
|
+
end
|
63
|
+
path = @library.path row
|
64
|
+
unless path and File.exist? path
|
65
|
+
puts "track with id #{id} did not refer to a file"
|
66
|
+
next
|
67
|
+
end
|
68
|
+
@library.with_track(id) do |track|
|
69
|
+
@scrobbler.update track if @scrobbling
|
70
|
+
register track
|
71
|
+
player_pid = path.to_player
|
72
|
+
Process.wait player_pid
|
73
|
+
@scrobbler.scrobble track if @scrobbling
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
puts "Started (pid #{@pid})"
|
78
|
+
end
|
79
|
+
|
80
|
+
def stop
|
81
|
+
return unless @pid
|
82
|
+
Process.kill 'TERM', @pid
|
83
|
+
@pid = nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def restart
|
87
|
+
stop
|
88
|
+
start
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Hearken
|
2
|
+
class Preferences
|
3
|
+
def initialize
|
4
|
+
@preference_path = home_path '.hearken'
|
5
|
+
if File.exists? @preference_path
|
6
|
+
@preferences = YAML.load File.read(@preference_path)
|
7
|
+
else
|
8
|
+
@preferences = {}
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def home_path *paths
|
13
|
+
File.join File.expand_path('~'), *paths
|
14
|
+
end
|
15
|
+
|
16
|
+
def [] key
|
17
|
+
@preferences[key]
|
18
|
+
end
|
19
|
+
|
20
|
+
def []= key, value
|
21
|
+
@preferences[key] = value
|
22
|
+
persist
|
23
|
+
end
|
24
|
+
|
25
|
+
def persist
|
26
|
+
File.open(@preference_path, 'w') {|f| f.puts @preferences.to_yaml}
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Hearken
|
2
|
+
module Queue
|
3
|
+
def enqueue id
|
4
|
+
@sequence ||= 0
|
5
|
+
@library.with_track id do |track|
|
6
|
+
File.open("#{Time.now.to_i}-#{@sequence.to_s.rjust(8,'0')}.song", 'w') {|f| f.print track.to_yaml }
|
7
|
+
@sequence += 1
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def each
|
12
|
+
Dir.glob('*.song').sort.each do |file|
|
13
|
+
yield YAML.load File.read(file)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def dequeue
|
18
|
+
file = Dir.glob('*.song').sort.first
|
19
|
+
return nil unless file
|
20
|
+
hash = YAML.load(File.read(file))
|
21
|
+
id = hash[:id] if hash
|
22
|
+
FileUtils.rm file
|
23
|
+
id
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Hearken
|
2
|
+
class RangeExpander
|
3
|
+
def expand text
|
4
|
+
text.split(/[^0-9a-z-]/).inject([]) {|acc, term| acc + expand_term(term) }
|
5
|
+
end
|
6
|
+
|
7
|
+
def expand_to_ids text
|
8
|
+
expand(text).map {|number| from_number number }
|
9
|
+
end
|
10
|
+
private
|
11
|
+
def expand_term term
|
12
|
+
words = term.split '-'
|
13
|
+
words.empty? ? [] : range(words.first, words.last)
|
14
|
+
end
|
15
|
+
|
16
|
+
def range from, to
|
17
|
+
f, t = to_number(from), to_number(to)
|
18
|
+
t = to_number(from.slice(0...from.size-to.size)+to) if t < f
|
19
|
+
(f..t).to_a
|
20
|
+
end
|
21
|
+
|
22
|
+
def from_number term
|
23
|
+
term.to_s 36
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_number term
|
27
|
+
term.to_i 36
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|