Class: Sweeper

Inherits:
Object
  • Object
show all
Defined in:
lib/sweeper.rb

Defined Under Namespace

Classes: Problem

Constant Summary collapse

BASIC_KEYS =
['artist', 'title', 'url']
GENRE_KEYS =
['genre', 'comment']
ALBUM_KEYS =
['album', 'track']
GENRES =
ID3Lib::Info::Genres
GENRE_COUNT =
10
DEFAULT_GENRE =
{'genre' => 'Other', 'comment' => 'other'}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ Sweeper

Instantiate a new Sweeper. See bin/sweeper for options details.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/sweeper.rb', line 38

def initialize(options = {})
  @dir = File.expand_path(options['dir'] || Dir.pwd)    

  if RUBY_PLATFORM =~ /win32/
    @dir = @dir[2..-1] # Strip drive letter
    @null = "nul"
  else
    @null = "/dev/null"
  end
        
  @options = options
  @errf = Tempfile.new("stderr")
  @match_cache = {}    
end

Instance Attribute Details

#optionsObject (readonly)

Returns the value of attribute options.



35
36
37
# File 'lib/sweeper.rb', line 35

def options
  @options
end

Instance Method Details

#binaryObject

Returns the path to the fingerprinter binary for this platform.



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/sweeper.rb', line 249

def binary
  here = "#{File.expand_path(File.dirname(__FILE__))}/../vendor"
  @binary ||= case RUBY_PLATFORM
      when /win32/
        if defined?(RUBYSCRIPT2EXE)
          e = RUBYSCRIPT2EXE            
          p [e.tempdir, e.userdir, e.exedir, e.appdir] if ENV['DEBUG']
          "#{e.appdir}/../bin/lastfmfpclient.exe"
        else
          "#{here}/lastfm.fpclient.beta2.win32/lastfmfpclient.exe"
        end
      when /darwin/
        "#{here}/lastfm.fpclient.beta2.OSX-intel/lastfmfpclient"
      else 
        "#{here}/lastfm.fpclient.beta2.linux-32/lastfmfpclient"
      end
end

#load(filename) ⇒ Object

Loads metadata for an mp3 file. Looks for which ID3 version is already populated, instead of just the existence of frames.



268
269
270
# File 'lib/sweeper.rb', line 268

def load(filename) 
  ID3Lib::Tag.new(filename, ID3Lib::V_ALL)
end

#lookup(filename, tags = {}) ⇒ Object

Lookup all available remote metadata for an mp3 file. Accepts a pathname and an optional hash of existing tags. Returns a tag hash.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/sweeper.rb', line 133

def lookup(filename, tags = {})
  tags = tags.dup
  updated = {}

  # Are there any empty basic tags we need to lookup?
  if options['force'] or 
    (BASIC_KEYS - tags.keys).any?
    updated.merge!(lookup_basic(filename))
  end

  # Are there any empty genre tags we need to lookup?
  if options['genre'] and 
    (options['force'] or options['genre'] == 'force' or (GENRE_KEYS - tags.keys).any?)
    updated.merge!(lookup_genre(updated.merge(tags)))
  end

  if options['force']
    # Force all remote tags.
    tags.merge!(updated)      
  elsif options['genre'] == 'force'
    # Force remote genre tags only.
    tags.merge!(updated.slice(*GENRE_KEYS))
  end

  # Merge back in existing tags.
  updated.merge(tags)    
end

#lookup_basic(filename) ⇒ Object

Lookup the basic metadata for an mp3 file. Accepts a pathname. Returns a tag hash.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/sweeper.rb', line 162

def lookup_basic(filename)
  Dir.chdir File.dirname(binary) do
    cmd = "#{binary} #{filename.inspect} 2> #{@null}"
    p cmd if ENV['DEBUG']
    response = `#{cmd}`
    object = begin
      XSD::Mapping.xml2obj(response)
    rescue Object => e
      raise Problem, "#{e.class.name} - #{e.message}"
    end              
    raise Problem, "Fingerprint failed" unless object
    
    tags = {}
    song = Array(object.track).first      
    
    BASIC_KEYS.each do |key|
      tags[key] = song.send(key) if song.respond_to? key 
    end
    tags
  end
end

#lookup_genre(tags) ⇒ Object

Lookup the genre metadata for a set of basic metadata. Accepts a tag hash. Returns a genre tag hash.



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/sweeper.rb', line 185

