#!/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"

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.2.2"
	PROTOCOL = "1.2"
	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

		@playtime_signal = nil
		@seconds_played = 0

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

		@current_song = nil

		@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(&method(:on_playback_current_id))

		@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)
			pid = File.open(file, "r") { |f| f.read }.chomp.to_i

			begin
				Process.kill(0, pid)
				die = true
			rescue Errno::EPERM
				die = true
			rescue
				# found stale lock file - ignoring
				die = false
			end

			raise "another instance of xmms2-scrobbler is running" if die
		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(&method(:on_playback_playtime))
		end
	end

	def should_submit?
		return false if @current_song.nil?

		@seconds_played >= [240, @current_song.duration / 2].min
	end

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

		self.want_playtime = false

		if should_submit?
			@logger.debug("Adding previous song to submission queue")
			@queue << @current_song
			@current_song = nil
		end

		@seconds_played = 0

		@xc.medialib_get_info(id).notifier(&method(:on_mlib_get_info))
	end

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

		blocked = false
		props = res.value

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

		metadata = {}
		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)

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

		np_song = nil

		unless blocked
			np_song = Song.new(metadata)

			begin
				np_song.submit_now_playing
			rescue Song::SongError => err
				@logger.info("now playing failed - #{err.message}")
			end
		end

		# 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
			@current_song = np_song
			self.want_playtime = true
		end
	end

	def on_playback_playtime(res)
		@seconds_played += 1

		sleep(1.0)
		res.restart
	end

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

		timestamp = Time.now.gmtime.to_i.to_s

		md5 = Digest::MD5.hexdigest(config[:password])
		md5 = Digest::MD5.hexdigest(md5 + timestamp)
		query = "/?hs=true&p=#{PROTOCOL}&c=#{CLIENT_ID}&v=#{VERSION}" +
		        "&u=#{@config[:user]}&t=#{timestamp}&a=#{md5}"

		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 /^OK\n([0-9a-z]+)\n(.+)\n(.+)/
					@submission_settings[:session_id] = $1
					@submission_settings[:now_playing_url] = URI.parse($2)
					@submission_settings[:submission_url] = URI.parse($3)
					@logged_in = true
					@logger.debug("handshake succeeded")
				when /^BANNED$/
					@logger.error("oops, xmms2-scrobbler got banned")
				when /^BADTIME$/
					@logger.error("bad time, go fix your clock")
				when /^FAILED (.+)$/
					@logger.warn("handshake failed - #{resp}")
				when /^BADAUTH$/
					@logger.warn("handshake failed - bad username/password")
				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 duration
		@metadata[:duration]
	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

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

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

		url = $app.submission_settings[:submission_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)
				resp = r.body.strip

				case resp
				when /^OK$/
					@submitted = true
				when /^BADSESSION$/
					$app.logged_in = false
					raise(SubmissionError, "invalid session id")
				when /^FAILED (.+)\n/
					raise(SubmissionError, resp)
				else
					raise(SubmissionError, "hard failure")
				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
	end

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

		query = build_now_playing_query

		$app.logger.debug("submitting now playing: #{query}")

		url = $app.submission_settings[:now_playing_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)
				resp = r.body.strip

				case resp
				when /^OK$/
					$app.logger.debug("now playing succeeded")
				when /^BADSESSION$/
					$app.logged_in = false
					raise(SubmissionError, "invalid session id")
				else
					raise(SubmissionError, "hard failure")
				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
	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 = @time.to_i
		user = $app.config[:user]
		passwd = $app.config[:password]
		session_id = $app.submission_settings[:session_id]

		"s=#{session_id}&a[0]=#{artist}&b[0]=#{album}" +
		"&t[0]=#{title}&l[0]=#{duration}&i[0]=#{time}" +
		"&o[0]=P&r[0]=&n[0]=&m[0]=#{track_id}"
	end

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

		session_id = $app.submission_settings[:session_id]

		"s=#{session_id}&a=#{artist}&b=#{album}" +
		"&t=#{title}&l=#{duration}&n=&m=#{track_id}"
	end
end

class SubmissionQueue < Array
	def initialize
		@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
					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 concat(songs)
		@mutex.synchronize do
			super
			@cond.signal
		end
	end

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

		self
	end

	private
	def submit(song)
		begin
			song.submit
		rescue Song::SongError => err
			$app.logger.info("submission failed - #{err.message}")
			sleep(60)
		else
			delete_at(0)
			$app.logger.info("submission succeeded")
			sleep(1)
		end
	end
end

trap("SIGINT") { EventLoop.quit }

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