#!/usr/bin/env ruby
#
# Copyright (c) 2004-2006 Tilman Sauerbeck (tilman at code-monkey de)
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

$VERBOSE = true

require "xmmsclient"
require "event-loop"
require "net/http"
require "cgi"
require "md5"
require "yaml"
require "logger"
require "thread"
require "uri"
require "fileutils"

class Time
	def interval_passed(int)
		(Time.now - self) > int
	end
end

module Xmms
	class Client
		def add_to_event_loop
			@io = IO.for_fd(io_fd)

			@io.on_readable { io_in_handle }
			@io.on_writable { io_out_handle }

			EventLoop.on_before_sleep do
				if io_want_out
					@io.monitor_event(:writable)
				else
					@io.ignore_event(:writable)
				end
			end
		end
	end
end

class SubmissionFilter
	@@filters = []

	def SubmissionFilter.inherited(c)
		$app.logger.info("adding filter #{c}")

		@@filters << c.new
	end

	def SubmissionFilter.ignore?(propdict)
		@@filters.any? { |f| f.ignore?(propdict) }
	end
end

class Xmms2Scrobbler
	VERSION = "0.1.3"
	PROTOCOL = "1.1"
	CLIENT_ID = "xm2"
	HOST = "post.audioscrobbler.com"
	PORT = 80
	CONFIG_DIR = File.join(Xmms.userconfdir,
	                       "clients",
	                       "xmms2-scrobbler")

	attr_reader :config, :submission_settings, :logger

	def initialize
		FileUtils.mkdir_p(CONFIG_DIR) unless File.directory?(CONFIG_DIR)

		handle_lockfile
		setup_logger

		@blocked = true
		@playtime_signal = nil
		@last_playtime = 0

		@config = {}
		@logged_in = false
		@handshake_thread = nil
		@submission_settings = {}

		@metadata = {}

		@queue_file = File.join(CONFIG_DIR, "queue.yaml")
		@queue = nil
		read_settings
		check_settings

		@xc = Xmms::Client.new("XMMS2-Scrobbler")
		@xc.connect(ENV["XMMS_PATH"])
		@xc.add_to_event_loop
		@xc.on_disconnect { EventLoop.quit }

		bc = @xc.broadcast_playback_current_id
		bc.notifier { |res| on_playback_current_id(res) }

		@broadcasts = [bc]
	end

	def connect
		@handshake_thread = Thread.new do
			while !@logged_in
				sleep(60) unless do_handshake
			end
		end
	end

	def logged_in?
		@logged_in
	end

	def logged_in=(v)
		@logged_in = (v == true)

		connect if !logged_in? && !@handshake_thread.alive?
	end

	def shutdown
		# kill off handshake thread if it's still running
		if !@handshake_thread.nil? && @handshake_thread.alive?
			@handshake_thread.kill
		end

		@queue.shutdown

		@broadcasts.each { |bc| bc.disconnect }
		@playtime_signal.disconnect unless @playtime_signal.nil?

		save_queue
	end

	def load_filters
		Dir["#{CONFIG_DIR}/filters/*rb"].each do |f|
			load f
		end
	end

	def load_queue
		@queue = SubmissionQueue.new
		songs = []

		if File.exist?(@queue_file)
			File.open(@queue_file) { |f| songs = YAML.load(f) }
		end

		@queue.concat(songs) if songs.is_a?(Array)

		unless validate_queue
			@queue.shutdown
			@queue = SubmissionQueue.new
		end

		@logger.info("loaded queue with #{@queue.length} entries")

		@queue.run
	end

	private
	def setup_logger
		file = File.open(File.join(CONFIG_DIR, "logfile.log"),
		                 File::WRONLY | File::CREAT | File::TRUNC)
		file.sync = true

		@logger = Logger.new(file)
		@logger.level = Logger::DEBUG
		@logger.datetime_format = "%Y-%m-%d %H:%M:%S"
	end

	def handle_lockfile
		file = File.join(CONFIG_DIR, "lock")
		if File.exist?(file)
			raise "another instance of xmms2-scrobbler is running"
		end

		File.open(file, "w") { |f| f.write(Process.pid) }

		at_exit { remove_lockfile }
	end

	def remove_lockfile
		File.delete(File.join(CONFIG_DIR, "lock"))
	end

	def validate_queue
		@queue.each do |song|
			return false unless song.is_a?(Song) && !song.submitted?
			# FIXME: validate song data, too
		end

		true
	end

	def save_queue
		sub = @queue.find_all { |song| song.submitted? }
		unless sub.empty?
			@logger.error("Submitted songs found in queue, " +
			              "please notify the developer!")
			@queue.delete_if { |song| song.submitted? }
		end

		songs = []
		songs.concat(@queue)

		File.open(@queue_file, "w") { |f| YAML.dump(songs, f) }

		@logger.info("saved queue with #{@queue.length} entries")
	end

	def read_settings
		file = File.join(CONFIG_DIR, "config")
		IO.foreach(file) do |l|
			md = l.match(/^(\w+):\s*(.+)$/)
			if md.nil?
				raise "invalid contents in config file - #{l}"
			end

			@config[$1.to_sym] = $2.strip.dup.freeze
		end
	end

	def check_settings
		if (@config[:user] || "") == ""
			raise "invalid username"
		end

		if (@config[:password] || "") == ""
			raise "invalid password"
		end
	end

	def want_playtime=(b)
		unless b
			@playtime_signal.disconnect unless @playtime_signal.nil?
			@playtime_signal = nil
		else
			@playtime_signal = @xc.signal_playback_playtime
			@playtime_signal.notifier do |res|
				begin
					pltime = res.value / 1000
				rescue Xmms::Result::ValueError => e
					@logger.debug(e.message)
				else
					on_playback_playtime(pltime)
				end

				sleep(0.5)
				res.restart
			end
		end
	end

	def on_playback_current_id(res)
		id = res.value
		@logger.debug("Got current song ID: #{id}")

		@metadata = {}
		@last_playtime = 0
		self.want_playtime = true

		@xc.medialib_get_info(id).notifier do |res2|
			on_mlib_get_info(res2)
		end

		@blocked = false
	end

	def on_mlib_get_info(res)
		@logger.debug("Got current song metadata")

		props = res.value

		# backwards compatibility
		if props.has_key?(:server)
			props = props[:server]
		end

		@metadata.clear
		@metadata[:artist] = (props[:artist] || "").strip
		@metadata[:title] = (props[:title] || "").strip

		# block if the meta data is incomplete
		@metadata.each { |(k, v)| @blocked |= v.length == 0 }

		duration = props[:duration].to_i / 1000
		@metadata[:duration] = duration
		@metadata[:album] = (props[:album] || "").strip
		@metadata[:track_id] = (props[:track_id] || "").strip
		@logger.debug(@metadata.inspect)

		# block this song if its shorter than 30 seconds
		@blocked |= (duration < 30)

		# block if this song is coming from a stream
		@blocked |= (!props[:channel].nil?)

		unless @blocked
			@blocked = SubmissionFilter.ignore?(props)
		end
	end

	def on_playback_playtime(playtime)
		return if @blocked || @metadata.empty?

		if @last_playtime.zero?
			@last_playtime = playtime
			return
		end

		diff = (@last_playtime - playtime).abs
		@last_playtime = playtime

		# invalidate on seek
		if diff > 2
			@blocked = true
			self.want_playtime = false
			return
		end

		# we only submit a song if 240 seconds resp. 50% have been played
		progress = playtime.to_f / @metadata[:duration].to_f
		return unless playtime >= 240 || progress >= 0.5

		@logger.debug("Time limit hit, adding song to submission queue")
		@queue << Song.new(@metadata)

		@blocked = true
		self.want_playtime = false
	end

	def do_handshake
		@submission_settings.clear
		@logger.debug("performing handshake")

		query = "/?hs=true&p=#{PROTOCOL}&c=#{CLIENT_ID}&v=#{VERSION}" +
		        "&u=#{@config[:user]}"

		begin
			Net::HTTP::Proxy(@config[:proxy], @config[:proxy_port].to_i).
			                start(HOST, PORT) do |http|
				resp = http.get(query).body.strip

				case resp
				when /^UPTODATE\n([0-9a-zA-Z]+)\n(.+)\n/
					@submission_settings[:challenge] = $1
					@submission_settings[:url] = URI.parse($2)
					@logged_in = true
					@logger.debug("handshake succeeded")
				when /^FAILED (.+)\n/
					@logger.warn("handshake failed - #{resp}")
				when /^BADUSER\n/
					@logger.warn("handshake failed - bad username")
				else
					@logger.warn("bad response in handshake - #{resp}")
				end
			end
		rescue SocketError => err
			@logger.debug("socket error: #{err}")
		rescue SystemCallError => err
			@logger.debug("system call error: #{err}")
		rescue IOError => err
			@logger.debug("io error: #{err}")
		rescue Timeout::Error
			@logger.debug("timeout during handshake")
		end

		@logged_in
	end
