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.



36
37
38
39
40
41
# File 'lib/sweeper.rb', line 36

def initialize(options = {})
  @dir = File.expand_path(options['dir'] || Dir.pwd)
  @options = options
  @outf = Tempfile.new("stdout")
  @errf = Tempfile.new("stderr")    
end

Instance Attribute Details

#optionsObject (readonly)

Returns the value of attribute options.



33
34
35
# File 'lib/sweeper.rb', line 33

def options
  @options
end

Instance Method Details

#binaryObject

Returns the path to the fingerprinter binary for this platform.



234
235
236
237
238
239
240
241
242
243
244
# File 'lib/sweeper.rb', line 234

def binary
  @binary ||= "#{File.dirname(__FILE__)}/../vendor/" + 
    case RUBY_PLATFORM
      when /darwin/
        "lastfm.fpclient.beta2.OSX-intel/lastfmfpclient"
      when /win32/
        "lastfm.fpclient.beta2.win32/lastfmfpclient.exe"
      else 
        "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.



247
248
249
# File 'lib/sweeper.rb', line 247

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.



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/sweeper.rb', line 123

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.



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/sweeper.rb', line 152

def lookup_basic(filename)
  Dir.chdir File.dirname(binary) do
    response = `./#{File.basename(binary)} #{filename.inspect} 2> #{@errf.path}`
    object = begin
      XSD::Mapping.xml2obj(response)
    rescue REXML::ParseException
      raise Problem, "Server sent invalid response"
    end              
    raise Problem, "Fingerprint failed or not found" 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.



173
174
175
176
177
178
179
180
181
182
183
184
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
# File 'lib/sweeper.rb', line 173

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 OpenURI::HTTPError, URI::InvalidURIError
    return DEFAULT_GENRE
  end
  
  object = XSD::Mapping.xml2obj(response)
  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_results = Amatch::Levenshtein.new(this).similar(GENRES)
    # Get the levenshtein best-match weight
    max = match_results.max
    # Reverse lookup the canonical genre
    match = GENRES[match_results.index(max)]
    # Bias slightly towards higher tagging counts
    max += ((GENRE_COUNT - index) / GENRE_COUNT / 4.0)

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

#read(filename) ⇒ Object

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



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/sweeper.rb', line 105

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.



69
70
71
72
73
74
75
76
77
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
# File 'lib/sweeper.rb', line 69

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}): #{File.basename(filename)}"
        @failed += 1
      end
    end
  end  
end

#runObject

Run the Sweeper according to the options.



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/sweeper.rb', line 44

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

  Kernel.at_exit do
    if @read == 0
      puts "No mp3 files found. Maybe you meant --recursive?"
      exec "#{$0} --help"
    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.



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/sweeper.rb', line 214

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