ogg_album_tagger 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -2,8 +2,8 @@ module OggAlbumTagger
2
2
 
3
3
  class Error < ::StandardError; end
4
4
 
5
- class SystemError < Error; end
6
- class ArgumentError < Error; end
7
- class MetadataError < Error; end
5
+ class SystemError < OggAlbumTagger::Error; end
6
+ class ArgumentError < OggAlbumTagger::Error; end
7
+ class MetadataError < OggAlbumTagger::Error; end
8
8
 
9
9
  end
@@ -3,9 +3,6 @@ require 'ogg_album_tagger/tag_container'
3
3
  require 'ogg_album_tagger/exceptions'
4
4
 
5
5
  require 'set'
6
- require 'shellwords'
7
- require 'pathname'
8
- require 'open3'
9
6
  require 'fileutils'
10
7
 
11
8
  module OggAlbumTagger
@@ -13,38 +10,32 @@ module OggAlbumTagger
13
10
  # A Library is just a hash associating each ogg file to a TagContainer.
14
11
  # A subset of file can be selected in order to be tagged.
15
12
  class Library
13
+ attr_reader :path
16
14
  attr_reader :selected_files
17
15
 
18
- # Build the library by parsing specified ogg file.
19
- # In order to consider the library as a single album, you have to separately provide
20
- # the absolute path to the album and relative paths to the ogg files.
21
- # Otherwise, use absolute paths.
16
+ # Build a Library from a list of TagContainer.
22
17
  #
23
- # Paths must be provided as Pathnames.
24
- #
25
- # A OggAlbumTagger::SystemError will be raised if vorbiscomment cannot be invoked.
26
- # A OggAlbumTagger::ArgumentError will be raised if one of the files is not a valid ogg file.
27
- def initialize files, dir = nil
18
+ # dir:: The name of the directory supposed to contain all the files. Pass any name if the
19
+ # tracks of that library are related, +nil+ otherwise.
20
+ # containers:: A hash mapping the files to the containers.
21
+ def initialize dir, tracks
28
22
  @path = dir
29
- @files = {}
30
23
 
31
- files.each do |f|
32
- @files[f] = TagContainer.new(fullpath(f))
33
- end
24
+ @files = tracks.map { |e| e }
34
25
 
35
- @selected_files = Set.new @files.keys
26
+ @selected_files = @files.slice(0, @files.size).to_set
36
27
  end
37
28
 
38
- # Return the full path to the file.
39
- def fullpath(file)
40
- @path.nil? ? file : @path + file
29
+ # Return the number of files in this library.
30
+ def size
31
+ @files.size
41
32
  end
42
33
 
43
34
  # Returns the list of the tags used in the selected files.
44
35
  def tags_used
45
36
  s = Set.new
46
37
  @selected_files.each do |file|
47
- s.merge @files[file].tags
38
+ s.merge file.tags
48
39
  end
49
40
  s.to_a.map { |v| v.downcase }
50
41
  end
@@ -67,14 +58,14 @@ class Library
67
58
  def summary(selected_tag = nil)
68
59
  data = Hash.new { |h, k| h[k] = Hash.new }
69
60
 
70
- positions = Hash[@files.keys.sort.each_with_index.to_a]
61
+ @files.each_with_index { |file, i|
62
+ next unless @selected_files.include? file
71
63
 
72
- @selected_files.each do |file|
73
- @files[file].each do |tag, values|
64
+ file.each do |tag, values|
74
65
  next unless selected_tag.nil? or tag.eql?(selected_tag)
75
- data[tag][positions[file]] = values.sort
66
+ data[tag][i] = values.sort
76
67
  end
77
- end
68
+ }
78
69
 
79
70
  data
80
71
  end
@@ -93,7 +84,7 @@ class Library
93
84
  # Write the tags to the files.
94
85
  def write
95
86
  @selected_files.each do |file|
96
- @files[file].write(fullpath(file))
87
+ file.write(file.path)
97
88
  end
98
89
  end
99
90
 
@@ -102,13 +93,15 @@ class Library
102
93
  # Any previous value will be removed.
103
94
  def set_tag(tag, *values)
104
95
  tag.upcase!
105
- @selected_files.each { |file| @files[file].set_values(tag, *values) }
96
+ @selected_files.each { |file| file.set_values(tag, *values) }
97
+ self
106
98
  end
