class ID3v2
This class can be used to decode id3v2 tags from files, like .mp3 or .ape for example. It works like a hash, where key represents the tag name as 3 or 4 upper case letters (respectively related to 2.2 and 2.3+ tag) and value represented as array or raw value. Written version is always 2.3.
Constants
- APIC
- PIC
- TAGS
- TAG_MAPPING_2_2_to_2_3
Translate V2 to V3 tags
- TEXT_ENCODINGS
See id3v2.4.0-structure document, at section 4.
Attributes
this is the position in the file where the tag really ends
:lang
: for writing comments
- DEPRECATION
-
:
encoding
: one of the string ofTEXT_ENCODINGS
,
use of :encoding parameter is DEPRECATED. In ruby 1.8, use utf-8 encoded strings for tags. In ruby >= 1.9, strings are automatically transcoded from their originaloriginal encoding.
Public Class Methods
possible options are described above ('options' attribute) you can access this object like an hash, with [] and []= methods special cases are [“disc_number”] and [“disc_total”] mirroring TPOS attribute
# File lib/mp3info/id3v2.rb, line 182 def initialize(options = {}) @options = { :lang => "ENG" } if @options[:encoding] warn("use of :encoding parameter is DEPRECATED. In ruby 1.8, use utf-8 encoded strings for tags.\n" + "In ruby >= 1.9, strings are automatically transcoded from their original encoding.") end @options.update(options) @hash = {} #TAGS.keys.each { |k| @hash[k] = nil } @hash_orig = {} super(@hash) @parsed = false @version_maj = @version_min = nil end
Public Instance Methods
ID3V2::add_picture Takes an image string as input and writes it with header. Mime type is automatically guessed by default. It is possible but not necessary to include:
:pic_type => 0 - 14 (see http://id3.org/id3v2.3.0#Attached_picture) :mime => 'gif' :description => "Image description"
# File lib/mp3info/id3v2.rb, line 243 def add_picture(data, opts = {}) options = { :pic_type => 0, :mime => nil, :description => "image" } options.update(opts) jpg = Regexp.new( "^\xFF".force_encoding("BINARY"), Regexp::FIXEDENCODING ) png = Regexp.new( "^\x89PNG".force_encoding("BINARY"), Regexp::FIXEDENCODING ) gif = Regexp.new( "^\x89GIF".force_encoding("BINARY"), Regexp::FIXEDENCODING ) mime = options[:mime] mime ||= "jpg" if data.match jpg mime ||= "png" if data.match png mime ||= "gif" if data.match gif pic_type = options[:pic_type] pic_type = ["%02i" % pic_type].pack('H*') desc = "#{options[:description]}" header = "\x00image/#{mime}\x00#{pic_type}#{desc}\x00" self["APIC"] = header + data.force_encoding('BINARY') end
does this tag has been changed ?
# File lib/mp3info/id3v2.rb, line 205 def changed? @hash_orig != @hash end
gets id3v2 tag information from io object (must support seek() method)
# File lib/mp3info/id3v2.rb, line 350 def from_io(io) @io = io original_pos = @io.pos @io.extend(Mp3Info::Mp3FileMethods) version_maj, version_min, flags = @io.read(3).unpack("CCB4") @unsync, ext_header, _, _ = (0..3).collect { |i| flags[i].chr == '1' } # _, _ = experimental, footer raise(ID3v2Error, "can't find version_maj ('#{version_maj}')") unless [2, 3, 4].include?(version_maj) @version_maj, @version_min = version_maj, version_min @tag_length = @io.get_syncsafe @parsed = true begin case @version_maj when 2 read_id3v2_2_frames when 3, 4 # seek past extended header if present @io.seek(@io.get_syncsafe - 4, IO::SEEK_CUR) if ext_header read_id3v2_3_frames end rescue ID3v2Error => e warn("warning: id3v2 tag not fully parsed: #{e.message}") end @io_position = @io.pos @tag_length = @io_position - original_pos @hash_orig = @hash.dup #no more reading @io = nil end
# File lib/mp3info/id3v2.rb, line 340 def inspect self.to_inspect_hash end
does this tag has been correctly read ?
# File lib/mp3info/id3v2.rb, line 200 def parsed? @parsed end
Returns an array of images:
[ ["01_.jpg", "Image Data in Binary String"], ["02_.png", "Another Image in a String"] ]
e.g. to write all images: mp3.tag2.pictures.each do |image|
File.open(img[0], 'wb'){|f| f.write img[1])
end
# File lib/mp3info/id3v2.rb, line 276 def pictures apic_images = [self["APIC"]].flatten.dup result = [] apic_images.each_index do |index| pic = apic_images[index] next if !pic.is_a?(String) or pic == "" pic.force_encoding 'BINARY' picture = [] jpg = Regexp.new("jpg|JPG|jpeg|JPEG".force_encoding("BINARY"), Regexp::FIXEDENCODING ) png = Regexp.new("png|PNG".force_encoding("BINARY"), Regexp::FIXEDENCODING ) header = pic.unpack('a120').first.force_encoding "BINARY" mime_pos = 0 # safest way to correctly extract jpg and png is finding mime if header.match jpg mime = "jpg" mime_pos = header =~ jpg start = Regexp.new("^\377".force_encoding("BINARY"), Regexp::FIXEDENCODING ) elsif header.match png mime = "png" mime_pos = header =~ png start = Regexp.new("^\x89PNG".force_encoding("BINARY"), Regexp::FIXEDENCODING ) else mime = "dat" end puts "analysing image: #{header.inspect}..." if $DEBUG mim, pic_type, desc, data = pic[mime_pos, pic.length].unpack('Z*hZ*a*') if mime != "dat" and (!data.match(start) or data.nil?) real_start = pic =~ start data = pic[real_start, pic.length] end if mime == "dat" # if no jpg or png, extract data anyway e.g. gif mime, desc, data = pic.unpack('h Z* h Z* a*').values_at(1,3,4) end if mime == "jpg" # inspect jpg image header (first 10 chars) for "\xFF\x00" (expect "\xFF") trailing_null_byte = Regexp.new("(\377)(\000)".force_encoding('BINARY'), Regexp::FIXEDENCODING) if (data =~ trailing_null_byte) < 10 data.gsub!(trailing_null_byte, "\xff".force_encoding('BINARY')) end end desc = "%02i_#{desc[0,25]}" % (index + 1) filename = desc.match("#{mime}$") ? desc : "#{desc}.#{mime}" filename.gsub!('/','') picture[0] = filename picture[1] = data result << picture end result end
# File lib/mp3info/id3v2.rb, line 344 def remove_pictures self.APIC = "" self.PIC = "" end
dump tag for writing. Version is always 2.3.0
# File lib/mp3info/id3v2.rb, line 382 def to_bin #TODO handle of @tag2[TLEN"] #TODO add of crc #TODO add restrictions tag tag = "" @hash.each do |k, v| next unless v next if v.respond_to?("empty?") and v.empty? # Automagically translate V2 to V3 tags k = TAG_MAPPING_2_2_to_2_3[k] if TAG_MAPPING_2_2_to_2_3.has_key?(k) # doesn't encode id3v2.2 tags, which have 3 characters next if k.size != 4 # Output one flag for each array element, or one only if it's not an array [v].flatten.each do |value| data = encode_tag(k, value.to_s) #data << "\x00"*2 #End of tag tag << k[0,4] #4 characte max for a tag's key #tag << to_syncsafe(data.size) #+1 because of the language encoding byte size = data.size unless RUBY_1_8 size = data.dup.force_encoding("binary").size end tag << [size].pack("N") #+1 because of the language encoding byte tag << "\x00"*2 #flags tag << data end end tag_str = "ID3" #version_maj, version_min, unsync, ext_header, experimental, footer tag_str << [ 3, 0, "0000" ].pack("CCB4") tag_str << [to_syncsafe(tag.size)].pack("N") tag_str << tag puts "tag in binary format: #{tag_str.inspect}" if $DEBUG tag_str end
cuts out long tag values from hash for display on screen
# File lib/mp3info/id3v2.rb, line 220 def to_inspect_hash result = Marshal.load(Marshal.dump(self.to_hash)) result.each do |k,v| if v.is_a? Array v.each_index do |i, item| if (v[i].is_a? String and v[i].length > 128) result[k][i] = pretty_header(v[i]) end end elsif v.is_a? String and v.length > 128 result[k] = pretty_header(v) # this method 'snips' long data end end result end
full version of this tag (like “2.3.0”) or nil if tag was not correctly read
# File lib/mp3info/id3v2.rb, line 211 def version if @version_maj && @version_min "2.#{@version_maj}.#{@version_min}" else nil end end
Private Instance Methods
Add data to tag2 read lang_encoding, decode data if unicode and create an array if the key already exists in the tag
# File lib/mp3info/id3v2.rb, line 549 def add_value_to_tag2(name, size) puts "add_value_to_tag2" if $DEBUG if size > 50_000_000 raise ID3v2Error, "tag size is > 50_000_000" end data_io = @io.read(size) data = decode_tag(name, data_io) if data && !data.empty? if self.keys.include?(name) if self[name].is_a?(Array) unless self[name].include?(data) self[name] << data end else self[name] = [ self[name], data ] end else self[name] = data end if name == "TPOS" && data =~ /(\d+)\s*\/\s*(\d+)/ self["disc_number"] = $1.to_i self["disc_total"] = $2.to_i end end puts "self[#{name.inspect}] = #{self[name].inspect}" if $DEBUG end
Read a tag from file and perform UNICODE translation if needed
# File lib/mp3info/id3v2.rb, line 449 def decode_tag(name, raw_value) puts("decode_tag(#{name.inspect}, #{raw_value.inspect})") if $DEBUG if name =~ /^(T|COM)/ if name =~ /^COM/ #FIXME improve this encoding_index, lang, raw_tag = raw_value.unpack("ca3a*") if encoding_index == 1 comment = Mp3Info::EncodingHelper.decode_utf16(raw_tag) e = comment.encoding out = comment.force_encoding("BINARY").split("\x00\x00").last.force_encoding(e) p out comment = Mp3Info::EncodingHelper.decode_utf16(raw_tag) split_val = RUBY_1_8 ? "\x00\x00" : "\x00".encode(comment.encoding) out = comment.split(split_val).last rescue "" else comment, out = raw_tag.split("\x00", 2) end puts "COM tag found. encoding: #{encoding_index} lang: #{lang} str: #{out.inspect}" if $DEBUG else encoding_index = raw_value.getbyte(0) # language encoding (see TEXT_ENCODINGS constant) out = raw_value[1..-1] end # we need to convert the string in order to match # the requested encoding if encoding_index && TEXT_ENCODINGS[encoding_index] && out if RUBY_1_8 out = Mp3Info::EncodingHelper.convert_to(out, TEXT_ENCODINGS[encoding_index], "utf-8") else if encoding_index == 1 out = Mp3Info::EncodingHelper.decode_utf16(out) else out.force_encoding(TEXT_ENCODINGS[encoding_index]) end if out out.encode!("utf-8") end end end if out # remove padding zeros for textual tags if RUBY_1_8 r = /\0*$/ else r = Regexp.new("\x00*$".encode(out.encoding)) end out.sub!(r, '') end return out else return raw_value end end
# File lib/mp3info/id3v2.rb, line 426 def encode_tag(name, value) puts "encode_tag(#{name.inspect}, #{value.inspect})" if $DEBUG name = name.to_s if name =~ /^(COM|T)/ transcoded_value = Mp3Info::EncodingHelper.convert_to(value, "utf-8", "utf-16") end case name when "COMM" puts "encode COMM: lang: #{@options[:lang]}, value #{transcoded_value.inspect}" if $DEBUG s = [ 1, @options[:lang], "\xFE\xFF\x00\x00", transcoded_value].pack("ca3a*a*") return s when /^T/ unless RUBY_1_8 transcoded_value.force_encoding("BINARY") end return "\x01" + transcoded_value else return value end end
this is especially useful for printing out APIC data because only the header of the APIC tag is of interest The result also shows some bytes escaped for cleaner display
# File lib/mp3info/id3v2.rb, line 597 def pretty_header(str, chars=128) "#{str.unpack("a#{chars}").first}<<<...snip...>>>".force_encoding('BINARY').inspect[1..-2] end
reads id3 ver 2.2.x frames and adds the contents to @tag2 hash NOTE: the id3v2 header does not take padding zero's into consideration
# File lib/mp3info/id3v2.rb, line 531 def read_id3v2_2_frames loop do name = @io.read(3) if name.nil? || name.getbyte(0) == 0 @io.seek(-3, IO::SEEK_CUR) seek_to_v2_end break else size = (@io.getbyte << 16) + (@io.getbyte << 8) + @io.getbyte add_value_to_tag2(name, size) break if @io.pos >= @tag_length end end end
reads id3 ver 2.3.x/2.4.x frames and adds the contents to @tag2 hash NOTE: the id3v2 header does not take padding zero's into consideration
# File lib/mp3info/id3v2.rb, line 508 def read_id3v2_3_frames loop do # there are 2 ways to end the loop name = @io.read(4) if name.nil? || name.getbyte(0) == 0 || name == "MP3e" #bug caused by old tagging application "mp3ext" ( http://www.mutschler.de/mp3ext/ ) @io.seek(-4, IO::SEEK_CUR) # 1. find a padding zero, seek_to_v2_end break else if @version_maj == 4 size = @io.get_syncsafe else size = @io.get32bits end @io.seek(2, IO::SEEK_CUR) # skip flags puts "name '#{name}' size #{size}" if $DEBUG add_value_to_tag2(name, size) end break if @io.pos >= @tag_length # 2. reach length from header end end
runs thru @file one char at a time looking for best guess of first MPEG
frame, which should be first 0xff byte after id3v2 padding zero's
# File lib/mp3info/id3v2.rb, line 582 def seek_to_v2_end until @io.getbyte == 0xff raise ID3v2Error, "got EOF before finding id3v2 end" if @io.eof? end @io.seek(-1, IO::SEEK_CUR) end
convert an 32 integer to a syncsafe string
# File lib/mp3info/id3v2.rb, line 590 def to_syncsafe(num) ( (num<<3) & 0x7f000000 ) + ( (num<<2) & 0x7f0000 ) + ( (num<<1) & 0x7f00 ) + ( num & 0x7f ) end