end

class Song
	class SongError < StandardError; end
	class AlreadySubmittedError < SongError; end
	class NotLoggedInError < SongError; end
	class ConnectionError < SongError; end
	class SubmissionError < SongError; end

	attr_reader :attempts, :last_attempt

	def initialize(metadata)
		@metadata = metadata.dup
		@time = Time.new.gmtime

		@submitted = false
		@attempts = 0
		@last_attempt = Time.at(0)
	end

	def submitted?
		@submitted
	end

	def submit
		if @submitted
			raise(AlreadySubmittedError, "song was already submitted")
		end

		unless $app.logged_in?
			raise(NotLoggedInError, "not logged in")
		end

		ret = nil
		query = build_submit_query
		@attempts += 1
		@last_attempt = Time.now

		$app.logger.debug("attempt #{@attempts}: #{query}")

		url = $app.submission_settings[:url]

		begin
			Net::HTTP::Proxy($app.config[:proxy],
			                 $app.config[:proxy_port].to_i).
			                start(url.host, url.port) do |http|
				h = {"content-type" => "application/x-www-form-urlencoded"}
				r = http.post(url.path, query, h)

				case r.body.strip
				when /OK\nINTERVAL (\d+)/
					@submitted = true
					ret = $1.to_i
				when /BADAUTH\n/
					$app.logged_in = false
					raise(SubmissionError, "invalid username/password")
				else
					raise(SubmissionError,
					      "server didn't accept the submitted data - " +
					      r.body)
				end
			end
		rescue SocketError => err
			raise(ConnectionError, "socket error: #{err}")
		rescue SystemCallError => err
			raise(ConnectionError, "system call error: #{err}")
		rescue IOError => err
			raise(ConnectionError, "io error: #{err}")
		rescue Timeout::Error
			raise(ConnectionError, "couldn't connect to server")
		end

		ret
	end

	private
	def build_submit_query
		artist = CGI.escape(@metadata[:artist])
		album = CGI.escape(@metadata[:album])
		title = CGI.escape(@metadata[:title])
		duration = @metadata[:duration]
		track_id = @metadata[:track_id]

		time = CGI.escape(@time.strftime("%Y-%m-%d %H:%M:%S"))
		user = $app.config[:user]
		passwd = $app.config[:password]
		challenge = $app.submission_settings[:challenge]
		md5 = Digest::MD5.hexdigest(passwd)
		md5 = Digest::MD5.hexdigest(md5 + challenge)

		"u=#{user}&s=#{md5}&a[0]=#{artist}&b[0]=#{album}" +
		"&t[0]=#{title}&l[0]=#{duration}&i[0]=#{time}" +
		"&m[0]=#{track_id}"
	end
