sweeper 0.2 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
data.tar.gz.sig CHANGED
Binary file
data/CHANGELOG CHANGED
@@ -1,4 +1,6 @@
1
1
 
2
+ v0.2.1. Better error handling.
3
+
2
4
  v0.2. Genre tagging; bugfixes.
3
5
 
4
6
  v0.1. First release.
data/README CHANGED
@@ -24,29 +24,57 @@ If you use this software, please {make a donation}[http://blog.evanweaver.com/do
24
24
  * id3lib (Linux and OS X only)
25
25
  * some mp3 files
26
26
 
27
- == Installation
27
+ == Usage
28
28
 
29
- Just install the gem:
29
+ First, install the gem:
30
30
  sudo gem install sweeper
31
31
 
32
- == Usage
33
-
34
- Change to the directory of mp3s you want to tag and run:
35
- sweeper
36
-
37
- That's all.
32
+ Now, change to the directory of mp3s you want to tag and run:
33
+ sweeper --genre --recursive
34
+
35
+ Your mp3s will be updated with missing artist, genre, and trackname information, and a list of all tagged genres will be added to the <tt>comment</tt> field, for use in smart playlists.
36
+
37
+ == Demo
38
+
39
+ Unknown song before sweeping:
40
+
41
+ $ id3info 1_001.mp3
42
+ *** Tag information for 1_001.mp3
43
+ *** mp3 info
44
+ MPEG1/layer III
45
+ Bitrate: 128KBps
46
+ Frequency: 44KHz
47
+
48
+ Same song after running <tt>sweeper ----genre</tt>:
49
+
50
+ $ id3info 1_001.mp3
51
+ *** Tag information for 1_001.mp3
52
+ === TPE1 (Lead performer(s)/Soloist(s)): Photon Band
53
+ === TIT2 (Title/songname/content description): To Sing For You
54
+ === WORS (Official internet radio station homepage): http://www.last.fm/music/Ph
55
+ oton+Band/_/To+Sing+For+You
56
+ === TCON (Content type): Psychadelic
57
+ === COMM (Comments): ()[]: rock, psychedelic, mod, Philly
58
+ *** mp3 info
59
+ MPEG1/layer III
60
+ Bitrate: 128KBps
61
+ Frequency: 44KHz
38
62
 
39
63
  == Options
40
64
 
41
65
  Sweeper takes a few command line options:
42
66
 
43
- -d, --dir Directory to search (defaults to current).
44
- -r, --recursive Recurse directories.
45
- --dry-run Do a dry run (no files will be changed).
46
- -f, --force Overwrite all existing tags.
47
- -g, --genre Add genre and genre comments.
48
- -e, --force-genre Add genre and genre comments, overwriting e
49
-
67
+ -d, --dir Directory to search (defaults to current).
68
+ -r, --recursive Recurse directories.
69
+ --dry-run Do a dry run (no files will be changed).
70
+ -f, --force Overwrite all existing tags.
71
+ -g, --genre=[force] Add genre tag and genre comments, optionally overwriting
72
+ existing ones.
73
+
74
+ == Notes
75
+
76
+ You will get cleaner tags if you convert your files to ID3v2 first. Sweeper reads both tag versions, but only outputs ID3v2.
77
+
50
78
  == Reporting problems
51
79
 
52
80
  The support forum is here[http://rubyforge.org/forum/forum.php?forum_id=23599].
data/TODO CHANGED
@@ -1,3 +1,2 @@
1
1
 
2
- * Add genres via http://ws.audioscrobbler.com/1.0/artist/ARTIST/toptags.xml service.
3
- * Some kind of album detection?
2
+ * Album detection via set clustering on http://ws.audioscrobbler.com/1.0/album/ARTIST/ALBUM/info.xml .
data/bin/sweeper CHANGED
@@ -32,15 +32,9 @@ Choice.options do
32
32
 
33
33
  option :genre do
34
34
  short '-g'
35
- long '--genre'
36
- desc "Add genre and genre comments."
37
- end
38
-
39
- option :"force-genre" do
40
- short '-e'
41
- long '--force-genre'
42
- desc "Add genre and genre comments, overwriting existing ones."
43
- end
35
+ long '--genre=[force]'
36
+ desc "Add genre tag and genre comments, optionally overwriting existing ones."
37
+ end
44
38
  end
45
39
 
46
40
  Sweeper.new(Choice.choices).run
data/lib/sweeper.rb CHANGED
@@ -25,18 +25,20 @@ class Sweeper
25
25
 
26
26
  BASIC_KEYS = ['artist', 'title', 'url']
27
27
  GENRE_KEYS = ['genre', 'comment']
28
- GENRES = ID3Lib::Info::Genres.sort
29
- GENRE_COUNT = 7
28
+ ALBUM_KEYS = ['album', 'track']
29
+ GENRES = ID3Lib::Info::Genres
30
+ GENRE_COUNT = 10
30
31
  DEFAULT_GENRE = {'genre' => 'Other', 'comment' => 'other'}
31
32
 
32
33
  attr_reader :options
33
34
 
35
+ # Instantiate a new Sweeper. See <tt>bin/sweeper</tt> for <tt>options</tt> details.
34
36
  def initialize(options = {})
35
- options['genre'] ||= options['force-genre']
36
37
  @dir = File.expand_path(options['dir'] || Dir.pwd)
37
38
  @options = options
38
39
  end
39
40
 
41
+ # Run the Sweeper according to the <tt>options</tt>.
40
42
  def run
41
43
  @read = 0
42
44
  @updated = 0
@@ -44,7 +46,7 @@ class Sweeper
44
46
 
45
47
  Kernel.at_exit do
46
48
  if @read == 0
47
- puts "No files found. Maybe you meant --recursive?"
49
+ puts "No mp3 files found. Maybe you meant --recursive?"
48
50
  exec "#{$0} --help"
49
51
  else
50
52
  puts "Read: #{@read}\nUpdated: #{@updated}\nFailed: #{@failed}"
@@ -61,75 +63,99 @@ class Sweeper
61
63
 
62
64
  #private
63
65
 
66
+ # Recurse one directory, reading, looking up, and writing each file, if appropriate. Accepts a directory path.
64
67
  def recurse(dir)
68
+ # Hackishly avoid problems with metacharacters in the Dir[] string.
69
+ dir = dir.gsub(/[^\s\w\.\/\\\-]/, '?')
70
+ # p dir if ENV['DEBUG']
65
71
  Dir["#{dir}/*"].each do |filename|
66
72
  if File.directory? filename and options['recursive']
67
73
  recurse(filename)
68
- elsif File.extname(filename) == ".mp3"
74
+ elsif File.extname(filename) =~ /\.mp3$/i
69
75
  @read += 1
70
76
  tries = 0
71
77
  begin
72
78
  current = read(filename)
73
79
  updated = lookup(filename, current)
74
80
 
75
- if updated != current
81
+ if ENV['DEBUG']
82
+ p current, updated
83
+ end
84
+
85
+ if updated != current
86
+ # Don't bother updating identical metadata.
76
87
  write(filename, updated)
77
88
  @updated += 1
89
+ else
90
+ puts "Unchanged: #{File.basename(filename)}"
78
91
  end
79
92
 
80
93
  rescue Problem => e
81
94
  tries += 1 and retry if tries < 2
82
- puts "Skipped #{File.basename(filename)}: #{e.message}"
95
+ puts "Skipped (#{e.message}): #{File.basename(filename)}"
83
96
  @failed += 1
84
97
  end
85
98
  end
86
- end
99
+ end
87
100
  end
88
101
 
102
+ # Read tags from an mp3 file. Returns a tag hash.
89
103
  def read(filename)
90
104
  tags = {}
91
-
92
- song = ID3Lib::Tag.new(filename, ID3Lib::V2)
93
- if song.empty?
94
- song = ID3Lib::Tag.new(filename, ID3Lib::V1)
95
- end
105
+ song = load(filename)
96
106
 
97
107
  (BASIC_KEYS + GENRE_KEYS).each do |key|
98
108
  tags[key] = song.send(key) if !song.send(key).blank?
99
109
  end
100
110
 
111
+ # Change numeric genres into TCON strings
112
+ # XXX Might not work well
113
+ if tags['genre'] =~ /(\d+)/
114
+ tags['genre'] = GENRES[$1.to_i]
115
+ end
116
+
101
117
  tags
102
118
  end
103
119
 
120
+ # Lookup all available remote metadata for an mp3 file. Accepts a pathname and an optional hash of existing tags. Returns a tag hash.
104
121
  def lookup(filename, tags = {})
122
+ tags = tags.dup
105
123
  updated = {}
124
+
125
+ # Are there any empty basic tags we need to lookup?
106
126
  if options['force'] or
107
127
  (BASIC_KEYS - tags.keys).any?
108
128
  updated.merge!(lookup_basic(filename))
109
129
  end
130
+
131
+ # Are there any empty genre tags we need to lookup?
110
132
  if options['genre'] and
111
- (options['force'] or options['force-genre'] or (GENRE_KEYS - tags.keys).any?)
133
+ (options['force'] or options['genre'] == 'force' or (GENRE_KEYS - tags.keys).any?)
112
134
  updated.merge!(lookup_genre(updated.merge(tags)))
113
135
  end
114
136
 
115
137
  if options['force']
138
+ # Force all remote tags.
116
139
  tags.merge!(updated)
117
- elsif options['force-genre']
118
- tags.merge!(updated.slice('genre', 'comment'))
140
+ elsif options['genre'] == 'force'
141
+ # Force remote genre tags only.
142
+ tags.merge!(updated.slice(*GENRE_KEYS))
119
143
  end
120
144
 
145
+ # Merge back in existing tags.
121
146
  updated.merge(tags)
122
147
  end
123
148
 
149
+ # Lookup the basic metadata for an mp3 file. Accepts a pathname. Returns a tag hash.
124
150
  def lookup_basic(filename)
125
151
  Dir.chdir File.dirname(binary) do
126
152
  response = silence { `./#{File.basename(binary)} #{filename.inspect}` }
127
153
  object = begin
128
154
  XSD::Mapping.xml2obj(response)
129
155
  rescue REXML::ParseException
130
- raise Problem, "Server sent invalid response."
156
+ raise Problem, "Server sent invalid response"
131
157
  end
132
- raise Problem, "Fingerprint failed or not found." unless object
158
+ raise Problem, "Fingerprint failed or not found" unless object
133
159
 
134
160
  tags = {}
135
161
  song = Array(object.track).first
@@ -140,7 +166,8 @@ class Sweeper
140
166
  tags
141
167
  end
142
168
  end
143
-
169
+
170
+ # Lookup the genre metadata for a set of basic metadata. Accepts a tag hash. Returns a genre tag hash.
144
171
  def lookup_genre(tags)
145
172
  return DEFAULT_GENRE if tags['artist'].blank?
146
173
 
@@ -157,36 +184,51 @@ class Sweeper
157
184
  return DEFAULT_GENRE if !genres.any?
158
185
 
159
186
  primary = nil
160
- genres.each do |this|
187
+ genres.each_with_index do |this, index|
161
188
  match_results = Amatch::Levenshtein.new(this).similar(GENRES)
189
+ # Get the levenshtein best-match weight
162
190
  max = match_results.max
191
+ # Reverse lookup the canonical genre
163
192
  match = GENRES[match_results.index(max)]
193
+ # Bias slightly towards higher tagging counts
194
+ max += ((GENRE_COUNT - index) / GENRE_COUNT / 4.0)
164
195
 
165
196
  if ['Rock', 'Pop', 'Rap'].include? match
166
197
  # Penalize useless genres
167
198
  max = max / 3.0
168
199
  end
200
+
201
+ p [max, match] if ENV['DEBUG']
169
202
 
170
203
  if !primary or primary.first < max
171
204
  primary = [max, match]
172
205
  end
173
206
  end
174
207
 
175
- {'genre' => primary.last, 'comment' => genres.join(" ")}
208
+ {'genre' => primary.last, 'comment' => genres.join(", ")}
176
209
  end
177
210
 
211
+ # Write tags to an mp3 file. Accepts a pathname and a tag hash.
178
212
  def write(filename, tags)
179
213
  return if tags.empty?
180
- puts File.basename(filename)
214
+ puts "Updated: #{File.basename(filename)}"
215
+
216
+ song = load(filename)
181
217
 
182
- file = ID3Lib::Tag.new(filename, ID3Lib::V2)
183
218
  tags.each do |key, value|
184
- file.send("#{key}=", value)
219
+ song.send("#{key}=", value)
185
220
  puts " #{key.capitalize}: #{value}"
186
221
  end
187
- file.update! unless options['dry-run']
222
+ ALBUM_KEYS.each do |key|
223
+ puts " #{key.capitalize}: #{song.send(key)}"
224
+ end
225
+
226
+ unless options['dry-run']
227
+ song.update!(ID3Lib::V2)
228
+ end
188
229
  end
189
230
 
231
+ # Returns the path to the fingerprinter binary for this platform.
190
232
  def binary
191
233
  @binary ||= "#{File.dirname(__FILE__)}/../vendor/" +
192
234
  case RUBY_PLATFORM
@@ -199,6 +241,12 @@ class Sweeper
199
241
  end
200
242
  end
201
243
 
244
+ # Loads metadata for an mp3 file. Looks for which ID3 version is already populated, instead of just the existence of frames.
245
+ def load(filename)
246
+ ID3Lib::Tag.new(filename, ID3Lib::V_ALL)
247
+ end
248
+
249
+ # Silence STDERR and STDOUT for the duration of the block.
202
250
  def silence(outf = nil, errf = nil)
203
251
  # This method is annoying.
204
252
  outf, errf = Tempfile.new("stdout"), Tempfile.new("stderr")
data/sweeper.gemspec CHANGED
@@ -1,10 +1,10 @@
1
1
 
2
- # Gem::Specification for Sweeper-0.2
2
+ # Gem::Specification for Sweeper-0.2.1
3
3
  # Originally generated by Echoe
4
4
 
5
5
  Gem::Specification.new do |s|
6
6
  s.name = %q{sweeper}
7
- s.version = "0.2"
7
+ s.version = "0.2.1"
8
8
 
9
9
  s.specification_version = 2 if s.respond_to? :specification_version=
10
10
 
@@ -32,7 +32,7 @@ class SweeperTest < Test::Unit::TestCase
32
32
 
33
33
  def test_lookup_genre
34
34
  assert_equal(
35
- {"genre"=>"Psychadelic", "comment"=>"rock psychedelic mod Philly"},
35
+ {"genre"=>"Psychadelic", "comment"=>"rock, psychedelic, mod, Philly"},
36
36
  @s.lookup_genre(@s.lookup_basic(@found_many))
37
37
  )
38
38
  assert_equal(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sweeper
3
3
  version: !ruby/object:Gem::Version
4
- version: "0.2"
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evan Weaver
metadata.gz.sig CHANGED
Binary file