107
99
 
108
100
  # Tags the selected files with the specified values.
109
101
  def add_tag(tag, *values)
110
102
  tag.upcase!
111
- @selected_files.each { |file| @files[file].add_values(tag, *values) }
103
+ @selected_files.each { |file| file.add_values(tag, *values) }
104
+ self
112
105
  end
113
106
 
114
107
  # Remove the specified values from the selected files.
@@ -116,45 +109,47 @@ class Library
116
109
  # If no value is specified, the tag will be removed.
117
110
  def rm_tag(tag, *values)
118
111
  tag.upcase!
119
- @selected_files.each { |file| @files[file].rm_values(tag, *values) }
112
+ @selected_files.each { |file| file.rm_values(tag, *values) }
113
+ self
120
114
  end
121
115
 
122
116
  # Return a list of the files in the library.
123
117
  def ls
124
- @files.keys.sort.each_with_index.map do |file, i|
125
- { file: file, position: i+1, selected: @selected_files.include?(file) }
118
+ @files.each_with_index.map do |file, i|
119
+ { file: (@path.nil? ? file.path : file.path.relative_path_from(@path)).to_s, selected: @selected_files.include?(file) }
126
120
  end
127
121
  end
128
122
 
129
- # Modify the list of selected files.
123
+ # Build a Set representing the selected files specified by the selectors.
130
124
  #
131
125
  # The available selector are:
132
126
  # * "all": all files.
133
127
  # * "3": the third file.
134
- # * "5-7" the files 5, 6 and 7.
128
+ # * "5-7": the files 5, 6 and 7.
135
129
  #
136
130
  # The two last selector can be prefixed by "+" or "-" in order to add or remove items
137
131
  # from the current selection. They are called cumulative selectors.
138
132
  #
139
- # You can specify several selectors, but non-cumulative selectors cannot be specified after a cumulative one.
140
- def select(args)
141
- all_files = @files.keys.sort
133
+ # Non-cumulative selectors cannot be specified after a cumulative one.
134
+ def build_selection(selectors)
135
+ return @selected_files if selectors.empty?
136
+
142
137
  mode = :absolute
143
138
 
144
- first_rel = !!(args.first =~ /^[+-]/)
139
+ first_rel = !!(selectors.first =~ /^[+-]/)
145
140
 
146
141
  sel = first_rel ? Set.new(@selected_files) : Set.new
147
142
 
148
- args.each do |selector|
143
+ selectors.each do |selector|
149
144
  case selector
150
145
  when 'all'
151
146
  raise OggAlbumTagger::ArgumentError, "Cannot use the \"#{selector}\" selector after a cumulative selector (+/-...)" if mode == :cumulative
152
- sel.replace all_files
147
+ sel.replace @files
153
148
  when /^([+-]?)([1-9]\d*)$/
154
149
  i = $2.to_i - 1
155
- raise OggAlbumTagger::ArgumentError, "Item #{$2} is out of range" if i >= all_files.length
150
+ raise OggAlbumTagger::ArgumentError, "Item #{$2} is out of range" if i >= @files.size
156
151
 
157
- items = [all_files.slice(i)]
152
+ items = [@files.slice(i)]
158
153
  case $1
159
154
  when '-'
160
155
  sel.subtract items
@@ -169,9 +164,9 @@ class Library
169
164
  when /^([+-]?)(?:([1-9]\d*)-([1-9]\d*))$/
170
165
  i = $2.to_i - 1
171
166
  j = $3.to_i - 1
172
- raise OggAlbumTagger::ArgumentError, "Range #{$2}-#{$3} is invalid" if i >= all_files.length or j >= all_files.length or i > j
167
+ raise OggAlbumTagger::ArgumentError, "Range #{$2}-#{$3} is invalid" if i >= @files.size or j >= @files.size or i > j
173
168
 
174
- items = all_files.slice(i..j)
169
+ items = @files.slice(i..j)
175
170
  case $1
176
171
  when '-'
177
172
  sel.subtract items
@@ -188,25 +183,57 @@ class Library
188
183
  end
189
184
  end
190
185
 
