#! /usr/bin/python
# -*- coding: iso-8859-15 -*-

##
## lastfmsubmitter.py
##  - a Python library module to submit data to a last.fm account
##    This module implements the AudioScrobbler Realtime Submission
##    Protocol v1.2.
## See http://www.ohrner.net/ for latest news and updates, please.
##
## Copyright (C) 2007-2008  Gunter Ohrner "gunter _(@)_ ohrner.net"
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, write to the Free Software
## Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
##


import itertools
import md5
import re
import socket
import time
import urllib

import utilitylib


debug = False
debug = True

def md5hex(str_to_hash):
	return md5.new(str_to_hash).hexdigest()


class LastFmException( Exception ):
	'''Base class for lastfmsubmitter error conditions'''
	def __init__(self, text, errorcode):
		Exception.__init__(self, text)
		self.errorcode = errorcode

	def getErrorCode(self):
		return self.errorcode
	

class LastFmBadSessionException( LastFmException ):
	'''The Session has somehow been invalidated, client should re-handshake'''

	ERROR_CODE = 1
	
	def __init__(self):
		LastFmException.__init__(self, 'The server session is invalid.',
														 self.ERROR_CODE)


class LastFmHardFailureException( LastFmException ):
	'''A "hard" failure (serious but probably recoverable failure),
	according to the protocol spec'''

	ERROR_CODE = 2
	
	def __init__(self, text, errorcode = ERROR_CODE):
		LastFmException.__init__(self, 'last.fm reported a problem: "%s"'
														 % text, errorcode)


class LastFmParseError( LastFmHardFailureException ):
	'''Unexpected / unparseable server response, usually indicates a server failure'''

	ERROR_CODE = 3
	
	def __init__(self, text):
		LastFmHardFailureException.__init__(self, 'Could not understand server response: "%s"' % text,
																				self.ERROR_CODE)


class LastFmFatalException( LastFmException ):
	'''Fatal error, user interaction is required before continuing'''
	def __init__(self, text, errorcode):
		LastFmException.__init__(self, text, errorcode)


class LastFmBannedException( LastFmFatalException ):
	'''Whoops! Server told the client that it has been banned!'''

	ERROR_CODE = 4
	
	def __init__(self):
		LastFmFatalException.__init__(self, "Your last.fm-client has been permanently banned from last.fm, probably because of bugs in the program. Please update to a later version of lastfmsubmitter.py from http://www.ohrner.net/ .",
																	self.ERROR_CODE)


class LastFmBadAuthException( LastFmFatalException ):
	"""Server thinks we're using invalid login credentials"""

	ERROR_CODE = 5
	
	def __init__(self):
		LastFmFatalException.__init__(self, 'Your login credentials have not been accepted. Please check username and password.',
																	self.ERROR_CODE)


class LastFmBadTimeException( LastFmFatalException ):
	"""Server thinks our clock is wrong"""

	ERROR_CODE = 6
	
	def __init__(self):
		LastFmFatalException.__init__(self, "The current timestamp submitted to the server has not been accepted by last.fm. Please check your computer's system clock!",
																	self.ERROR_CODE)
		

class LastFmSubmissionException( LastFmFatalException ):
	"""This exception plays a special role in that it is a 'meta-exception'.
	It's thrown by the submission routine if some unrecoverable error occured
	during submission and carries the exception describing the failure as well
	as a list of still pending submission records."""

	ERROR_CODE = 7
	
	def __init__(self, e, pending_submission_record_list):
		LastFmFatalException.__init__(self, 'Track submission failed: "%s"'
																	% e,
																	self.ERROR_CODE)
		self.cause = e
		self.pending_submissions = pending_submission_record_list

	def getCause(self):
		return self.cause

	def getPendingSubmissions(self):
		return self.pending_submissions



def handleReply(status_reply):
	'''Handle a server reply status line. This method will return normally
	if the status message was "OK" and throw an appropriate logical exception
	otherwise.'''
	if status_reply.status == 'OK':
		pass ## everything is fine
	elif status_reply.status == 'BANNED':
		raise LastFmBannedException()
	elif status_reply.status == 'BADAUTH':
		raise LastFmBadAuthException()
	elif status_reply.status == 'BADTIME':
		raise LastFmBadTimeException()
	elif status_reply.status == 'BADSESSION':
		raise LastFmBadSessionException()
	elif status_reply.status == 'FAILED':
		raise LastFmHardFailureException(status_reply.description)
	else:
		raise LastFmParseError('%s / %s' % (status_reply.status,
																				status_reply.description))