end

class SubmissionQueue < Array
	def initialize(submission_interval = 1, retry_interval = 60)
		@last_success = Time.at(0)
		@subm_int = submission_interval
		@retry_int = retry_interval

		@mutex = Mutex.new
		@cond = ConditionVariable.new
		@thread = nil
		@shutdown = false
	end

	def run
		@thread = Thread.new do
			while !@shutdown
				@mutex.synchronize do
					@cond.wait(@mutex) if empty?

					# @cond is also signalled on shutdown
					maybe_submit(first) unless empty? || @shutdown
				end
			end
		end
	end

	def shutdown
		@mutex.synchronize do
			@shutdown = true
			@cond.signal
		end

		if !@thread.nil? && @thread.alive?
			@thread.join
		end
	end

	def submission_interval=(interval)
		@mutex.synchronize { @subm_int = interval }
	end

	def concat(songs)
		@mutex.synchronize do
			super
			@cond.signal
		end
	end

	def <<(song)
		@mutex.synchronize do
			super
			@cond.signal
		end

		self
	end

	private
	def maybe_submit(song)
		is_retry = (song.attempts > 0 &&
		            song.last_attempt.interval_passed(@retry_int))
		is_1st = (song.attempts.zero? &&
		          @last_success.interval_passed(@subm_int))

		if (is_retry || is_1st) && submit(song)
			delete_at(0)
			sleep(@subm_int)
		else
			sleep(1)
		end
	end

	def submit(song)
		new_int = nil

		begin
			new_int = song.submit
		rescue Song::SongError => err
			$app.logger.info("submission failed - #{err.message}")

			false
		else
			$app.logger.info("submission succeeded")

			@subm_int = new_int unless new_int.nil?
			true
		end
	end
end

trap("SIGINT") { EventLoop.quit }

$app = Xmms2Scrobbler.new
$app.load_filters
$app.load_queue
$app.connect
EventLoop.run
$app.shutdown