191
- @selected_files.replace sel
186
+ return sel
187
+ end
188
+
189
+ # Modify the list of selected files.
190
+ def select(args)
191
+ @selected_files.replace(build_selection(args))
192
+
193
+ return self
194
+ end
195
+
196
+ def with_selection(selectors)
197
+ begin
198
+ previous_selection = Set.new(@selected_files)
199
+ @selected_files = build_selection(selectors)
200
+ yield
201
+ ensure
202
+ @selected_files = previous_selection
203
+ end
204
+ end
205
+
206
+ def move(from, to)
207
+ raise ::IndexError, "Invalid from index #{from}" unless (0...@files.size).include?(from)
208
+ raise ::IndexError, "Invalid to index #{to}" unless (0..@files.size).include?(to)
209
+
210
+ # Moving item N before item N does nothing
211
+ # Just like moving item N before item N+1
212
+ return if to == from or to == from + 1
213
+
214
+ item = @files.delete_at(from)
215
+ @files.insert(from < to ? to - 1 : to, item)
192
216
  end
193
217
 
194
218
  # Automatically set the TRACKNUMBER tag of the selected files based on their position in the selection.
195
219
  def auto_tracknumber
196
- @selected_files.sort.each_with_index do |file, i|
197
- @files[file].set_values('TRACKNUMBER', (i+1).to_s)
198
- end
220
+ i = 0
221
+ @files.each { |file|
222
+ next unless @selected_files.include? file
223
+ file.set_values('TRACKNUMBER', (i+1).to_s)
224
+ i += 1
225
+ }
199
226
  end
200
227
 
201
228
  # Test if a tag satisfy a predicate on each selected files.
202
229
  def validate_tag(tag)
203
- values = @selected_files.map { |file| @files[file][tag] }
230
+ values = @selected_files.map { |file| file[tag] }
204
231
  values.reduce(true) { |r, v| r && yield(v) }
205
232
  end
206
233
 
207
234
  # Test if a tag is used at least one time in an ogg file.
208
235
  def tag_used?(tag)
209
- values = @selected_files.map { |file| @files[file][tag] }
236
+ values = @selected_files.map { |file| file[tag] }
210
237
  values.reduce(false) { |r, v| r || v.size > 0 }
211
238
  end
212
239
 
@@ -220,10 +247,10 @@ class Library
220
247
  self.tag_used_k_times?(tag, 1)
221
248
  end
222
249
 
223
- # Test if a tag has multiple values in a single file.
250
+ # Test if at least one of the files has multiple values for the specified tag..
224
251
  def tag_used_multiple_times?(tag)
225
- values = @selected_files.map { |file| @files[file][tag] }
226
- values.reduce(false) { |r, v| r || v.size > 1 }
252
+ values = @selected_files.map { |file| file[tag] }
253
+ values.reduce(false) { |r, v| r || (v.size > 1) }
227
254
  end
228
255
 
229
256
  # Test if a tag is absent from each selected files.
@@ -238,7 +265,7 @@ class Library
238
265
 
239
266
  # Test if a tag has a single value and is uniq across all selected files.
240
267
  def uniq_tag?(tag)
241
- values = @selected_files.map { |file| @files[file][tag] }
268
+ values = @selected_files.map { |file| file[tag] }
242
269
  values.reduce(true) { |r, v| r && (v.size == 1) } && (values.map { |v| v.first }.uniq.length == 1)
243
270
  end
244
271
 
@@ -247,6 +274,7 @@ class Library
247
274
  validate_tag(tag) { |v| (v.size == 0) || (v.first.to_s =~ /^[1-9][0-9]*$/) }
248
275
  end
249
276
 
277
+ # TODO ISO 8601 compliance (http://www.cl.cam.ac.uk/~mgk25/iso-time.html)
250
278
  def date_tag?(tag)
251
279
  validate_tag(tag) { |v| (v.size == 0) || (v.first.to_s =~ /^\d\d\d\d$/) }
252
280
  end
@@ -262,8 +290,9 @@ class Library
262
290
  # * DISCNUMBER must be used at most one time per file.
263
291
  # * TRACKNUMBER and DISCNUMBER must have numerical values.
264
292
  def check
293
+ # Catch all the tags that cannot have multiple values.
265
294
  %w{ARTIST TITLE DATE ALBUM ALBUMDATE ARTISTALBUM TRACKNUMBER DISCNUMBER}.each do |t|