class TrackSubmitRecord:
	'''Information about a track listening event which should be
	submitted to the AudioScrobbler server.

	WARNING: Changing this into a new-style-class will drop
	It\'sGotTheVibez\' state file compatibility!'''

	SOURCE_USER_SELECTED='P'
	SOURCE_UNKNWON='U'
	SOURCE_WEBRADIO='R'
	SOURCE_PERSONALIZED_RADIO='E'
	SOURCE_LASTFM='L'
	
	def __init__(self, artist, title,
							 duration, play_timestamp, musicbrainz_id = None,
							 trackno = None, album = None,
							 rating = None, source = None):
		self.artist = artist
		self.title = title
		## timestamp in UTC
		self.play_timestamp = utilitylib.xint(play_timestamp)
		## may be None
		self.trackno = trackno
		## may be None
		self.album = album
		## may be None
		self.duration = utilitylib.xint(duration)
		## may be None
		self.musicbrainz_id = musicbrainz_id
		## may be None
		self.rating = rating
		## may be None
		self.source = source


def formatSubmitRecords(additional_fields, recs):
	'''Formats a list of TrackSubmitRecords for submission.
	Be aware that the audioscrobbler protocol specification states that
	only up to 50 tracks may be submitted at once.'''

	## kvps = KeyValuePairs to be submitted to the server
	kvps = additional_fields[:] ## init kvps with copy of list
	tid = 0

	#if debug:
	#	print recs
	
	for rec in recs:
		musicbrainz_id = rec.musicbrainz_id
		if musicbrainz_id == None: musicbrainz_id = ''

		kvps += [ ('a[%d]' % tid, rec.artist),
							('t[%d]' % tid, rec.title),
							('i[%d]' % tid, utilitylib.xint(rec.play_timestamp)),
							('o[%d]' % tid, utilitylib.emptyIfNone(rec.source,
																										 rec.SOURCE_UNKNWON)),
							('r[%d]' % tid, utilitylib.emptyIfNone(rec.rating)),
							('l[%d]' % tid, utilitylib.xint(rec.duration)),
							('b[%d]' % tid, utilitylib.emptyIfNone(rec.album)),
							('n[%d]' % tid, utilitylib.emptyIfNone(rec.trackno)),
							('m[%d]' % tid, utilitylib.emptyIfNone(musicbrainz_id))
							]
		tid += 1
		## end of formatting loop

	#print kvps
	return urllib.urlencode(kvps)



class StatusReply:
	def __init__(self, status_text, description = None):
		self.status = status_text
		self.description = description

	def __str__(self):
		if self.description != None and len(self.description) > 0:
			return '%s: "%s"' % (self.status, self.description)
		else:
			return self.status
		

PARSE_REPL = re.compile(r'^(OK|BANNED|BADAUTH|BADTIME|BADSESSION|FAILED)\s*(.*)$')


def parseStatus(status_str):
	'''"parses" AudioScrobbler status reply'''
	if debug:
		print 'Status message: "%s"' % status_str

	matcher = PARSE_REPL.match(status_str.strip('\n'))
	if not matcher:
		raise LastFmParseError(status_str)
	else:
		return StatusReply(matcher.group(1), matcher.group(2))



