[QUIZ] ID3 Tags (#136)

K

Ken Bloom

One of the biggest problems in software development is feature creep. In
the case of this Quiz, specification creep was the culprit, with the
spec being changed two times in two days. No offense intended, JEG2 ;-)

Luckily, we can use the mighty power of Ruby to make our application
impervious to such changes, and save a couple heredocs to boot.

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

#!/usr/bin/env ruby -rubygems

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

fields, genres = (Hpricot(open("http://www.rubyquiz.com/quiz136.html")) / "p.example").map{|e| e.inner_html}
fields = fields.split
genres = genres.split "<br />"

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
 
K

Ken Bloom

class NoID3Error < StandardError
end

class ID3
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".split("\n").map{|x| x.gsub(/^\s+/,'')}

attr_accessor :title, :artist, :album, :year, :comment, :genre, :track
def genre_name
Genres[@genre]
end

def initialize(filename)
rawdata=open(filename) do |f|
f.seek(f.lstat.size-128)
f.read
end
tag,@title,@artist,@album,@year,@comment,@genre=rawdata.unpack
"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
 
B

Brad Ediger

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
 
J

Joel VanderWerf

Johannes said:
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
 
J

Johannes Held

Joel said:
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.
 
E

Erik Bryn

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)} <dir>" 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
 
A

Alpha Chen

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
 
J

John Miller

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 Miller
 
M

Matthew Moss

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
 
J

James Edward Gray II

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=85
class ID3

@@recLen =3D 0

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

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

James Edward Gray II
 
M

Matthew Moss

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=85
class ID3

@@recLen =3D 0

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

Changing flags=3D[] 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).
 
M

Matthew Moss

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=85
class ID3

@@recLen =3D 0

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

Changing flags=3D[] 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).


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.
 

Ask a Question

Want to reply to this thread or ask your own question?

You'll need to choose a username for the site, which only take a couple of moments. After that, you can post your question and our members will help you out.

Ask a Question

Members online

No members online now.

Forum statistics

Threads
473,995
Messages
2,570,236
Members
46,821
Latest member
AleidaSchi

Latest Threads

Top