266
- raise OggAlbumTagger::MetadataError, "The #{t} tag cannot be used multiple times in a single track." if tag_used_multiple_times?(t)
295
+ raise OggAlbumTagger::MetadataError, "The #{t} tag must not appear more than once per track." if tag_used_multiple_times?(t)
267
296
  end
268
297
 
269
298
  %w{DISCNUMBER TRACKNUMBER}.each do |t|
@@ -282,17 +311,25 @@ class Library
282
311
 
283
312
  return if @path.nil?
284
313
 
285
- raise OggAlbumTagger::MetadataError, "The ALBUM tag must have a single and uniq value among all songs." unless uniq_tag?('ALBUM')
314
+ raise OggAlbumTagger::MetadataError, "The ALBUM tag must have a single and unique value among all songs." unless uniq_tag?('ALBUM')
315
+
316
+ unless uniq_tag?('DATE')
317
+ raise OggAlbumTagger::MetadataError, "The ALBUMDATE tag must have a single and uniq value among all songs." unless uniq_tag?('ALBUMDATE')
318
+ end
319
+
320
+ if @selected_files.size == 1
321
+ raise OggAlbumTagger::MetadataError, 'This album has only one track. The consistency of some tags cannot be verified.'
322
+ end
286
323
 
287
324
  if uniq_tag?('ARTIST')
288
- raise OggAlbumTagger::MetadataError, 'The ALBUMARTIST is only required for compilations.' if tag_used?('ALBUMARTIST')
325
+ if tag_used?('ALBUMARTIST')
326
+ raise OggAlbumTagger::MetadataError, 'The ALBUMARTIST is not required since all tracks have the same and unique ARTIST.'
327
+ end
289
328
  else
290
329
  if not uniq_tag?('ALBUMARTIST') or (first_value('ALBUMARTIST') != 'Various artists')
291
330
  raise OggAlbumTagger::MetadataError, 'This album seems to be a compilation. The ALBUMARTIST tag should have the value "Various artists".'
292
331
  end
293
332
  end
294
-
295
- raise OggAlbumTagger::MetadataError, "The ALBUMDATE tag must have a single and uniq value among all songs." if not uniq_tag?('DATE') and not uniq_tag?('ALBUMDATE')
296
333
  end
297
334
 
298
335
  # Auto rename the directory and the ogg files of the library.
@@ -314,15 +351,15 @@ class Library
314
351
  # Ogg file: ALBUM - ALBUMDATE - [DISCNUMBER.]TRACKNUMBER - ARTIST - TITLE - DATE
315
352
  #
316
353
  # Disc and track numbers are padded with zeros.
317
- def auto_rename
354
+
355
+ def compute_rename_mapping
318
356
  check()
319
357
 
320
358
  mapping = {}
321
359
 
322
360
  if @path.nil?
323
361
  @selected_files.each do |file|
324
- tags = @files[file]
325
- mapping[file] = sprintf('%s - %s - %s.ogg', tags.first('ARTIST'), tags.first('DATE'), tags.first('TITLE'))
362
+ mapping[file] = sprintf('%s - %s - %s.ogg', file.first('ARTIST'), file.first('DATE'), file.first('TITLE'))
326
363
  end
327
364
  else
328
365
  tn_maxlength = tag_summary('TRACKNUMBER').values.map { |v| v.first.to_s.length }.max
@@ -346,15 +383,13 @@ class Library
346
383
 
347
384
  if uniq_tag?('ARTIST')
348
385
  @selected_files.each do |file|
349
- tags = @files[file]
350
-
351
- common_tags = [tags.first('ARTIST'), album_date, tags.first('ALBUM'),
352
- format_number.call(tags), tags.first('TITLE')]
386
+ common_tags = [file.first('ARTIST'), album_date, file.first('ALBUM'),
387
+ format_number.call(file), file.first('TITLE')]
353
388
 
354
389
  mapping[file] = if uniq_tag?('DATE')
355
390
  sprintf('%s - %s - %s - %s - %s.ogg', *common_tags)
356
391
  else
357
- sprintf('%s - %s - %s - %s - %s - %s.ogg', *common_tags, tags.first('DATE'))
392
+ sprintf('%s - %s - %s - %s - %s - %s.ogg', *common_tags, file.first('DATE'))
358
393
  end
359
394
  end