class LastFmSubmitter:
	client_id = 'vib'
	client_version = '0.2'
	#client_id = 'tst'
	#client_version = '1.0'
	handshake_url = 'http://post.audioscrobbler.com/' \
									'?hs=true&p=1.2&c=%s&v=%s&u=%%s&t=%%d&a=%%s' \
									% (client_id, client_version)
	
	def __init__(self, username, password):
		self.username = username
		self.pwhash = md5hex(password)
		self.forced_delay = 1
		self.update_url = None
		self.sessionid = None
		self.submit_url = None
		## currently unsupported by lastfmsubmitter.py
		self.nowplaying_url = None

		## Already performed a successful handshake with last.fm?
		self.authenticated = False



	def shakeHands(self):
		'''initiate a new session with the server'''

		ts = time.time()

		auth_token = md5hex('%s%d' % (self.pwhash, ts))

		url_with_user = self.handshake_url \
										% (urllib.quote_plus(self.username), ts, auth_token)

		if debug:
			print 'Shaking hands at ' + url_with_user

		hs_reply = None
		handshake_delay = 60
		self.authenticated = False
		
		while not self.authenticated:
			handshake_exception = None

			## (nested "try"s to stay Python 2.4 compatible)
			try: ## finally
				try: ## except
					hs_reply = urllib.urlopen(url_with_user)
					status_str = hs_reply.readline().strip('\n')
					status = parseStatus(status_str)

					handleReply(status)

					self.sessionid = hs_reply.readline().strip('\n')
					self.nowplaying_url = hs_reply.readline().strip('\n')
					self.submit_url = hs_reply.readline().strip('\n')
					
					if debug:
						print 'Sucessful handshake with last.fm!'
						print 'Session-ID: %s' % self.sessionid
						print 'NowPlayURL: %s' % self.nowplaying_url
						print 'Submit-URL: %s' % self.submit_url

					self.last_request_ts = time.time()

					self.authenticated = True

					## Ok, handshake successful!


				## Catch and handle "hard" failures (as defined by last.fm
				## protocol spec) and timeouts.
				except LastFmHardFailureException, e:
					handshake_exception = e

				except socket.timeout, e:
					handshake_exception = e

			finally:
				utilitylib.safeClose(hs_reply)

			if handshake_exception != None:
				print 'Problem during handshake with last.fm: %s' % str(e)
				print 'Waiting for %d seconds.' % handshake_delay
				time.sleep(handshake_delay)

				## double delay on each failure, according to spec
				handshake_delay *= 2
				## 120 min. max. delay according to spec
				handshake_delay = min(handshake_delay, 120 * 60)
				
			## end while


	def isTrackSubmitRecordValid(self, submit_record):
		'''Predicate used to filter out invalid records.
		
		This could possibly later be replaced by a callable
		to allow for arbitrary complex filter conditions.
		
		However I\'m not really sure whether filtering for
		arbitrary conditions would belong into the
		lastfmsubmitter module, I think this functionality
		should preferrably be implemented within
		It\'sGotTheVibez\' core.'''
		return utilitylib.noneIfEmpty(submit_record.artist) != None \
					 and utilitylib.noneIfEmpty(submit_record.title) != None \
					 and submit_record.duration != None \
					 and submit_record.duration >= 30


	def submitTracks(self, records):
		'''Submit a list of TrackSubmitRecords to last.fm.
		Automatically splits the list into chunks digestible by the server.

		If this method returns normally, all tracks will have been submitted.
		If an unrecoverable failure occurs, a LastFmSubmissionException will
		be thrown instead, wrapping the causing Exception as well as a list
		of submission records which have not been submitted yet.'''

		is_submit_record_valid_predicate = lambda x: self.isTrackSubmitRecordValid(x)

		records_iter = iter(records)
		valid_records_iter = itertools.ifilter(is_submit_record_valid_predicate,
																					 records_iter)

		new_chunk = []
		while new_chunk != None:
			limit = 50 ## as per last.fm protocol 1.2 spec

			limited_records_iter = itertools.takewhile(
				utilitylib.LessThanLimitPredicate(limit), valid_records_iter)

			## We materialize this iterator so we do not lose some
			## records in case of an Exception during the submission
			## phase.
			new_chunk = [ record for record in limited_records_iter ]
			
			if len(new_chunk) == 0:
				## Iterator exhausted, abort loop.
				new_chunk = None
			else:
				try:
					self.submitTrackChunkInternal(new_chunk)
				except Exception, e:
					## If one submit fails, we abort completely and ask the user to
					## retry at a later point of time.
					unprocessed = new_chunk
					unprocessed += records_iter
					raise LastFmSubmissionException(e, unprocessed)



	def submitTrackChunkInternal(self, records):
		'''Internal helper method to submit a list of TrackSubmitRecords
		to last.fm.
		The "records" list should contain at most 50 records.
		This method returns the submit status.'''

		if not self.authenticated:
			self.shakeHands()

		if debug:
			print 'Preparing to submit %d tracks.' % len(records)
		
		submit_str = formatSubmitRecords([ ('s', self.sessionid) ],
																		 records)

		submit_successful = False
		retry_cnt = 0
		err_delay = 1
		while not submit_successful:
			submit_reply = None
			status = None
			hard_failure_exception = None
			try:
				try:
					submit_reply = urllib.urlopen(self.submit_url, submit_str)
					status_str = submit_reply.readline()
					status = parseStatus(status_str)

					handleReply(status)

					## success, return
					submit_successful = True
				
				finally:
					utilitylib.safeClose(submit_reply)

			except socket.timeout, e:
				hard_failure_exception = e
			except LastFmHardFailureException, e:
				hard_failure_exception = e

			if hard_failure_exception != None:
				print 'Server reported failure: "%s"' % hard_failure_exception
				if retry_cnt >= 3:
					## re-authenticate, this may raise another exception
					self.shakeHands()
					retry_cnt = 0
					err_delay = 1
				else:
					print 'spec. recommends retry. ' \
								'Waiting %d seconds...' % err_delay
					time.sleep(err_delay)
					err_delay *= 2 ## double error delay
					print 'Retrying...'

		return status



if __name__ == '__main__':
	import sys
	socket.setdefaulttimeout(30)
	lfm = LastFmSubmitter(sys.argv[1], sys.argv[2])
	lfm.shakeHands()
