apetag 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/apetag.rb +489 -0
- data/test/test_apetag.rb +422 -0
- metadata +45 -0
data/apetag.rb
ADDED
@@ -0,0 +1,489 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# This library implements a APEv2 parser/generator.
|
3
|
+
# If called from the command line, it prints out the contents of the APEv2 tag for the given filename arguments.
|
4
|
+
#
|
5
|
+
# ruby-apetag is a pure Ruby library for manipulating APEv2 (and ID3v1.1) tags.
|
6
|
+
# It aims for standards compliance with the APE spec (1). APEv2 is the standard
|
7
|
+
# tagging format for Musepack (.mpc) and Monkey's Audio files (.ape), and it can
|
8
|
+
# also be used with mp3s as an alternative to the mess that is ID3v2.x
|
9
|
+
# (technically, it can be used on any file type and is not limited to storing
|
10
|
+
# just audio file metadata).
|
11
|
+
#
|
12
|
+
# The module is in written in pure Ruby, so it should be useable on all
|
13
|
+
# platforms that Ruby supports. It has been tested on OpenBSD.
|
14
|
+
# The minimum python version required should be 1.8, but it has only been tested
|
15
|
+
# on 1.8.4. Modifying the code to work with previous version shouldn't be
|
16
|
+
# difficult, though there aren't any plans to do so.
|
17
|
+
#
|
18
|
+
# The library is complete, but it hasn't been used, so there may still be
|
19
|
+
# some bugs in it. I haven't found any bugs in my normal use of it that I
|
20
|
+
# haven't already fixed, though. RDoc documentation is available, as is
|
21
|
+
# Test::Unit based testing.
|
22
|
+
#
|
23
|
+
# General Use:
|
24
|
+
#
|
25
|
+
# require 'apetag'
|
26
|
+
# a = ApeTag.new('file.mp3')
|
27
|
+
# a.exists? # if it already has an APEv2 tag
|
28
|
+
# a.raw # the raw APEv2+ID3v1.1 tag already on the file
|
29
|
+
# a.fields # a hash of fields, keys are strings, values are list of strings
|
30
|
+
# a.pretty_print # string suitable for pretty printing
|
31
|
+
# a.update{|fields| fields['Artist']='Test Artist'; fields.delete('Year')}
|
32
|
+
# # Update the tag with the added/changed/deleted fields
|
33
|
+
# # Note that you should do: a.update{|fields| fields.replace('Test'=>'Test')}
|
34
|
+
# # and NOT: a.update{|fields| fields = {'Test'=>'Test'}}
|
35
|
+
# # You need to update/modify the fields given, not reassign it
|
36
|
+
# a.remove! # remove the APEv2 and ID3v1.1 tags.
|
37
|
+
#
|
38
|
+
# To run the tests for the library, run test_apetag.rb.
|
39
|
+
#
|
40
|
+
# If you find any bugs, would like additional documentation, or want to submit a
|
41
|
+
# patch, please use Rubyforge (http://rubyforge.org/projects/apetag/).
|
42
|
+
#
|
43
|
+
# The most current source code can be accessed via anonymous SVN at
|
44
|
+
# svn://suven.no-ip.org/ruby-apetag/. Note that the library isn't modified on a
|
45
|
+
# regular basis, so it is unlikely to be different from the latest release.
|
46
|
+
#
|
47
|
+
# (1) http://wiki.hydrogenaudio.org/index.php?title=APEv2_specification
|
48
|
+
#
|
49
|
+
# Copyright (c) 2007 Jeremy Evans
|
50
|
+
#
|
51
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
52
|
+
# of this software and associated documentation files (the "Software"), to deal
|
53
|
+
# in the Software without restriction, including without limitation the rights
|
54
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
55
|
+
# copies of the Software, and to permit persons to whom the Software is
|
56
|
+
# furnished to do so, subject to the following conditions:
|
57
|
+
#
|
58
|
+
# The above copyright notice and this permission notice shall be included in
|
59
|
+
# all copies or substantial portions of the Software.
|
60
|
+
#
|
61
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
62
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
63
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
64
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
65
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
66
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
67
|
+
# SOFTWARE.
|
68
|
+
|
69
|
+
require 'set'
|
70
|
+
|
71
|
+
# Error raised by the library
|
72
|
+
class ApeTagError < StandardError
|
73
|
+
end
|
74
|
+
|
75
|
+
# The individual items in the APE tag.
|
76
|
+
# Because all items can contain a list of values, this is a subclass of Array.
|
77
|
+
class ApeItem < Array
|
78
|
+
MIN_SIZE = 11 # 4+4+2+1 (length, flags, minimum key length, key-value separator)
|
79
|
+
BAD_KEYS = Set.new(%w'id3 tag oggs mp+')
|
80
|
+
BAD_KEY_RE = Regexp.new("[\0-\x1f\x80-\xff]")
|
81
|
+
ITEM_TYPES = %w'utf8 binary external reserved'
|
82
|
+
|
83
|
+
attr_reader :read_only, :ape_type, :key, :key_downcased
|
84
|
+
|
85
|
+
# Creates an APE tag with the appropriate key and value.
|
86
|
+
# If value is a valid ApeItem, just updates the key.
|
87
|
+
# If value is an Array, creates an ApeItem with the key and all of its values.
|
88
|
+
# Otherwise, creates an ApeItem with the key and the singular value.
|
89
|
+
# Raise ApeTagError if key or or value is invalid.
|
90
|
+
def self.create(key, value)
|
91
|
+
if value.is_a?(self) && value.valid?
|
92
|
+
value.key = key
|
93
|
+
return value
|
94
|
+
end
|
95
|
+
value = [value] unless value.is_a?(Array)
|
96
|
+
new(key, value)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Parse an ApeItem from the given data string starting at the provided offset.
|
100
|
+
# Check for validity and populate the object with the parsed data.
|
101
|
+
# Return the offset of the next item (or end of string).
|
102
|
+
# Raise ApeTagError if the parsed data is invalid.
|
103
|
+
def self.parse(data, offset)
|
104
|
+
length, flags = data[offset...(offset+8)].unpack('VN')
|
105
|
+
raise ApeTagError, "Invalid item length at offset #{offset}" if length + offset + MIN_SIZE > data.length
|
106
|
+
raise ApeTagError, "Invalid item flags at offset #{offset}" if flags > 7
|
107
|
+
key_end = data.index("\0", offset += 8)
|
108
|
+
raise ApeTagError, "Missing key-value separator at offset #{offset}" unless key_end
|
109
|
+
raise ApeTagError, "Invalid item length at offset #{offset}" if (next_item_start=length + key_end + 1) > data.length
|
110
|
+
item = ApeItem.new(data[offset...key_end], data[(key_end+1)...next_item_start].split("\0"))
|
111
|
+
item.read_only = flags & 1 > 0
|
112
|
+
item.ape_type = ITEM_TYPES[flags/2]
|
113
|
+
return [item, next_item_start]
|
114
|
+
end
|
115
|
+
|
116
|
+
# Set key and value.
|
117
|
+
# Set read_only to false and ape_type to utf8.
|
118
|
+
# Raise ApeTagError if key or value is invalid.
|
119
|
+
def initialize(key, value)
|
120
|
+
self.key = key
|
121
|
+
self.read_only = false
|
122
|
+
self.ape_type = ITEM_TYPES[0]
|
123
|
+
super(value)
|
124
|
+
raise ApeTagError, "Invalid item value encoding (non UTF-8)" unless valid_value?
|
125
|
+
end
|
126
|
+
|
127
|
+
# Set ape_type if valid, otherwise raise ApeTagError.
|
128
|
+
def ape_type=(type)
|
129
|
+
raise ApeTagError, "Invalid APE type" unless valid_ape_type?(type)
|
130
|
+
@ape_type=type
|
131
|
+
end
|
132
|
+
|
133
|
+
# Set key if valid, otherwise raise ApeTagError.
|
134
|
+
def key=(key)
|
135
|
+
raise ApeTagError, "Invalid APE key" unless valid_key?(key)
|
136
|
+
@key = key
|
137
|
+
@key_downcased = key.downcase
|
138
|
+
end
|
139
|
+
|
140
|
+
# The on disk representation of the entire ApeItem.
|
141
|
+
# Raise ApeTagError if ApeItem is invalid.
|
142
|
+
def raw
|
143
|
+
raise ApeTagError, "Invalid key, value, APE type, or Read-Only Flag" unless valid?
|
144
|
+
flags = ITEM_TYPES.index(ape_type) * 2 + (read_only ? 1 : 0)
|
145
|
+
sv = string_value
|
146
|
+
"#{[sv.length, flags].pack('VN')}#{key}\0#{sv}"
|
147
|
+
end
|
148
|
+
|
149
|
+
# Set read only flag if valid, otherwise raise ApeTagError.
|
150
|
+
def read_only=(flag)
|
151
|
+
raise ApeTagError, "Invalid Read-Only Flag" unless valid_read_only?(flag)
|
152
|
+
@read_only = flag
|
153
|
+
end
|
154
|
+
|
155
|
+
# The on disk representation of the ApeItem's values.
|
156
|
+
def string_value
|
157
|
+
join("\0")
|
158
|
+
end
|
159
|
+
|
160
|
+
# Check if current item is valid
|
161
|
+
def valid?
|
162
|
+
valid_ape_type?(ape_type) && valid_read_only?(read_only) && valid_key?(key) && valid_value?
|
163
|
+
end
|
164
|
+
|
165
|
+
# Check if given type is a valid APE type (a member of ApeItem::ITEM_TYPES).
|
166
|
+
def valid_ape_type?(type)
|
167
|
+
ITEM_TYPES.include?(type)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Check if the given key is a valid APE key (string, 2 <= length <= 255, not in ApeItem::BAD_KEYS, not containing invalid characters).
|
171
|
+
def valid_key?(key)
|
172
|
+
key.is_a?(String) && key.length >= 2 && key.length <= 255 && !BAD_KEYS.include?(key.downcase) && key !~ BAD_KEY_RE
|
173
|
+
end
|
174
|
+
|
175
|
+
# Check if the given read only flag is valid (boolean).
|
176
|
+
def valid_read_only?(flag)
|
177
|
+
[true, false].include?(flag)
|
178
|
+
end
|
179
|
+
|
180
|
+
# Check if the string value is valid UTF-8.
|
181
|
+
def valid_value?
|
182
|
+
begin
|
183
|
+
string_value.unpack('U*') if ape_type == 'utf8' || ape_type == 'external'
|
184
|
+
rescue ArgumentError
|
185
|
+
false
|
186
|
+
else
|
187
|
+
true
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Contains all of the ApeItems found in the filename/file given.
|
193
|
+
# MAX_SIZE and MAX_ITEM_COUNT constants are recommended defaults, they can be
|
194
|
+
# increased if necessary.
|
195
|
+
class ApeTag
|
196
|
+
MAX_SIZE = 8192
|
197
|
+
MAX_ITEM_COUNT = 64
|
198
|
+
HEADER_FLAGS = "\x00\x00\x00\xA0"
|
199
|
+
FOOTER_FLAGS = "\x00\x00\x00\x80"
|
200
|
+
PREAMBLE = "APETAGEX\xD0\x07\x00\x00"
|
201
|
+
RECOMMENDED_KEYS = %w'Title Artist Album Year Comment Genre Track Subtitle
|
202
|
+
Publisher Conductor Composer Copyright Publicationright File EAN/UPC ISBN
|
203
|
+
Catalog LC Media Index Related ISRC Abstract Language Bibliography
|
204
|
+
Introplay Dummy' << 'Debut Album' << 'Record Date' << 'Record Location'
|
205
|
+
ID3_GENRES = 'Blues, Classic Rock, Country, Dance, Disco, Funk, Grunge,
|
206
|
+
Hip-Hop, Jazz, Metal, New Age, Oldies, Other, Pop, R & B, Rap, Reggae,
|
207
|
+
Rock, Techno, Industrial, Alternative, Ska, Death Metal, Prank, Soundtrack,
|
208
|
+
Euro-Techno, Ambient, Trip-Hop, Vocal, Jazz + Funk, Fusion, Trance,
|
209
|
+
Classical, Instrumental, Acid, House, Game, Sound Clip, Gospel, Noise,
|
210
|
+
Alternative Rock, Bass, Soul, Punk, Space, Meditative, Instrumental Pop,
|
211
|
+
Instrumental Rock, Ethnic, Gothic, Darkwave, Techno-Industrial, Electronic,
|
212
|
+
Pop-Fol, Eurodance, Dream, Southern Rock, Comedy, Cult, Gangsta, Top 40,
|
213
|
+
Christian Rap, Pop/Funk, Jungle, Native US, Cabaret, New Wave, Psychadelic,
|
214
|
+
Rave, Showtunes, Trailer, Lo-Fi, Tribal, Acid Punk, Acid Jazz, Polka,
|
215
|
+
Retro, Musical, Rock & Roll, Hard Rock, Folk, Folk-Rock, National Folk,
|
216
|
+
Swing, Fast Fusion, Bebop, Latin, Revival, Celtic, Bluegrass, Avantgarde,
|
217
|
+
Gothic Rock, Progressive Rock, Psychedelic Rock, Symphonic Rock, Slow Rock,
|
218
|
+
Big Band, Chorus, Easy Listening, Acoustic, Humour, Speech, Chanson, Opera,
|
219
|
+
Chamber Music, Sonata, Symphony, Booty Bass, Primus, Porn Groove, Satire,
|
220
|
+
Slow Jam, Club, Tango, Samba, Folklore, Ballad, Power Ballad, Rhytmic Soul,
|
221
|
+
Freestyle, Duet, Punk Rock, Drum Solo, Acapella, Euro-House, Dance Hall,
|
222
|
+
Goa, Drum & Bass, Club-House, Hardcore, Terror, Indie, BritPop, Negerpunk,
|
223
|
+
Polsk Punk, Beat, Christian Gangsta Rap, Heavy Metal, Black Metal,
|
224
|
+
Crossover, Contemporary Christian, Christian Rock, Merengue, Salsa,
|
225
|
+
Trash Meta, Anime, Jpop, Synthpop'.split(',').collect{|g| g.strip}
|
226
|
+
ID3_GENRES_HASH = Hash.new(255.chr)
|
227
|
+
ID3_GENRES.each_with_index{|g,i| ID3_GENRES_HASH[g] = i.chr }
|
228
|
+
FILE_OBJ_METHODS = %w'close seek read pos write truncate'
|
229
|
+
YEAR_RE = Regexp.new('\d{4}')
|
230
|
+
MP3_RE = Regexp.new('\.mp3\z')
|
231
|
+
|
232
|
+
@@check_id3 = true
|
233
|
+
|
234
|
+
attr_reader :filename, :file, :tag_size, :tag_start, :tag_data, :tag_header, :tag_footer, :tag_item_count, :check_id3
|
235
|
+
|
236
|
+
# Set whether to check for id3 tags by default on file objects (defaults to true)
|
237
|
+
def self.check_id3=(flag)
|
238
|
+
raise ApeTagError, "check_id3 must be boolean" unless [true, false].include?(flag)
|
239
|
+
@@check_id3 = flag
|
240
|
+
end
|
241
|
+
|
242
|
+
# Set the filename or file object to operate on. If the object has all methods
|
243
|
+
# in FILE_OBJ_METHODS, it is treated as a file, otherwise, it is treated as a filename.
|
244
|
+
# If the filename is invalid, Errno::ENOENT or Errno::EINVAL will probably be raised when calling methods.
|
245
|
+
# Optional argument check_id3 checks for ID3 tags.
|
246
|
+
# If check_id3 is not specified and filename is a file object, the ApeTag default is used.
|
247
|
+
# If check_id3 is not specified and filename is a filename, it checks for ID3 tags only if
|
248
|
+
# the filename ends with ".mp3".
|
249
|
+
# If files have APE tags but no ID3 tags, ID3 tags will never be added.
|
250
|
+
# If files have neither tag, check_id3 will decide whether to add an ID3 tag.
|
251
|
+
# If files have both tags, make sure check_id3 is true or it will miss both tags.
|
252
|
+
def initialize(filename, check_id3 = nil)
|
253
|
+
if FILE_OBJ_METHODS.each{|method| break unless filename.respond_to?(method)}
|
254
|
+
@file = filename
|
255
|
+
@check_id3 = check_id3.nil? ? @@check_id3 : check_id3
|
256
|
+
else
|
257
|
+
@filename = filename.to_s
|
258
|
+
@check_id3 = check_id3 unless check_id3.nil?
|
259
|
+
@check_id3 = !MP3_RE.match(@filename).nil? if @check_id3.nil?
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
# Check the file for an APE tag. Returns true or false. Raises ApeTagError for corrupt tags.
|
264
|
+
def exists?
|
265
|
+
@has_tag.nil? ? access_file('rb'){has_tag} : @has_tag
|
266
|
+
end
|
267
|
+
|
268
|
+
# Remove an APE tag from a file, if one exists.
|
269
|
+
# Returns true. Raises ApeTagError for corrupt tags.
|
270
|
+
def remove!
|
271
|
+
access_file('rb+'){file.truncate(tag_start) if has_tag}
|
272
|
+
@has_tag, @fields, @id3, @tag_size, @tag_start, @tag_data, @tag_header, @tag_footer, @tag_item_count = []
|
273
|
+
true
|
274
|
+
end
|
275
|
+
|
276
|
+
# A hash of ApeItems found in the file, or an empty hash if the file
|
277
|
+
# doesn't have an APE tag. Raises ApeTagError for corrupt tags.
|
278
|
+
def fields
|
279
|
+
@fields || access_file('rb'){get_fields}
|
280
|
+
end
|
281
|
+
|
282
|
+
# Pretty print tags, with one line per field, showing key and value.
|
283
|
+
def pretty_print
|
284
|
+
begin
|
285
|
+
fields.values.sort_by{|value| value.key_downcased}.collect{|value| "#{value.key}: #{value.join(', ')}"}.join("\n")
|
286
|
+
rescue ApeTagError
|
287
|
+
"CORRUPT TAG!"
|
288
|
+
rescue Errno::ENOENT, Errno::EINVAL
|
289
|
+
"FILE NOT FOUND!"
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
# The raw APEv2 + ID3v1.1 tag. If one or the other is empty that part will be missing.
|
294
|
+
# Raises ApeTagError for corrupt tags.
|
295
|
+
def raw
|
296
|
+
exists?
|
297
|
+
"#{tag_header}#{tag_data}#{tag_footer}#{id3}"
|
298
|
+
end
|
299
|
+
|
300
|
+
# Yields a hash of ApeItems found in the file, or an empty hash if the file
|
301
|
+
# doesn't have an APE tag. This hash should be modified (not reassigned) inside
|
302
|
+
# the block. An APEv2+ID3v1.1 tag with the new fields will overwrite the previous
|
303
|
+
# tag. If the file doesn't have an APEv2 tag, one will be created and appended to it.
|
304
|
+
# If the file doesn't have an ID3v1.1 tag, one will be generated from the ApeTag fields
|
305
|
+
# and appended to it. If the file already has an ID3v1.1 tag, the data in it is ignored,
|
306
|
+
# and it is overwritten. Raises ApeTagError if either the existing tag is invalid
|
307
|
+
# or the tag to be written would be invalid.
|
308
|
+
def update(&block)
|
309
|
+
access_file('rb+') do
|
310
|
+
yield get_fields
|
311
|
+
normalize_fields
|
312
|
+
update_id3
|
313
|
+
update_ape
|
314
|
+
write_tag
|
315
|
+
end
|
316
|
+
fields
|
317
|
+
end
|
318
|
+
|
319
|
+
private
|
320
|
+
# If working with a file object, yield the object.
|
321
|
+
# If working with a filename, open the file to be accessed using the correct mode,
|
322
|
+
# yield the file. Return the value returned by the block passed.
|
323
|
+
def access_file(how, &block)
|
324
|
+
if @filename
|
325
|
+
File.open(filename, how) do |file|
|
326
|
+
@file = file
|
327
|
+
return_value = yield
|
328
|
+
@file.close
|
329
|
+
@file = nil
|
330
|
+
return_value
|
331
|
+
end
|
332
|
+
else
|
333
|
+
yield
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
# If working with a filename, use the file system's size for that filename.
|
338
|
+
# If working with a file that has a size method (e.g. StringIO), call that.
|
339
|
+
# Otherwise, seek to the end of the file and return the position.
|
340
|
+
def file_size
|
341
|
+
if @filename
|
342
|
+
File.size(filename)
|
343
|
+
elsif file.respond_to?(:size)
|
344
|
+
file.size
|
345
|
+
else
|
346
|
+
file.seek(0, IO::SEEK_END) && file.pos
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
# Parse the raw tag data to get the tag fields (a hash of ApeItems), or an empty hash
|
351
|
+
# if the file has no APE tag.
|
352
|
+
def get_fields
|
353
|
+
return @fields if @fields
|
354
|
+
return @fields = Hash.new unless has_tag
|
355
|
+
ape_items = Hash.new
|
356
|
+
ape_item_keys = Set.new
|
357
|
+
offset = 0
|
358
|
+
last_possible_item_start = tag_data.length - ApeItem::MIN_SIZE
|
359
|
+
tag_item_count.times do
|
360
|
+
raise ApeTagError, "End of tag reached but more items specified" if offset > last_possible_item_start
|
361
|
+
item, offset = ApeItem.parse(tag_data, offset)
|
362
|
+
raise ApeTagError, "Multiple items with same key (#{item.key.inspect})" if ape_item_keys.include?(item.key_downcased)
|
363
|
+
ape_item_keys.add(item.key_downcased)
|
364
|
+
ape_items[item.key] = item
|
365
|
+
end
|
366
|
+
raise ApeTagError, "Data remaing after specified number of items parsed" if offset != tag_data.length
|
367
|
+
@fields = ape_items
|
368
|
+
end
|
369
|
+
|
370
|
+
# Get various information about the tag (if it exists), and check it for validity if a tag is present.
|
371
|
+
def get_tag_information
|
372
|
+
unless file_size >= id3.length + 64
|
373
|
+
@has_tag = false
|
374
|
+
@tag_start = file_size - id3.length
|
375
|
+
return
|
376
|
+
end
|
377
|
+
file.seek(-32-id3.length, IO::SEEK_END)
|
378
|
+
tag_footer = file.read(32)
|
379
|
+
unless tag_footer[0...12] == PREAMBLE && tag_footer[20...24] == FOOTER_FLAGS
|
380
|
+
@has_tag = false
|
381
|
+
@tag_start = file_size - id3.length
|
382
|
+
return
|
383
|
+
end
|
384
|
+
@tag_footer = tag_footer
|
385
|
+
@tag_size, @tag_item_count = tag_footer[12...20].unpack('VV')
|
386
|
+
@tag_size += 32
|
387
|
+
raise ApeTagError, "Tag size (#{tag_size}) smaller than minimum size" if tag_size < 64
|
388
|
+
raise ApeTagError, "Tag size (#{tag_size}) larger than possible" if tag_size + id3.length > file_size
|
389
|
+
raise ApeTagError, "Tag size (#{tag_size}) is larger than #{MAX_SIZE}" if tag_size > MAX_SIZE
|
390
|
+
raise ApeTagError, "Item count (#{tag_item_count}) is larger than #{MAX_ITEM_COUNT}" if tag_item_count > MAX_ITEM_COUNT
|
391
|
+
raise ApeTagError, "Item count (#{tag_item_count}) is larger than possible" if tag_item_count > tag_size-64/ApeItem::MIN_SIZE
|
392
|
+
file.seek(-tag_size-id3.length, IO::SEEK_END)
|
393
|
+
@tag_start=file.pos
|
394
|
+
@tag_header=file.read(32)
|
395
|
+
@tag_data=file.read(tag_size-64)
|
396
|
+
raise ApeTagError, "Missing header" unless tag_header[0...12] == PREAMBLE && tag_header[20...24] == HEADER_FLAGS
|
397
|
+
raise ApeTagError, "Header and footer size does match" unless tag_size == tag_header[12...16].unpack('V')[0] + 32
|
398
|
+
raise ApeTagError, "Header and footer item count does match" unless tag_item_count == tag_header[16...20].unpack('V')[0]
|
399
|
+
@has_tag = true
|
400
|
+
end
|
401
|
+
|
402
|
+
# Check if the file has a tag or not
|
403
|
+
def has_tag
|
404
|
+
return @has_tag unless @has_tag.nil?
|
405
|
+
get_tag_information
|
406
|
+
@has_tag
|
407
|
+
end
|
408
|
+
|
409
|
+
# Get the raw id3 string for the file (this is ignored).
|
410
|
+
# If check_id3 is false, it doesn't check for the ID3, which means that
|
411
|
+
# the APE tag will probably not be recognized if the file ends with an ID3 tag.
|
412
|
+
def id3
|
413
|
+
return @id3 unless @id3.nil?
|
414
|
+
return @id3 = '' if file_size < 128 || check_id3 == false
|
415
|
+
file.seek(-128, IO::SEEK_END)
|
416
|
+
data = file.read(128)
|
417
|
+
@id3 = data[0...3] == 'TAG' && data[125] == 0 ? data : ''
|
418
|
+
end
|
419
|
+
|
420
|
+
# Turn fields hash from a hash of arbitrary objects to a hash of ApeItems
|
421
|
+
# Check that multiple identical keys are not present.
|
422
|
+
def normalize_fields
|
423
|
+
new_fields = Hash.new
|
424
|
+
new_fields_keys = Set.new
|
425
|
+
fields.each do |key, value|
|
426
|
+
new_fields[key] = value = ApeItem.create(key, value)
|
427
|
+
raise ApeTagError, "Multiple items with same key (#{value.key.inspect})" if new_fields_keys.include?(value.key_downcased)
|
428
|
+
new_fields_keys.add(value.key_downcased)
|
429
|
+
end
|
430
|
+
@fields = new_fields
|
431
|
+
end
|
432
|
+
|
433
|
+
# Update internal variables to reflect the new APE tag. Check that produced
|
434
|
+
# tag is still valid.
|
435
|
+
def update_ape
|
436
|
+
entries = fields.values.collect{|value| value.raw}.sort{|a,b| x = a.length <=> b.length; x != 0 ? x : a <=> b}
|
437
|
+
@tag_data = entries.join
|
438
|
+
@tag_item_count = entries.length
|
439
|
+
@tag_size = tag_data.length + 64
|
440
|
+
base_start = "#{PREAMBLE}#{[tag_size-32, tag_item_count].pack('VV')}"
|
441
|
+
base_end = "\0"*8
|
442
|
+
@tag_header = "#{base_start}#{HEADER_FLAGS}#{base_end}"
|
443
|
+
@tag_footer = "#{base_start}#{FOOTER_FLAGS}#{base_end}"
|
444
|
+
raise ApeTagError, "Updated tag has too many items (#{tag_item_count})" if tag_item_count > MAX_ITEM_COUNT
|
445
|
+
raise ApeTagError, "Updated tag too large (#{tag_size})" if tag_size > MAX_SIZE
|
446
|
+
end
|
447
|
+
|
448
|
+
# Update the ID3v1.1 tag variable to use the fields from the APEv2 tag.
|
449
|
+
# If the file doesn't have an ID3 and the file already has an APE tag or
|
450
|
+
# check_id3 is not set, an ID3 won't be added.
|
451
|
+
def update_id3
|
452
|
+
return if id3.length == 0 && (has_tag || check_id3 == false)
|
453
|
+
id3_fields = Hash.new('')
|
454
|
+
id3_fields['genre'] = 255.chr
|
455
|
+
fields.values.each do |value|
|
456
|
+
case value.key_downcased
|
457
|
+
when /\Atrack/
|
458
|
+
id3_fields['track'] = value.string_value.to_i
|
459
|
+
id3_fields['track'] = 0 if id3_fields['track'] > 255
|
460
|
+
id3_fields['track'] = id3_fields['track'].chr
|
461
|
+
when /\Agenre/
|
462
|
+
id3_fields['genre'] = ID3_GENRES_HASH[value.first]
|
463
|
+
when /\Adate\z/
|
464
|
+
match = YEAR_RE.match(value.string_value)
|
465
|
+
id3_fields['year'] = match[0] if match
|
466
|
+
when /\A(title|artist|album|year|comment)\z/
|
467
|
+
id3_fields[value.key_downcased] = value.join(', ')
|
468
|
+
end
|
469
|
+
end
|
470
|
+
@id3 = ["TAG", id3_fields['title'], id3_fields['artist'], id3_fields['album'],
|
471
|
+
id3_fields['year'], id3_fields['comment'], "\0", id3_fields['track'],
|
472
|
+
id3_fields['genre']].pack("a3a30a30a30a4a28a1a1a1")
|
473
|
+
end
|
474
|
+
|
475
|
+
# Write the APEv2 and ID3v1.1 tags to disk.
|
476
|
+
def write_tag
|
477
|
+
file.seek(tag_start, IO::SEEK_SET)
|
478
|
+
file.write(raw)
|
479
|
+
file.truncate(file.pos)
|
480
|
+
@has_tag = true
|
481
|
+
end
|
482
|
+
end
|
483
|
+
|
484
|
+
# If called directly from the command line, treat all arguments as filenames, and pretty print the APE tag's fields for each filename.
|
485
|
+
if __FILE__ == $0
|
486
|
+
ARGV.each do |filename|
|
487
|
+
puts filename, '-'*filename.length, ApeTag.new(filename).pretty_print, ''
|
488
|
+
end
|
489
|
+
end
|
data/test/test_apetag.rb
ADDED
@@ -0,0 +1,422 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'apetag'
|
3
|
+
require 'stringio'
|
4
|
+
require 'test/unit'
|
5
|
+
|
6
|
+
EMPTY_APE_TAG = "APETAGEX\320\a\0\0 \0\0\0\0\0\0\0\0\0\0\240\0\0\0\0\0\0\0\0APETAGEX\320\a\0\0 \0\0\0\0\0\0\0\0\0\0\200\0\0\0\0\0\0\0\0TAG\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\377"
|
7
|
+
EXAMPLE_APE_TAG = "APETAGEX\xd0\x07\x00\x00\xb0\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00Track\x001\x04\x00\x00\x00\x00\x00\x00\x00Date\x002007\t\x00\x00\x00\x00\x00\x00\x00Comment\x00XXXX-0000\x0b\x00\x00\x00\x00\x00\x00\x00Title\x00Love Cheese\x0b\x00\x00\x00\x00\x00\x00\x00Artist\x00Test Artist\x16\x00\x00\x00\x00\x00\x00\x00Album\x00Test Album\x00Other AlbumAPETAGEX\xd0\x07\x00\x00\xb0\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00TAGLove Cheese\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00Test Artist\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00Test Album, Other Album\x00\x00\x00\x00\x00\x00\x002007XXXX-0000\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\xff"
|
8
|
+
EXAMPLE_APE_TAG2 = "APETAGEX\xd0\x07\x00\x00\x99\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\xa0\x00\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00Blah\x00Blah\x04\x00\x00\x00\x00\x00\x00\x00Date\x002007\t\x00\x00\x00\x00\x00\x00\x00Comment\x00XXXX-0000\x0b\x00\x00\x00\x00\x00\x00\x00Artist\x00Test Artist\x16\x00\x00\x00\x00\x00\x00\x00Album\x00Test Album\x00Other AlbumAPETAGEX\xd0\x07\x00\x00\x99\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00TAG\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00Test Artist\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00Test Album, Other Album\x00\x00\x00\x00\x00\x00\x002007XXXX-0000\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff"
|
9
|
+
EMPTY_APE_ONLY_TAG, EXAMPLE_APE_ONLY_TAG, EXAMPLE_APE_ONLY_TAG2 = [EMPTY_APE_TAG, EXAMPLE_APE_TAG, EXAMPLE_APE_TAG2].collect{|x|x[0...-128]}
|
10
|
+
EXAMPLE_APE_FIELDS = {"Track"=>["1"], "Comment"=>["XXXX-0000"], "Album"=>["Test Album", "Other Album"], "Title"=>["Love Cheese"], "Artist"=>["Test Artist"], "Date"=>["2007"]}
|
11
|
+
EXAMPLE_APE_FIELDS2 = {"Blah"=>["Blah"], "Comment"=>["XXXX-0000"], "Album"=>["Test Album", "Other Album"], "Artist"=>["Test Artist"], "Date"=>["2007"]}
|
12
|
+
EXAMPLE_APE_TAG_PRETTY_PRINT = "Album: Test Album, Other Album\nArtist: Test Artist\nComment: XXXX-0000\nDate: 2007\nTitle: Love Cheese\nTrack: 1"
|
13
|
+
|
14
|
+
class ApeTagTest < Test::Unit::TestCase
|
15
|
+
def get_ape_tag(f, check_id3)
|
16
|
+
f.is_a?(ApeTag) ? f : ApeTag.new(f, check_id3)
|
17
|
+
end
|
18
|
+
|
19
|
+
def get_size(x)
|
20
|
+
get_ape_tag(x, nil).send :file_size
|
21
|
+
end
|
22
|
+
|
23
|
+
def item_test(item, check_id3)
|
24
|
+
f = item
|
25
|
+
id3_size = check_id3 ? 128 : 0
|
26
|
+
size = get_size(f)
|
27
|
+
assert_equal false, get_ape_tag(f, check_id3).exists?
|
28
|
+
assert_equal size, get_size(f)
|
29
|
+
assert_equal true, get_ape_tag(f, check_id3).remove!
|
30
|
+
assert_equal size, get_size(f)
|
31
|
+
assert_equal "", get_ape_tag(f, check_id3).raw
|
32
|
+
assert_equal size, get_size(f)
|
33
|
+
assert_equal Hash.new, get_ape_tag(f, check_id3).fields
|
34
|
+
assert_equal size, get_size(f)
|
35
|
+
assert_equal Hash.new, get_ape_tag(f, check_id3).update{|x|}
|
36
|
+
assert_equal (size+=64+id3_size), get_size(f)
|
37
|
+
assert_equal true, get_ape_tag(f, check_id3).exists?
|
38
|
+
assert_equal size, get_size(f)
|
39
|
+
assert_equal (check_id3 ? EMPTY_APE_TAG : EMPTY_APE_ONLY_TAG), get_ape_tag(f, check_id3).raw, "#{item.inspect} #{check_id3}"
|
40
|
+
assert_equal size, get_size(f)
|
41
|
+
assert_equal Hash.new, get_ape_tag(f, check_id3).fields
|
42
|
+
assert_equal size, get_size(f)
|
43
|
+
assert_equal Hash.new, get_ape_tag(f, check_id3).update{|x|}
|
44
|
+
assert_equal size, get_size(f)
|
45
|
+
assert_equal true, get_ape_tag(f, check_id3).remove!
|
46
|
+
assert_equal (size-=64+id3_size), get_size(f)
|
47
|
+
assert_equal EXAMPLE_APE_FIELDS, get_ape_tag(f, check_id3).update{|x| x.replace(EXAMPLE_APE_FIELDS)}
|
48
|
+
assert_equal (size+=208+id3_size), get_size(f)
|
49
|
+
assert_equal true, get_ape_tag(f, check_id3).exists?
|
50
|
+
assert_equal size, get_size(f)
|
51
|
+
assert_equal (check_id3 ? EXAMPLE_APE_TAG : EXAMPLE_APE_ONLY_TAG), get_ape_tag(f, check_id3).raw
|
52
|
+
assert_equal EXAMPLE_APE_TAG_PRETTY_PRINT, get_ape_tag(f, check_id3).pretty_print
|
53
|
+
assert_equal size, get_size(f)
|
54
|
+
assert_equal EXAMPLE_APE_FIELDS, get_ape_tag(f, check_id3).fields
|
55
|
+
assert_equal size, get_size(f)
|
56
|
+
assert_equal EXAMPLE_APE_FIELDS, get_ape_tag(f, check_id3).update{|x|}
|
57
|
+
assert_equal size, get_size(f)
|
58
|
+
assert_equal EXAMPLE_APE_FIELDS2, get_ape_tag(f, check_id3).update {|x| x.delete('Track'); x.delete('Title'); x['Blah']='Blah'}
|
59
|
+
assert_equal (size-=23), get_size(f)
|
60
|
+
assert_equal true, get_ape_tag(f, check_id3).exists?
|
61
|
+
assert_equal size, get_size(f)
|
62
|
+
assert_equal (check_id3 ? EXAMPLE_APE_TAG2 : EXAMPLE_APE_ONLY_TAG2), get_ape_tag(f, check_id3).raw
|
63
|
+
assert_equal size, get_size(f)
|
64
|
+
assert_equal EXAMPLE_APE_FIELDS2, get_ape_tag(f, check_id3).fields
|
65
|
+
assert_equal size, get_size(f)
|
66
|
+
assert_equal EXAMPLE_APE_FIELDS2, get_ape_tag(f, check_id3).update{|x|}
|
67
|
+
assert_equal size, get_size(f)
|
68
|
+
assert_equal true, get_ape_tag(f, check_id3).remove!
|
69
|
+
assert_equal "", get_ape_tag(f, check_id3).raw
|
70
|
+
assert_equal (size-=185+id3_size), get_size(f)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Test to make sure different file sizes don't cause any problems.
|
74
|
+
# Use both StringIOs, Files, and Strings.
|
75
|
+
# Use both a single ApeTag for each test and a new ApeTag for each to test
|
76
|
+
# that ApeTag state is created and saved correctly.
|
77
|
+
def test_blanks
|
78
|
+
filename = 'test.apetag'
|
79
|
+
File.new(filename,'wb').close
|
80
|
+
[0,1,63,64,65,127,128,129,191,192,193,8191,8192,8193].each do |x|
|
81
|
+
[true, false].each do |check_id3|
|
82
|
+
s = StringIO.new(' ' * x)
|
83
|
+
item_test(s, check_id3)
|
84
|
+
item_test(ApeTag.new(s, check_id3), check_id3)
|
85
|
+
f = File.new(filename,'rb+')
|
86
|
+
f.write(' ' * x)
|
87
|
+
item_test(f, check_id3)
|
88
|
+
item_test(ApeTag.new(f, check_id3), check_id3)
|
89
|
+
f.close()
|
90
|
+
item_test(filename, check_id3)
|
91
|
+
item_test(ApeTag.new(filename, check_id3), check_id3)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
File.delete(filename)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Test ApeItem instance methods
|
98
|
+
def test_ape_item
|
99
|
+
ai = ApeItem.new('BlaH', ['BlAh'])
|
100
|
+
# Test valid defaults
|
101
|
+
assert_equal ['BlAh'], ai
|
102
|
+
assert_equal false, ai.read_only
|
103
|
+
assert_equal 'utf8', ai.ape_type
|
104
|
+
assert_equal 'BlaH', ai.key
|
105
|
+
assert_equal 'blah', ai.key_downcased
|
106
|
+
assert_equal 'BlAh', ai.string_value
|
107
|
+
assert_equal "\04\0\0\0\0\0\0\0BlaH\0BlAh", ai.raw
|
108
|
+
assert_equal true, ai.valid?
|
109
|
+
|
110
|
+
# Test valid read_only settings
|
111
|
+
assert_nothing_raised{ai.read_only=true}
|
112
|
+
assert_nothing_raised{ai.read_only=false}
|
113
|
+
assert_raises(ApeTagError){ai.read_only=nil}
|
114
|
+
assert_raises(ApeTagError){ai.read_only='Blah'}
|
115
|
+
assert_equal true, ai.valid?
|
116
|
+
|
117
|
+
# Test valid ape_type settings
|
118
|
+
ApeItem::ITEM_TYPES.each{|type| assert_nothing_raised{ai.ape_type=type}}
|
119
|
+
assert_raises(ApeTagError){ai.ape_type='Blah'}
|
120
|
+
|
121
|
+
# Test valid key settings
|
122
|
+
((("\0".."\x1f").to_a+("\x80".."\xff").to_a).collect{|x|"#{x} "} +
|
123
|
+
[nil, 1, '', 'x', 'x'*256]+ApeItem::BAD_KEYS.to_a).each{|x|assert_raises(ApeTagError){ai.key=x}}
|
124
|
+
("\x20".."\x7f").to_a.collect{|x|"#{x} "}+ApeItem::BAD_KEYS.to_a.collect{|x|"#{x} "} +
|
125
|
+
['xx', 'x'*255].each{|x| assert_nothing_raised{ai.key=x}}
|
126
|
+
|
127
|
+
# Test valid raw and string value for different settings
|
128
|
+
ai.key="BlaH"
|
129
|
+
assert_equal "\04\0\0\0\0\0\0\06BlaH\0BlAh", ai.raw
|
130
|
+
assert_equal 'BlAh', ai.string_value
|
131
|
+
ai.read_only=true
|
132
|
+
assert_equal "\04\0\0\0\0\0\0\07BlaH\0BlAh", ai.raw
|
133
|
+
assert_equal 'BlAh', ai.string_value
|
134
|
+
ai << 'XYZ'
|
135
|
+
assert_equal "\010\0\0\0\0\0\0\07BlaH\0BlAh\0XYZ", ai.raw
|
136
|
+
assert_equal "BlAh\0XYZ", ai.string_value
|
137
|
+
end
|
138
|
+
|
139
|
+
# Test ApeItem.create methods
|
140
|
+
def test_ape_item_create
|
141
|
+
ai = ApeItem.new('BlaH', ['BlAh'])
|
142
|
+
ac = ApeItem.create('BlaH', ai)
|
143
|
+
# Test same key and ApeItem passed gives same item with key
|
144
|
+
assert_equal ai.object_id, ac.object_id
|
145
|
+
assert_equal 'BlaH', ai.key
|
146
|
+
assert_equal 'BlaH', ac.key
|
147
|
+
# Test different key and ApeItem passed gives same item with different key
|
148
|
+
ac = ApeItem.create('XXX', ai)
|
149
|
+
assert_equal ai.object_id, ac.object_id
|
150
|
+
assert_equal 'XXX', ai.key
|
151
|
+
assert_equal 'XXX', ac.key
|
152
|
+
|
153
|
+
# Test create fails with invalid key
|
154
|
+
assert_raises(ApeTagError){ApeItem.create('', ai)}
|
155
|
+
# Test create fails with invalid UTF-8 value
|
156
|
+
assert_raises(ApeTagError){ApeItem.create('xx',["\xfe"])}
|
157
|
+
# Test create doesn't fail with valid UTF-8 value
|
158
|
+
assert_nothing_raised{ApeItem.create('xx',[[12345, 1345].pack('UU')])}
|
159
|
+
|
160
|
+
# Test create with empty array
|
161
|
+
ac = ApeItem.create('Blah', [])
|
162
|
+
assert_equal ApeItem, ac.class
|
163
|
+
assert_equal 0, ac.length
|
164
|
+
assert_equal '', ac.string_value
|
165
|
+
|
166
|
+
# Test create works with string
|
167
|
+
ac = ApeItem.create('Blah', 'Blah')
|
168
|
+
assert_equal ApeItem, ac.class
|
169
|
+
assert_equal 1, ac.length
|
170
|
+
assert_equal 'Blah', ac.string_value
|
171
|
+
|
172
|
+
# Test create works with random object (Hash in this case)
|
173
|
+
ac = ApeItem.create('Blah', 'xfe'=>132)
|
174
|
+
assert_equal ApeItem, ac.class
|
175
|
+
assert_equal 1, ac.length
|
176
|
+
assert_equal 'xfe132', ac.string_value
|
177
|
+
|
178
|
+
# Test create works with array of mixed objects
|
179
|
+
ac = ApeItem.create('Blah', ['sadf', 'adsfas', 11, {'xfe'=>132}])
|
180
|
+
assert_equal ApeItem, ac.class
|
181
|
+
assert_equal 4, ac.length
|
182
|
+
assert_equal "sadf\0adsfas\00011\0xfe132", ac.string_value
|
183
|
+
end
|
184
|
+
|
185
|
+
# Test ApeItem.parse
|
186
|
+
def test_ape_item_parse
|
187
|
+
data = "\010\0\0\0\0\0\0\07BlaH\0BlAh\0XYZ"
|
188
|
+
# Test simple item parsing
|
189
|
+
ai, offset = ApeItem.parse(data, 0)
|
190
|
+
assert_equal 2, ai.length
|
191
|
+
assert_equal offset, data.length
|
192
|
+
assert_equal "BlAh\0XYZ", ai.string_value
|
193
|
+
assert_equal true, ai.read_only
|
194
|
+
assert_equal 'reserved', ai.ape_type
|
195
|
+
assert_equal 'BlaH', ai.key
|
196
|
+
|
197
|
+
# Test parsing with bad key
|
198
|
+
assert_raises(ApeTagError){ApeItem.parse("\0\0\0\0\0\0\0\07x\0", 0)}
|
199
|
+
|
200
|
+
# Test parsing with no key end
|
201
|
+
assert_raises(ApeTagError){ApeItem.parse(data[0...-1], 0)}
|
202
|
+
|
203
|
+
# Test parsing with bad start value
|
204
|
+
assert_raises(ApeTagError){ApeItem.parse(data, 1)}
|
205
|
+
|
206
|
+
# Test parsing with bad/good flags
|
207
|
+
data[4] = 8
|
208
|
+
assert_raises(ApeTagError){ApeItem.parse(data, 0)}
|
209
|
+
data[4] = 0
|
210
|
+
assert_nothing_raised{ApeItem.parse(data, 0)}
|
211
|
+
|
212
|
+
# Test parsing with length longer than string
|
213
|
+
data[0] = 9
|
214
|
+
assert_raises(ApeTagError){ApeItem.parse(data, 0)}
|
215
|
+
|
216
|
+
# Test parsing with length shorter than string gives valid ApeItem
|
217
|
+
# Of course, the next item will probably be parsed incorrectly
|
218
|
+
data[0] = 3
|
219
|
+
assert_nothing_raised{ai, offset = ApeItem.parse(data, 0)}
|
220
|
+
assert_equal 16, offset
|
221
|
+
assert_equal "BlaH", ai.key
|
222
|
+
assert_equal "BlA", ai.string_value
|
223
|
+
|
224
|
+
# Test parsing gets correct key end
|
225
|
+
data[12] = '3'
|
226
|
+
assert_nothing_raised{ai, offset = ApeItem.parse(data, 0)}
|
227
|
+
assert_equal "BlaH3BlAh", ai.key
|
228
|
+
assert_equal "XYZ", ai.string_value
|
229
|
+
|
230
|
+
# Test parsing of invalid UTF-8
|
231
|
+
data = "\012\0\0\0\07\0\0\0BlaH\0BlAh\0XYZ\0\xff"
|
232
|
+
assert_raises(ApeTagError){ApeItem.parse(data, 0)}
|
233
|
+
end
|
234
|
+
|
235
|
+
# Test parsing of whole tags that have been monkeyed with
|
236
|
+
def test_bad_tags
|
237
|
+
data = EMPTY_APE_TAG.dup
|
238
|
+
# Test default case OK
|
239
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(data)).raw}
|
240
|
+
|
241
|
+
# Test footer size less than minimum size (32)
|
242
|
+
data[44] = 31
|
243
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
244
|
+
data[44] = 0
|
245
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
246
|
+
|
247
|
+
# Test tag size > 8192, when both larger than file and smaller than file
|
248
|
+
data[44] = 225
|
249
|
+
data[45] = 31
|
250
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
251
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(' '*8192+data)).raw}
|
252
|
+
|
253
|
+
data = EMPTY_APE_TAG.dup
|
254
|
+
# Test unmatching header and footer tag size, with footer size wrong
|
255
|
+
data[44] = 33
|
256
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
257
|
+
|
258
|
+
# Test matching header and footer but size to large for file
|
259
|
+
data[12] = 33
|
260
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
261
|
+
|
262
|
+
# Test that header and footer size isn't too large for file, but doesn't
|
263
|
+
# find the header
|
264
|
+
data=" #{data}"
|
265
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
266
|
+
|
267
|
+
# Test unmatching header and footer tag size, with header size wrong
|
268
|
+
data[45] = 32
|
269
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
270
|
+
|
271
|
+
data = EMPTY_APE_TAG.dup
|
272
|
+
# Test item count greater than maximum (64)
|
273
|
+
data[48] = 65
|
274
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
275
|
+
|
276
|
+
# Test item count greater than possible given tag size
|
277
|
+
data[48] = 1
|
278
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
279
|
+
|
280
|
+
# Test unmatched header and footer item size, header size wrong
|
281
|
+
data[48] = 0
|
282
|
+
data[16] = 1
|
283
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).raw}
|
284
|
+
|
285
|
+
# Test unmatched header and footer item size, footer size wrong
|
286
|
+
data = EXAMPLE_APE_TAG.dup
|
287
|
+
data[48] -=1
|
288
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
289
|
+
|
290
|
+
# Test missing/corrupt header
|
291
|
+
data = EMPTY_APE_TAG.dup
|
292
|
+
data[0] = 0
|
293
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
294
|
+
|
295
|
+
# Test parsing bad first item size
|
296
|
+
data = EXAMPLE_APE_TAG.dup
|
297
|
+
data[32] +=1
|
298
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
299
|
+
|
300
|
+
# Test parsing bad first item invalid key
|
301
|
+
data = EXAMPLE_APE_TAG.dup
|
302
|
+
data[40] = 0
|
303
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
304
|
+
|
305
|
+
# Test parsing bad first item key end
|
306
|
+
data = EXAMPLE_APE_TAG.dup
|
307
|
+
data[45] = 1
|
308
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
309
|
+
|
310
|
+
# Test parsing bad second item length too long
|
311
|
+
data = EXAMPLE_APE_TAG.dup
|
312
|
+
data[47] = 255
|
313
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
314
|
+
|
315
|
+
# Test parsing case insensitive duplicate keys
|
316
|
+
data = EXAMPLE_APE_TAG.dup
|
317
|
+
data[40...45] = 'Album'
|
318
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
319
|
+
data[40...45] = 'album'
|
320
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
321
|
+
data[40...45] = 'ALBUM'
|
322
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
323
|
+
|
324
|
+
# Test parsing incorrect item counts
|
325
|
+
data = EXAMPLE_APE_TAG.dup
|
326
|
+
data[16] -= 1
|
327
|
+
data[192] -= 1
|
328
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
329
|
+
data[16] += 2
|
330
|
+
data[192] += 2
|
331
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(data)).fields}
|
332
|
+
|
333
|
+
# Test updating with duplicate keys added
|
334
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['album']='blah'}}
|
335
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['ALBUM']='blah'}}
|
336
|
+
# Test updating without duplicate keys added
|
337
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['Album']='blah'}}
|
338
|
+
|
339
|
+
# Test updating an existing ApeItem via various array methods
|
340
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['Album'] += ['blah']}}
|
341
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['Album'] << 'blah'}}
|
342
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['Album'].replace(['blah'])}}
|
343
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['Album'].pop}}
|
344
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EXAMPLE_APE_TAG.dup)).update{|x| x['Album'].shift}}
|
345
|
+
|
346
|
+
# Test updating with invalid value
|
347
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(EMPTY_APE_TAG.dup)).update{|x| x['Album']="\xfe"}}
|
348
|
+
|
349
|
+
# Test updating with invalid key
|
350
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(EMPTY_APE_TAG.dup)).update{|x| x['x']=""}}
|
351
|
+
|
352
|
+
# Test updating with too many items
|
353
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(EMPTY_APE_TAG.dup)).update{|x| 65.times{|i|x["blah#{i}"]=""}}}
|
354
|
+
# Test updating with just enough items
|
355
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EMPTY_APE_TAG.dup)).update{|x| 64.times{|i|x["blah#{i}"]=""}}}
|
356
|
+
|
357
|
+
# Test updating with too large a tag
|
358
|
+
assert_raises(ApeTagError){ApeTag.new(StringIO.new(EMPTY_APE_TAG.dup)).update{|x| x['xx']=' '*8118}}
|
359
|
+
# Test updating with just enough tag
|
360
|
+
assert_nothing_raised{ApeTag.new(StringIO.new(EMPTY_APE_TAG.dup)).update{|x| x['xx']=' '*8117}}
|
361
|
+
end
|
362
|
+
|
363
|
+
def test_check_id3
|
364
|
+
x = StringIO.new()
|
365
|
+
assert_equal 0, x.size
|
366
|
+
|
367
|
+
# Test ApeTag defaults to adding id3s on file objects without ape tags
|
368
|
+
ApeTag.new(x).update{}
|
369
|
+
assert_equal 192, x.size
|
370
|
+
# Test ApeTag doesn't find tag if not checking id3s and and id3 is present
|
371
|
+
ApeTag.new(x, false).remove!
|
372
|
+
assert_equal 192, x.size
|
373
|
+
# Test ApeTag doesn't add id3s if ape tag exists but id3 does not
|
374
|
+
x.truncate(64)
|
375
|
+
assert_equal 64, x.size
|
376
|
+
ApeTag.new(x).update{}
|
377
|
+
assert_equal 64, x.size
|
378
|
+
ApeTag.new(x).remove!
|
379
|
+
assert_equal 0, x.size
|
380
|
+
|
381
|
+
# Test ApeTag without checking doesn't add id3
|
382
|
+
ApeTag.new(x, false).update{}
|
383
|
+
assert_equal 64, x.size
|
384
|
+
ApeTag.new(x).remove!
|
385
|
+
assert_equal 0, x.size
|
386
|
+
|
387
|
+
# Test ApeTag with explicit check_id3 argument works
|
388
|
+
ApeTag.new(x, true).update{}
|
389
|
+
assert_equal 192, x.size
|
390
|
+
ApeTag.new(x, false).remove!
|
391
|
+
assert_equal 192, x.size
|
392
|
+
ApeTag.new(x).remove!
|
393
|
+
assert_equal 0, x.size
|
394
|
+
|
395
|
+
# Test whether check_id3 class variable works
|
396
|
+
ApeTag.check_id3 = false
|
397
|
+
ApeTag.new(x).update{}
|
398
|
+
assert_equal 64, x.size
|
399
|
+
ApeTag.new(x).remove!
|
400
|
+
assert_equal 0, x.size
|
401
|
+
ApeTag.check_id3 = true
|
402
|
+
assert_raises(ApeTagError){ApeTag.check_id3 = 0}
|
403
|
+
|
404
|
+
# Test non-mp3 filename defaults to no id3
|
405
|
+
filename = 'test.apetag'
|
406
|
+
File.new(filename,'wb').close
|
407
|
+
ApeTag.new(filename).update{}
|
408
|
+
assert_equal 64, get_size(filename)
|
409
|
+
ApeTag.new(filename).remove!
|
410
|
+
assert_equal 0, get_size(filename)
|
411
|
+
File.delete(filename)
|
412
|
+
|
413
|
+
# Test mp3 filename defaults to id3
|
414
|
+
filename = 'test.apetag.mp3'
|
415
|
+
File.new(filename,'wb').close
|
416
|
+
ApeTag.new(filename).update{}
|
417
|
+
assert_equal 192, get_size(filename)
|
418
|
+
ApeTag.new(filename).remove!
|
419
|
+
assert_equal 0, get_size(filename)
|
420
|
+
File.delete(filename)
|
421
|
+
end
|
422
|
+
end
|
metadata
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
rubygems_version: 0.8.11
|
3
|
+
specification_version: 1
|
4
|
+
name: apetag
|
5
|
+
version: !ruby/object:Gem::Version
|
6
|
+
version: 1.0.0
|
7
|
+
date: 2007-01-14 00:00:00 -08:00
|
8
|
+
summary: APEv2 Tag Parser/Generator
|
9
|
+
require_paths:
|
10
|
+
- .
|
11
|
+
email: jeremyevans0@gmail.com
|
12
|
+
homepage:
|
13
|
+
rubyforge_project:
|
14
|
+
description:
|
15
|
+
autorequire: apetag
|
16
|
+
default_executable:
|
17
|
+
bindir: bin
|
18
|
+
has_rdoc: true
|
19
|
+
required_ruby_version: !ruby/object:Gem::Version::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">"
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 0.0.0
|
24
|
+
version:
|
25
|
+
platform: ruby
|
26
|
+
signing_key:
|
27
|
+
cert_chain:
|
28
|
+
authors:
|
29
|
+
- Jeremy Evans
|
30
|
+
files:
|
31
|
+
- apetag.rb
|
32
|
+
test_files:
|
33
|
+
- test/test_apetag.rb
|
34
|
+
rdoc_options: []
|
35
|
+
|
36
|
+
extra_rdoc_files: []
|
37
|
+
|
38
|
+
executables: []
|
39
|
+
|
40
|
+
extensions: []
|
41
|
+
|
42
|
+
requirements: []
|
43
|
+
|
44
|
+
dependencies: []
|
45
|
+
|