360
395
 
@@ -364,10 +399,9 @@ class Library
364
399
  first_value('ALBUM'))
365
400
  else
366
401
  @selected_files.each do |file|
367
- tags = @files[file]
368
- mapping[file] = sprintf('%s - %s - %s - %s - %s.ogg',
369
- tags.first('ALBUM'), album_date, format_number.call(tags),
370
- tags.first('ARTIST'), tags.first('TITLE'), tags.first('DATE'))
402
+ mapping[file] = sprintf('%s - %s - %s - %s - %s - %s.ogg',
403
+ file.first('ALBUM'), album_date, format_number.call(file),
404
+ file.first('ARTIST'), file.first('TITLE'), file.first('DATE'))
371
405
  end
372
406
 
373
407
  albumdir = sprintf('%s - %s', first_value('ALBUM'), album_date)
@@ -380,39 +414,62 @@ class Library
380
414
  mapping.each { |k, v| mapping[k] = v.gsub(/[\\\/:*?"<>|]/, '') }
381
415
 
382
416
  if mapping.values.uniq.size != @selected_files.size
383
- raise OggAlbumTagger::MetadataError, 'Generated filenames are not uniq.'
417
+ raise OggAlbumTagger::MetadataError, 'Generated filenames are not unique.'
384
418
  end
385
419
 
386
- # Renaming the album directory
387
- unless @path.nil?
420
+ newpath = @path.nil? ? nil : (@path.dirname + albumdir)
421
+
422
+ return newpath, mapping
423
+ end
424
+
425
+ def short_path(file)
426
+ @path.nil? ? file : file.relative_path_from(@path)
427
+ end
428
+
429
+ def auto_rename
430
+ newpath, mapping = compute_rename_mapping
431
+
432
+ # Renaming the ogg files
433
+ Set.new(@selected_files).each do |file|
388
434
  begin
389
- newpath = @path.dirname + albumdir
390
- if @path.expand_path != newpath.expand_path
391
- FileUtils.mv(@path, newpath)
392
- @path = newpath
435
+ oldfilepath = file.path
436
+ newfilepath = (@path.nil? ? oldfilepath.dirname : @path) + mapping[file]
437
+
438
+ # Don't rename anything if there's no change.
439
+ if oldfilepath != newfilepath
440
+ rename(oldfilepath, newfilepath)
441
+ file.path = newfilepath
393
442
  end
394
- rescue Exception => ex
395
- raise OggAlbumTagger::SystemError, "Cannot rename \"#{@path}\" to \"#{newpath}\"."
443
+ rescue Exception
444
+ raise OggAlbumTagger::SystemError, "Cannot rename \"#{short_path(oldfilepath)}\" to \"#{short_path(newfilepath)}\"."
396
445
  end
397
446
  end
398
447
 
399
- # Renaming the ogg files
400
- Set.new(@selected_files).each do |file|
448
+ # Renaming the album directory
449
+ unless @path.nil?
450
+ oldpath = @path
451
+
401
452
  begin
402
- oldpath = fullpath(file)
403
- newpath = (@path.nil? ? file.dirname : @path) + mapping[file]
404
- newpath_rel = file.dirname + mapping[file]
405
-
406
- if oldpath != newpath
407
- FileUtils.mv(oldpath, newpath)
408
- @files[newpath_rel] = @files.delete(file)
409
- @selected_files.delete(file).add(newpath_rel)
453
+ # Don't rename anything if there's no change.
454
+ if @path != newpath
455
+ rename(@path, newpath)
456
+ @path = newpath
457
+
458
+ @files.each { |file|
459
+ newfilepath = newpath + file.path.relative_path_from(oldpath)
460
+
461
+ file.path = newfilepath
462
+ }
410
463
  end
411
- rescue Exception => ex
412
- raise OggAlbumTagger::SystemError, "Cannot rename \"#{file}\" to \"#{mapping[file]}\"."
464
+ rescue Exception
465
+ raise OggAlbumTagger::SystemError, "Cannot rename \"#{oldpath}\" to \"#{newpath}\"."
413
466
  end
414
467
  end
415
468
  end
469
+
470
+ def rename(oldpath, newpath)
471
+ FileUtils.mv(oldpath, newpath)
472
+ end
416
473
  end
417
474
 
418
475
  end