#
# $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