def lookup_genre(tags)
  return DEFAULT_GENRE if tags['artist'].blank?
  
  response = begin 
    open("http://ws.audioscrobbler.com/1.0/artist/#{URI.encode(tags['artist'])}/toptags.xml").read
  rescue Object => e
    puts "Open-URI error: #{e.class.name} - #{e.message}" if ENV['DEBUG']
    return DEFAULT_GENRE
  end
  
  begin
    object = XSD::Mapping.xml2obj(response)
  rescue Object => e
    puts "XSD error: #{e.class.name} - #{e.message}" if ENV['DEBUG']
    return DEFAULT_GENRE
  end    
   
  return DEFAULT_GENRE if !object.respond_to? :tag

  genres = Array(object.tag)[0..(GENRE_COUNT - 1)].map(&:name)
  return DEFAULT_GENRE if !genres.any?
  
  primary = nil
  genres.each_with_index do |this, index|
    match, weight = nearest_genre(this)
    # Bias slightly towards higher tagging counts
    weight += ((GENRE_COUNT - index) / GENRE_COUNT / 4.0)

    if ['Rock', 'Pop', 'Rap'].include? match
      # Penalize useless genres
      weight = weight / 3.0
    end
          
    p [weight, match] if ENV['DEBUG']
    
    if !primary or primary.first < weight
      primary = [weight, match]
    end
  end
  
  {'genre' => primary.last, 'comment' => genres.join(", ")}
end

#nearest_genre(string) ⇒ Object



272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/sweeper.rb', line 272

def nearest_genre(string)
  @match_cache[string] ||= begin
    results = {}
    GENRES.each do |genre|
      results[Text::Levenshtein.distance(genre, string)] = genre
    end    
    min = results.keys.min
    match = results[min]
    
    [match, normalize(match, string, min)]
  end    
end

#normalize(genre, string, weight) ⇒ Object



285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/sweeper.rb', line 285

def normalize(genre, string, weight)
  # XXX Algorithm may not be right
  if weight == 0
    1.0
  elsif weight >= genre.size
    0.0
  elsif genre.size >= string.size
    1.0 - (weight / genre.size.to_f)
  else
    1.0 - (weight / string.size.to_f)
  end    
end

#read(filename) ⇒ Object

Read tags from an mp3 file. Returns a tag hash.



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/sweeper.rb', line 115

def read(filename)
  tags = {}
  song = load(filename)
  
  (BASIC_KEYS + GENRE_KEYS).each do |key|      
    tags[key] = song.send(key) if !song.send(key).blank?
  end
  
  # Change numeric genres into TCON strings
  # XXX Might not work well
  if tags['genre'] =~ /(\d+)/
    tags['genre'] = GENRES[$1.to_i]
  end
  
  tags
end

#recurse(dir) ⇒ Object

Recurse one directory, reading, looking up, and writing each file, if appropriate. Accepts a directory path.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/sweeper.rb', line 78

def recurse(dir)
  # Hackishly avoid problems with metacharacters in the Dir[] string.
  dir = dir.gsub(/[^\s\w\.\/\\\-]/, '?')
  p dir if ENV['DEBUG']
  
  Dir["#{dir}/*"].each do |filename|
    if File.directory? filename and options['recursive']
      recurse(filename)
    elsif File.extname(filename) =~ /\.mp3$/i
      @read += 1
      tries = 0
      begin
        current = read(filename)  
        updated = lookup(filename, current)
        
        if ENV['DEBUG']
          p current, updated
        end

        if updated != current 
          # Don't bother updating identical metadata.
          write(filename, updated)
          @updated += 1
        else
          puts "Unchanged: #{File.basename(filename)}"
        end
        
      rescue Problem => e          
        tries += 1 and retry if tries < 2
        puts "Skipped (#{e.message.gsub("\n", " ")}): #{File.basename(filename)}"
        @failed += 1
      end
    end
  end  
end

#runObject

Run the Sweeper according to the options.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/sweeper.rb', line 54

def run      
  @read = 0
  @updated = 0
  @failed = 0

  Kernel.at_exit do
    if @read == 0
      puts "No mp3 files found. Maybe you meant --recursive?"
    else
      puts "Read: #{@read}\nUpdated: #{@updated}\nFailed: #{@failed}"
    end
  end      

  begin
    recurse(@dir)
  rescue Object => e
    puts "Unknown error: #{e.inspect}"
    ENV['DEBUG'] ? raise : exit
  end
end

#write(filename, tags) ⇒ Object

Write tags to an mp3 file. Accepts a pathname and a tag hash.



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/sweeper.rb', line 229

def write(filename, tags)
  return if tags.empty?
  puts "Updated: #{File.basename(filename)}"
  
  song = load(filename)
  
  tags.each do |key, value|
    song.send("#{key}=", value)
    puts "  #{key.capitalize}: #{value}"
  end
  ALBUM_KEYS.each do |key|
    puts "  #{key.capitalize}: #{song.send(key)}"
  end
  
  unless options['dry-run']
    song.update!(ID3Lib::V2) 
  end
end