# # $Id$ # # Copyright (C) 2004 - 2005 Tilman Sauerbeck (tilman at code-monkey de) # require "socket" require "ftools" module CDDB VERSION = "0.1" class Track attr_reader :lba, :seconds, :frames, :title, :is_audio attr_writer :is_audio def initialize(title = "") @lba = 0 @seconds = 0 @frames = 0 @title = title @is_audio = true end def lba=(lba) @lba = lba @frames = lba + 150 @seconds = @frames / 75 end def is_audio? @is_audio end end class TocReader attr_reader :tracks, :total_length def initialize @tracks = [] @total_length = 0 end def read_toc(device) end end class TocReaderLinux < TocReader CDROMREADTOCHDR = 0x5305 CDROMREADTOCENTRY = 0x5306 CDROM_LBA = 0x01 CDROM_LEADOUT = 0xaa def read_toc(device) @tracks = [] @total_length = 0 File.open(device) do |f| buf = "" f.ioctl(CDROMREADTOCHDR, buf) first, last = buf.unpack("CC") last += 1 # leadout first.upto(last) do |i| track = Track.new t = (i == last) ? CDROM_LEADOUT : i buf = [t, 0, CDROM_LBA].pack("CCC") f.ioctl(CDROMREADTOCENTRY, buf) adr, ctrl, track.lba = buf.unpack("xhXHxxI") puts "Track #{i} ctrl = #{ctrl}, adr = #{adr}, lba = #{track.lba}" flags = (adr.to_i << 4) | (ctrl.to_i & 0x0f) track.is_audio = ((flags & 4) == 0) @tracks << track end @total_length = @tracks.pop.seconds [@tracks, @total_length] end end end class TocReaderBSD < TocReader CDIOREADTOCHEADER = 0x40046304 CDIOREADTOCENTRYS = 0xc0086305 CD_LBA_FORMAT = 1 def read_toc(device) @tracks = [] @total_length = 0 File.open(device) do |f| buf = "" f.ioctl(CDIOREADTOCHEADER, buf) first, last = buf[2, 2].unpack("CC") last += 1 # leadout num_tracks = last - first siz = 8 * num_tracks toc = " " * siz hi = (siz / 256) lo = (siz & 255) buf = [CD_LBA_FORMAT, 0, lo, hi, toc].pack("CCCCP8l") f.ioctl(CDIOREADTOCENTRYS, buf) first.upto(last) do |i| track = Track.new ctrl, adr, track.lba = buf.unpack("xhXHxxxI") puts "Track #{i} ctrl = #{ctrl}, adr = #{adr}, lba = #{track.lba}" flags = (adr.to_i << 4) | (ctrl.to_i & 0x0f) track.is_audio = ((flags & 4) == 0) @tracks << track end end @total_length = @tracks.pop.seconds [@tracks, @total_length] end end class Disc attr_reader :id, :tracks, :total_length, :category attr_accessor :artist, :title, :year, :genre def initialize(opts = {}) @id = opts[:id] || "" @tracks = opts[:tracks] || [] @total_length = opts[:total_length] || 0 @category = opts[:category] || "" @title = opts[:title] || "" @artist = "" @year = 0 @genre = "" end def compute_id(device) @tracks = [] @id = "" case RUBY_PLATFORM when /linux/i reader = TocReaderLinux.new when /bsd/i reader = TocReaderBSD.new end if reader.nil? raise "Platform not supported yet" else @tracks, @total_length = reader.read_toc(device) end checksum = @tracks.inject(0) do |res, t| t.seconds.to_s.each_byte { |x| res += x - ?0 } res end t = @total_length - @tracks.first.seconds id = (checksum % 255) << 24 | t << 8 | @tracks.length @id = "%08x" % id end def num_audio_tracks @tracks.inject(0) do |res, t| res + (t.is_audio ? 1 : 0) end end end class CDDB def initialize @conn = nil @dot_cddb = File.expand_path("~/.cddb") end def login(thost = "freedb.org", port = 8880, user = ENV["USER"], host = `hostname`.strip) @conn = TCPSocket.new(thost, port) @conn.gets @conn.puts("proto 5") unless @conn.gets =~ /^201 .*/ @conn.close @conn = nil raise "Protocol level 5 not supported" end @conn.puts("cddb hello #{user} #{host} " + "ruby-cddb #{VERSION}") if @conn.gets.strip =~ /^4\d\d/ @conn.close @conn = nil raise "Cannot connect" end end def logged_in? !@conn.nil? end def logout return unless @conn @conn.puts("quit") @conn = nil end def lookup_local(disc) cats = ["blues", "classical", "country", "data", "folk", "jazz", "misc", "newage", "reggae", "rock", "soundtrack"] results = [] return results unless File.directory?(@dot_cddb) cats.each do |c| f = File.join(@dot_cddb, c, disc.id) if File.exists?(f) opts = {:category => c, :id => disc.id} results << Disc.new(opts) end end results end def lookup(disc) raise(StandardError, "not connected") unless @conn raise(ArgumentError, "argument is not a disc") unless disc.is_a?(Disc) if disc.id == "" || disc.tracks.empty? raise "Need to call Disc#compute_id first" end results = [] @conn.puts(query_str(disc)) r = @conn.gets.strip case r when /^200 (.+)/ results << disc_from_match($1) when /^210 (.+)/, /^211 (.+)/ until (line = @conn.gets.strip) =~ /^\./ results << disc_from_match(line) end when /^4\d\d/ raise r end results end def get_details(disc) raise(StandardError, "not connected") unless @conn raise(ArgumentError, "argument is not a disc") unless disc.is_a?(Disc) if disc.id == "" || disc.category == "" raise "Need to call Disc#compute_id first" end @conn.puts(read_str(disc)) r = @conn.gets.strip case r when /^210/ # xmcd info follows, # dump it to hdd if it's not already there dir = File.join(@dot_cddb, disc.category) File.mkpath(dir) unless File.directory?(dir) tmp = File.join(dir, disc.id) f = File.exists?(tmp) ? nil : File.open(tmp, "w") begin until (line = @conn.gets.strip) =~ /^\./ f.puts(line) unless f.nil? parse_xmcd_data(line, disc) end ensure f.close unless f.nil? end when /^4\d\d/ raise r end disc end def get_details_local(disc) raise ArgumentError unless disc.is_a?(Disc) tmp = File.join(@dot_cddb, disc.category, disc.id) return unless File.exists?(tmp) File.open(tmp, "r") do |f| while !(line = f.gets).nil? parse_xmcd_data(line.strip, disc) end end disc end private def disc_from_match(match) unless match =~ /(\w+) (\w+) (.*)$/ raise "Malformed query result: " + match end opts = {:category => $1, :id => $2, :title => $3} Disc.new(opts) end def query_str(disc) buf = disc.id + " " + disc.tracks.length.to_s disc.tracks.each { |t| buf += " " + t.frames.to_s } "cddb query #{buf} #{disc.total_length.to_s}" end def read_str(disc) "cddb read #{disc.category} #{disc.id}" end def parse_xmcd_data(line, disc) return if line =~ /^#/ case line when /^DTITLE=(.*)$/ disc.artist, disc.title = $1.split(/ \/ /, 2) disc.title ||= disc.artist when /^DYEAR=(\d{4})$/ disc.year = $1.to_i when /^DGENRE=(.*)$/ disc.genre = $1 when /^TTITLE(\d+)=(.*)$/ disc.tracks[$1.to_i] = Track.new($2) end end end end if $0 == __FILE__ raise "Argument missing" unless ARGV.length > 0 disc = CDDB::Disc.new disc.compute_id(ARGV.shift) puts "Disc ID: #{disc.id}" end