ID3 Tags (#136)

My corrected version :

require “delegate”

class ID3Tags < DelegateClass(Struct)
MP3_TYPE=[“Blues”,“Classic
Rock”,“Country”,“Dance”,“Disco”,“Funk”,“Grunge”,“Hip-
Hop”,“Jazz”,“Metal”,“New
Age”,“Oldies”,“Other”,“Pop”,“R&B”,“Rap”,“Reggae”,“Rock”,“Techno”,“Industrial”,“Alternative”,“Ska”,“Death
Metal”,“Pranks”,“Soundtrack”,“Euro-Techno”,“Ambient”,“Trip-
Hop”,“Vocal”,“Jazz
+Funk”,“Fusion”,“Trance”,“Classical”,“Instrumental”,“Acid”,“House”,“Game”,“Sound
Clip”,“Gospel”,“Noise”,“AlternRock”,“Bass”,“Soul”,“Punk”,“Space”,“Meditative”,“Instrumental
Pop”,“Instrumental Rock”,“Ethnic”,“Gothic”,“Darkwave”,“Techno-
Industrial”,“Electronic”,“Pop-Folk”,“Eurodance”,“Dream”,“Southern
Rock”,“Comedy”,“Cult”,“Gangsta”,“Top 40”,“Christian Rap”,“Pop/
Funk”,“Jungle”,“Native American”,“Cabaret”,“New
Wave”,“Psychadelic”,“Rave”,“Showtunes”,“Trailer”,“Lo-
Fi”,“Tribal”,“Acid Punk”,“Acid Jazz”,“Polka”,“Retro”,“Musical”,“Rock &
Roll”,“Hard Rock”,“Folk”,“Folk-Rock”,“National Folk”,“Swing”,“Fast
Fusion”,“Bebob”,“Latin”,“Revival”,“Celtic”,“Bluegrass”,“Avantgarde”,“Gothic
Rock”,“Progressive Rock”,“Psychedelic Rock”,“Symphonic Rock”,“Slow
Rock”,“Big Band”,“Chorus”,“Easy
Listening”,“Acoustic”,“Humour”,“Speech”,“Chanson”,“Opera”,“Chamber
Music”,“Sonata”,“Symphony”,“Booty Bass”,“Primus”,“Porn
Groove”,“Satire”,“Slow
Jam”,“Club”,“Tango”,“Samba”,“Folklore”,“Ballad”,“Power
Ballad”,“Rhythmic Soul”,“Freestyle”,“Duet”,“Punk Rock”,“Drum Solo”,“A
capella”,“Euro-House”,“Dance Hall”]

Tag=Struct.new(:song,:album,:artist,:year,:comment,:track,:genre)

def initialize(file)
raise “No ID3 Tag detected” unless File.size(file) > 128
File.open(file,“r”) do |f|
f.seek(-128, IO::SEEK_END)
tag = f.read.unpack(‘A3A30A30A30A4A30C1’)
raise “No ID3 Tag detected” unless tag[0] == ‘TAG’
if tag[5][-2] == 0 and tag[5][-1] != 0
tag[5]=tag[5].unpack(‘A28A1C1’).values_at(0,2)
else
tag[5]=[tag[5],nil]
end
super(@tag=Tag.new(*tag.flatten[1…-1]))
end
end

def to_s
  members.each do |name|

puts “#{name} : #{send(name)}”
end
end

def genre
  MP3_TYPE[@tag.genre]
end

end

On Aug 26, 2007, at 1:08 PM, James Edward G. II wrote:

ARGF.read[-128…-1].unpack(“A3A30A30A30A4A30C”)

Well played, sir. I always forget about ARGF. And to think I call
myself a Perl nerd.

-be

On Mon, 27 Aug 2007 02:32:05 +0900, Brad E. wrote:

%w(hpricot open-uri).each(&method(:require))

fields, genres = (Hpricot(open(“Ruby Quiz - ID3 Tags (#136)”)) / “p.example”).map{|e| e.inner_html}
fields = fields.split
genres = genres.split “

You hard-coded the value of the unpack field. If you wanted to download
the spec
properly, you’d generate that from the spec like follows. (Picking up
from the end
of what I’ve quoted above)

unpacktypes=Hash.new(“A30”)
unpacktypes[“TAG”]=“A3”
unpacktypes[“year”]=“A4”
unpacktypes[“genre”]=“c”
unpackstr=fields.map{|x| unpacktypes[x]}.join

id3=Hash.new
raw=open(‘/home/bloom/scratch/music/rondo.mp3’) do |f|
f.seek(f.lstat.size-128)
f.read
end

values=raw.unpack(unpackstr)

fields.zip(values).each do |field,value|
id3[field]=value
end

fail if id3[“TAG”]!=“TAG”

if id3[“comment”].length==30 and id3[“comment”][-2]==0
id3[“track”]=id3[“comment”][-1]
id3[“comment”]=id3[“comment”][0…-2].strip
end

id3[“genre”]=genres[id3[“genre”]] || “Unknown”
p id3

On Sun, 26 Aug 2007 09:16:32 -0500, Ken B. wrote:

Grunge
Rock
Vocal
Noise
Darkwave
Christian Rap
Tribal
Swing
Symphonic Rock
Sonata
Ballad

attr_accessor :title, :artist, :album, :year, :comment, :genre, :track
“A3A30A30A30A4A30c” if rawdata[3+30+30+30+4+28]==0
@track=rawdata[3+30+30+30+4+29]
@track=nil if @track==0
end
if tag!=“TAG”
raise NoID3Error
end
end
end

Apparently unpack(‘A30’) doesn’t work quite the way I thought –
it only shortens the string if the string ends in null characters.
If there are nulls in the middle, then those and the characters after
them are preserved.

–Ken

Brad E. schrieb:

On Aug 26, 2007, at 1:08 PM, James Edward G. II wrote:

ARGF.read[-128…-1].unpack(“A3A30A30A30A4A30C”)

Well played, sir. I always forget about ARGF. And to think I call myself
a Perl nerd.
What the heck is ARGF?

On Aug 26, 2007, at 2:40 PM, Ken B. wrote:

You hard-coded the value of the unpack field.

I know, I felt bad about doing it (and this was more of a “ha-ha,
have fun with the Quiz” submission than a “use this in production”
submission).

I was about to rewrite it to scrape the actual data structure from
the table in http://www.id3.org/ID3v1, but then I’d have to find
another quasi-official source for the genre list, and it began to
feel more like work.

I like your solution. Yes, I should have used a “c” for the genre
field, but my brain wasn’t working.

-be

Johannes Held wrote:

What the heck is ARGF?

It’s a pseudo-IO that reads the concatenation of the files named in
ARGV, unless ARGV is empty, in which case it just reads standard input.
It’s very useful in writing little command-line programs that can be
used as filters or on a list of named files (after you delete any
switches or options from the command line).

[~] cat >foo.txt
foo
[~] cat >bar.txt
bar
[~] ruby -e ‘puts ARGF.read’ foo.txt bar.txt
foo
bar

[~] echo zap | ruby -e ‘puts ARGF.read’
zap

Hey all, here’s another one for you. I admit that there isn’t anything
special about it… I think it’s one of the more direct solutions (i.e.
Nothing clever here guys). I didn’t see a reason to include the entire
genre, so it’s attached in a separate file. It simply declares a
constant
(an array which is indexed in read_tags).

Tom

–BEGIN SOLUTION–
require ‘id3_tag_genre’

class NoTagError < RuntimeError; end

class Mp3
attr_reader :song, :artist, :album, :year, :comment, :genre, :track

def initialize(file)
read_tags(file)
end

def read_tags(file)
begin
size = File.stat(file).size
f = File.open(file)
f.pos = size - 128
tag = f.read
raise NoTagError unless tag[0…2] == “TAG”
@song = tag[3…32].strip
@artist = tag[33…62].strip
@album = tag[63…92].strip
@year = tag[93…96].strip
@comment = tag[97…126]
if @comment[28] == 0 && @comment[29] != 0
@track = @comment[29…29].to_i
@comment = @comment[0…28].strip
end
@genre = Genre[tag[127]]
rescue NoTagError
puts “No tags found!”
return false
end
true
end
end

Joel VanderWerf schrieb:

Johannes Held wrote:

What the heck is ARGF?>
It’s a pseudo-IO that reads the concatenation of the files named in
ARGV, unless ARGV is empty, in which case it just reads standard input.
It’s very useful in writing little command-line programs that can be
used as filters or on a list of named files (after you delete any
switches or options from the command line).
Thank you.

Here’s mine. Takes a directory as input and exports a tab-seperated
list.

  • Erik

GENRES = [“Blues”, “Classic Rock”, “Country”, “Dance”, “Disco”,
“Funk”, “Grunge”, “Hip-Hop”, “Jazz”, “Metal”, “New Age”, “Oldies”,
“Other”, “Pop”, “R&B”, “Rap”, “Reggae”, “Rock”, “Techno”,
“Industrial”, “Alternative”, “Ska”, “Death Metal”, “Pranks”,
“Soundtrack”, “Euro-Techno”, “Ambient”, “Trip-Hop”, “Vocal”, “Jazz
+Funk”, “Fusion”, “Trance”, “Classical”, “Instrumental”, “Acid”,
“House”, “Game”, “Sound Clip”, “Gospel”, “Noise”, “AlternRock”,
“Bass”, “Soul”, “Punk”, “Space”, “Meditative”, “Instrumental Pop”,
“Instrumental Rock”, “Ethnic”, “Gothic”, “Darkwave”, “Techno-
Industrial”, “Electronic”, “Pop-Folk”, “Eurodance”, “Dream”, “Southern
Rock”, “Comedy”, “Cult”, “Gangsta”, “Top 40”, “Christian Rap”, “Pop/
Funk”, “Jungle”, “Native American”, “Cabaret”, “New Wave”,
“Psychadelic”, “Rave”, “Showtunes”, “Trailer”, “Lo-Fi”, “Tribal”,
“Acid Punk”, “Acid Jazz”, “Polka”, “Retro”, “Musical”, “Rock & Roll”,
“Hard Rock”, “Folk”, “Folk-Rock”, “National Folk”, “Swing”, “Fast
Fusion”, “Bebob”, “Latin”, “Revival”, “Celtic”, “Bluegrass”,
“Avantgarde”, “Gothic Rock”, “Progressive Rock”, “Psychedelic Rock”,
“Symphonic Rock”, “Slow Rock”, “Big Band”, “Chorus”, “Easy Listening”,
“Acoustic”, “Humour”, “Speech”, “Chanson”, “Opera”, “Chamber Music”,
“Sonata”, “Symphony”, “Booty Bass”, “Primus”, “Porn Groove”, “Satire”,
“Slow Jam”, “Club”, “Tango”, “Samba”, “Folklore”, “Ballad”, “Power
Ballad”, “Rhythmic Soul”, “Freestyle”, “Duet”, “Punk Rock”, “Drum
Solo”, “A capella”, “Euro-House”, “Dance Hall”]
FIELDS = [:song, :artist, :album, :year, :comment, :genre]

def find_track_number(fields)
if fields[:comment][-2] == 0 && fields[:comment][-1] != 0
fields[:track_number] = fields[:comment].slice!(-2…-1)[1]
fields[:comment].strip!
end
end

abort “Usage: #{File.basename($PROGRAM_NAME)} " unless ARGV.size
== 1
Dir[”#{ARGV.first}/*.mp3"].each do |path|
File.open(path, ‘rb’) do |f|
f.seek(-128, IO::SEEK_END)
bytes = f.read
next if bytes.slice!(0…2) != “TAG”

tags = Hash[*FIELDS.zip(bytes.unpack('A30A30A30A4A30C')).flatten]
tags[:genre] = GENRES[tags[:genre]]
find_track_number(tags)
puts "#{File.basename(path)}\t#{tags[:artist]}\t#{tags[:song]}

\t#{tags[:album]}\t#{tags[:track_number]}\t#{tags[:year]}
\t#{tags[:genre]}\t#{tags[:comment]}"
end
end

class ID3reader

attr_reader :song, :album, :artist, :comment,:year,:genre,:track
TAG = 3
SONG = 30
ALBUM = 30
ARTIST = 30
YEAR = 4
COMMENT = 30
GENRE = 1
GENRE_LIST = [“Blues”,“Classic
Rock”,“Country”,“Dance”,“Disco”,“Funk”,“Grunge”,“Hip-Hop”,“Jazz”,“Metal”,
“New
Age”,“Oldies”,“Other”,“Pop”,“R&B”,“Rap”,“Reggae”,“Rock”,“Techno”,“Industrial”,
“Alternative”,“Ska”,“Death
Metal”,“Pranks”,“Soundtrack”,“Euro-Techno”,“Ambient”,“Trip-Hop”,“Vocal”,

“Jazz+Funk”,“Fusion”,“Trance”,“Classical”,“Instrumental”,“Acid”,“House”,“Game”,“Sound
Clip”,

“Gospel”,“Noise”,“AlternRock”,“Bass”,“Soul”,“Punk”,“Space”,“Meditative”,“Instrumental
Pop”,“Instrumental Rock”,

“Ethnic”,“Gothic”,“Darkwave”,“Techno-Industrial”,“Electronic”,“Pop-Folk”,“Eurodance”,“Dream”,“Southern
Rock”,
“Comedy”,“Cult”,“Gangsta”,“Top 40”,“Christian
Rap”,“Pop/Funk”,“Jungle”,“Native American”,“Cabaret”,“New Wave”,

“Psychadelic”,“Rave”,“Showtunes”,“Trailer”,“Lo-Fi”,“Tribal”,“Acid
Punk”,“Acid Jazz”,“Polka”,“Retro”,“Musical”,
“Rock & Roll”,“Hard Rock”,“Folk”,“Folk-Rock”,“National
Folk”,“Swing”,“Fast Fusion”,“Bebob”,“Latin”,“Revival”,
“Celtic”,“Bluegrass”,“Avantgarde”,“Gothic
Rock”,“Progressive
Rock”,“Psychedelic Rock”,“Symphonic Rock”,“Slow Rock”,
“Big Band”,“Chorus”,“Easy
Listening”,“Acoustic”,“Humour”,“Speech”,“Chanson”,“Opera”,
“Chamber Music”,“Sonata”,“Symphony”,“Booty
Bass”,“Primus”,“Porn Groove”,“Satire”,
“Slow
Jam”,“Club”,“Tango”,“Samba”,“Folklore”,“Ballad”,“Power Ballad”,“Rhythmic
Soul”,
“Freestyle”,“Duet”,“Punk Rock”,“Drum Solo”,“A
capella”,“Euro-House”,“Dance Hall”]

def initialize(mp3_file_path)

mp3file = File.open(mp3_file_path,"r")
mp3file.seek(-128, IO::SEEK_END)

unless mp3file.read(TAG) != "TAG"
  @song = mp3file.read(SONG).strip
    @artist = mp3file.read(ARTIST).strip
  @album = mp3file.read(ALBUM).strip
  @year = mp3file.read(YEAR).strip
    @comment = mp3file.read(COMMENT)
  unless (@comment[28..29] =~ /\0[:cntrl:]?/).nil?
    @track = @comment[29].to_i
    @comment[29]=0
  end
  @comment.strip!
  @genre = GENRE_LIST[mp3file.read(GENRE).to_i]
  mp3file.close
end

end
end

irb(main):001:0> require ‘id3reader’

irb(main):003:0> ID3reader.new(“5.mp3”) #No tag’s
=> #ID3reader:0xb7ce3814

irb(main):004:0> ID3reader.new(“10.mp3”)
=> #<ID3reader:0xb7cde1c0 @year=“1995”, @genre=“Blues”,
@album=“undiscovered
soul”, @track=10, @artist=“richie sambora”, @comment=“Otro
temazo!oooooooooooooooo”, @song=“who I am”>
irb(main):005:0>

Juan M. Repetti

My fairly straightforward solution:

class ID3
genre_list = <<-GENRES
Blues
… # snipped for brevity
Dance Hall
GENRES

GENRE_LIST = genre_list.split("\n")
TAGS = [ :title, :artist, :album, :year, :comment, :track, :genre ]

attr_accessor *TAGS

def initialize(filename)
id3 = File.open(filename) do |mp3|
mp3.seek(-128, IO::SEEK_END)
mp3.read
end

raise "No ID3 tags" if id3 !~ /^TAG/

@title, @artist, @album, @year, @comment, @genre =

id3.unpack(‘xxxA30A30A30A4A30C1’)
@comment, @track = @comment.unpack(‘Z*@28C1’) if @comment =~ /
\0.$/

@genre = GENRE_LIST[@genre]

end
end

if FILE == $0
id3 = ID3.new(ARGV.shift)
ID3::TAGS.each do |tag|
puts “#{tag.to_s.capitalize.rjust(8)}: #{id3.send(tag)}”
end
end

Here is my go at things:

BEGIN
#Note: this script assumes Ruby 1.8.6 style handeling of strings. Some
changes
#will need to be made for Ruby 1.9 to work correctly

require ‘genre.rb’ #an array of the official genera list

def id3(filename)
id3 = File.open(filename,‘r’) do |file|
file.seek(-128,IO::SEEK_END) #get to the end of the file
file.read(128)
end
return “” unless id3 #protect against read error
if id3.slice(0,3) == “TAG”
#Skip the first 3 bytes grab three thirty byte fields
#and a 4 byte field dropping trailing whitespace.
#While we can assume the old style comment field and
#take 30 bytes (we’ll com back for the track number later)
#we must use ‘Z’ instead of ‘A’ to avoid having the track
#show up in our comment field.
#The last byte is the genre index.
song,artist,album,year,comment,genre = id3.unpack
“x3A30A30A30A4Z30C”
#grab the track with a pain slice
track = id3.slice(-2) if id3.slice(-3) == 0 && id3.slice(-2) != 0
desc = “#{artist}: #{album}(#{year})\n”
desc << " #{song}. "
desc << “tr. #{track}” if track
desc <<"\n"
desc << " Comment: #{comment.chomp(" “)}\n” if comment.length != 0
desc << " Genre: #{Genres[genre]}\n"
return desc
end

return “” #tag not forund

end

#usage id3.rb filename [filename*]
ARGV.each do |filename|
puts filename
puts id3(filename) if File.exists? filename
puts “\n”
end

END

I think the only real difference between what I’m seeing on this list
and my own solution is the unpack string. The ‘Comment’ filed must use
‘Z’ and strip trailing white space separately otherwise the track number
could get pulled and stuck on the end of the output.

I like the use of ARGF in other implementations. Something new to put
in my hat.

John M.

On Aug 29, 2007, at 6:56 PM, Matthew M. wrote:

I’ve been extremely busy lately, but I wanted to give this one a try.
This solution is not complete as far as the problem specification
goes, but my bit o’ metaprogramming-type stuff works, though I’d have
liked to push it further.

This is a very clever solution. I have one suggestion though…

class ID3

@@recLen = 0

def ID3.field(name, len, flags=[])

Changing flags=[] to *flags gives a nicer interface, I think.

James Edward G. II

On 8/29/07, James Edward G. II [email protected] wrote:

@@recLen = 0

def ID3.field(name, len, flags=[])

Changing flags=[] to *flags gives a nicer interface, I think.

True… I had thought of that this morning, though I also wanted to
add a conversion parameter… so a lambda or block could be provided
that would convert between the record’s string data and an integer
(e.g. the ID3 year).

On 8/30/07, Matthew M. [email protected] wrote:

class ID3
(e.g. the ID3 year).
And, of course, the whole field/record thingy should be separated out
into its own class/module/whatever. I did see bit-struct out there,
and considered a solution using that, but it felt weird to be doing
things at a bit-level, so I just kept on with my own.

I’ve been extremely busy lately, but I wanted to give this one a try.
This solution is not complete as far as the problem specification
goes, but my bit o’ metaprogramming-type stuff works, though I’d have
liked to push it further.

class ID3

@@recLen = 0

def ID3.field(name, len, flags=[])
class_eval(%Q[
def #{name}
@data[#{@@recLen}, #{len}].strip
end
])

  unless flags.include?(:readonly)
     class_eval(%Q[
        def #{name}=(val)
           # need to pad val to len
           @data[#{@@recLen}, #{len}] = val.ljust(#{len}, "\000")
        end
     ])
  end
  @@recLen += len

end

--------------------------------------------------------------

name, length, flags

field :sig, 3, [:readonly]
field :song, 30
field :album, 30
field :artist, 30
field :year, 4
field :comment, 30
field :genre, 1

TAG_SIG = “TAG”
TAG_SIZE = @@recLen
raise “ID3 tag size not 128!” unless TAG_SIZE == 128

--------------------------------------------------------------

def ID3.createFromBuffer(buffer)
ID3.new(buffer)
end

def ID3.createFromFile(fname)
size = File.size?(fname)
raise “Missing or empty file” unless size
raise “Invalid file” if size < TAG_SIZE

  # Read the tag and pass to createFromBuffer
  open(fname, "rb") do |f|
     f.seek(-TAG_SIZE, IO::SEEK_END)
     createFromBuffer(f.read(TAG_SIZE))
  end

end

--------------------------------------------------------------

def initialize(data)
@data = data

  raise "Wrong buffer size" unless @data.size == TAG_SIZE
  raise "ID3 tag not found" unless self.sig == TAG_SIG

end

end

id = ID3.createFromFile(“maple-leaf-rag.mp3”)
puts id.song