apetag 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
+
|