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

##
## itsgotthevibez.py
##  - It'sGotTheVibez, an offline last.fm scrobbler for Trekstor's
##    "Vibez" portable music player.
## See http://www.ohrner.net/ for latest news and updates, please.
##
## $Id$
## $URL$
## 
## 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 os
import os.path
import socket
import sys
import time

try:
	import cPickle as pickle
except ImportError:
	import pickle

import ConfigParser

import lastfmsubmitter
import itsgotthevibezlib
import utilitylib


## Import listening data import modules.
## This could be made more dynamic in the future
## if additional players are supported.

import importmodule_vibez_privatedb
import importmodule_vibez_musiclog


VERSION='0.2-alpha4pre1'


debug = True
#debug = False



def restoreState(statefile_name):
	'''restore remembered scrobbler state from state file'''
	statefile = None
	try:
		statefile = open(statefile_name, 'r')
		scrobbler_state = pickle.load(statefile)
		return scrobbler_state
	finally:
		utilitylib.safeClose(statefile)



def persistState(statefile_name, scrobbler_state):
	'''store scrobbler state, including date required by currently used
	import modules and the last.fm submit queue, to the state file'''
	statefile = None
	try:
		try:
			statefile = open(statefile_name, 'w')
			pickle.dump(scrobbler_state, statefile)
		finally:
			utilitylib.safeClose(statefile)
	except Exception, e:
		print 'Severe: Could not store state: %s' % (e,)



def submitTracks(scrobbler_state, username, password):
	'''Actually submit the pending queue.'''

	lfms = lastfmsubmitter.LastFmSubmitter(username, password)

	## last.fm is rather slow to respond at times...
	socket.setdefaulttimeout(25.0)

	#tmp = scrobbler_state.agenda[:15]
	#scrobbler_state.agenda = lfms.submitTracks(tmp) + scrobbler_state.agenda[15:]
	try:
		lfms.submitTracks(scrobbler_state.agenda)
		## If we got no exception, all pending sumbissions have
		## been transmitted successfully. :-)
		scrobbler_state.agenda = []

	except lastfmsubmitter.LastFmSubmissionException, e:
		## A problem occured! Rescue submit log.
		scrobbler_state.agenda = e.getPendingSubmissions()

		print 'Error while submitting tracks, %d plays still unsubmitted.' \
					% len(scrobbler_state.agenda)

		raise itsgotthevibezlib.ItsGotTheVibezLastfmSubmissionException(
			'Multiple failures occured while submitting '
			'data to last.fm.', e)

	except lastfmsubmitter.LastFmException, e:
		raise itsgotthevibezlib.ItsGotTheVibezLastfmSubmissionException(
			'A problem occured while talking to last.fm', e)



def printAgenda(agenda):
	'''Output a list of TrackSubmitRecords to standard output.'''
	for submit_record in agenda:
		print '%s: %s by %s (Album: %s)' \
					% (time.ctime(submit_record.play_timestamp),
						 utilitylib.emptyIfNone(submit_record.title, 'unknown track'),
						 utilitylib.emptyIfNone(submit_record.artist, 'an unknown artist'),
						 utilitylib.emptyIfNone(submit_record.album, 'unknown'))
	print '%d entries in submission queue.' % len(agenda)



def getCommandLineParser(default_config):
	'''Initializes and returns the command line parser.'''
	from optparse import OptionParser
	
	parser = OptionParser(usage = '%prog [options]',
												version = '%prog' + (' %s' % VERSION))
	
	parser.add_option(
		'-c', '--config-file', dest='config_file',
		help='Path/filename of an alternate configuration file. '
		'Default is "%s"' % default_config['config_file'],
		metavar='CONFIGFILE', default=default_config['config_file'])
	parser.add_option(
		'-s', '--state-file', dest='state_file',
		help='Path/filename of the file that It\'sGotTheVibez uses '
		'to remember its internal track submission state. '
		'The default is "%s".' % default_config['state_file'],
		metavar='STATEFILE')
	parser.add_option(
		'-u', '--username', dest='username',
		help='Your last.fm user name',
		metavar='USERNAME')
	parser.add_option(
		'-p', '--password', dest='password',
		help='Your last.fm password. YOU SHOULD NOT SPECIFY THIS '
		'ON THE COMMAND LINE on a multi-user computer, as other '
		'users can read your password eg. using the system '
		'utility "ps" while It\'sGotTheVibez runs!',
		metavar='PASSWORD')
	parser.add_option(
		'-d', '--dry-run', dest='dry_run',
		help='Test run: Do everything as normal, except transmitting '
		'anything to last.fm or updating your state.',
		action='store_true')
	parser.add_option(
		'-l', '--dont-submit', dest='dont_submit',
		help='Do not try to submit anything to last.fm ("local" mode). '
		'This flag '
		'is intended to be used if you want to import new track '
		'listens from your Vibez into the submit queue but currently '
		'have not internet connection.',
		action='store_true')
	parser.add_option(
		'-n', '--dont-read-events', dest='dont_read_events',
		help='Do not read the Vibez\' db, you can use this flag if '
		'you want to submit pending tracks from your current submit '
		'queue only, but do not want to attach your Vibez to your '
		'computer.',
		action='store_true')
	parser.add_option(
		'-r', '--remove-pending-events', dest='remove_pending_events',
		help='POTENTIALLY DANGEROUS OPTION! '
		'Asks the selected import module to forget all pending '
		'listeing events. This will delete pending listening '
		'events from your music player without submitting anything '
		'to last.fm, ie. the listening event will be LOST. '
		'It\'sGotTheVibez\'s pending submissions queue is NOT affected '
		'by this command.',
		action='store_true')
	parser.add_option(
		'--list-pending-submissions', dest='list_pending_submissions',
		help='List all pending submissions before actually transmitting '
		'them to last.fm. This option is probably most useful in '
		'conjunction with "-l".',
		action='store_true')

	return parser



class ItsGotTheVibez( object ):
	"""Class representing It'sGotTheVibez."""

	config_dir = os.path.expanduser(os.path.join('~', '.itsgotthevibez'))

	default_config = {
		'config_file': os.path.expanduser(os.path.join(config_dir, 'config')),
		'state_file': os.path.expanduser(os.path.join(config_dir,
																									'scrobblerstate.bin')),
		'username': '',
		'password': '' }

	import_modules = { importmodule_vibez_privatedb.IMPORT_MODULE_NAME:
										 importmodule_vibez_privatedb,
										 importmodule_vibez_musiclog.IMPORT_MODULE_NAME:
										 importmodule_vibez_musiclog }

	def __init__(self):
		## Set application state to undefined values
		## to make sure attributes exist.
		self.options = None

		try:
			self.scrobbler_state = None
			try:
				self.readConfiguration()

				self.checkAndPrepareRunEnvironment()

				self.scrobbler_state = self.restoreScrobblerState()

				migrateScrobblerStateIfNeccessary(self)

				self.volatile_module_states = {}

				selected_module_id = self.options.import_module
				
				if not self.options.dont_read_events:
					## process options for selected import module
					import_module = self.import_modules[selected_module_id]
					module_states = self.scrobbler_state.module_states

					## New module or virgin scrobbler state object?
					if selected_module_id not in module_states:
						module_states[selected_module_id] = None

					(im_volatile_state, im_scrobbler_state) \
					= import_module.initImportModule(
						self.options,
						module_states[selected_module_id])

					self.scrobbler_state.module_states[selected_module_id] \
						= im_scrobbler_state
					self.volatile_module_states[selected_module_id] \
						= im_volatile_state

				self.performRequestedScrobblerAction(selected_module_id)

			finally:
				if self.scrobbler_state is not None:
					self.persistScrobblerState(self.scrobbler_state)

			print 'Finished successfully, exiting.'

		except itsgotthevibezlib.ItsGotTheVibezException, e:
			print 'Ups, an error occured!'
			print str(e)
			sys.exit(e.getErrorCode())



	def readConfiguration(self):

		parser = getCommandLineParser(self.default_config)

		self.default_config['import_module'] = self.import_modules.keys()[0]
		parser.add_option(
			'-m', '--import-module', dest='import_module',
			help='Listen event import module to use. '
			'This basically specifies from where you want '
			'to get the data which should be submitted '
			'to last.fm. The following modules are '
			'available, the first of which is the '
			'default module if none is specified: '
			+ ', '.join(self.import_modules.keys()),
			metavar='MODULE_ID',
			default=self.default_config['import_module'])
		
		## add options from import modules
		for import_module in self.import_modules.values():
			import_module.publishOptions(parser, self.default_config)

		(options, args) = parser.parse_args()

		config = ConfigParser.ConfigParser(self.default_config)
		if not os.path.exists(options.config_file):
			print ('Config file "%s" does not (yet) exist, proceeding with '
						 'default configuration.') % options.config_file
		config.read([options.config_file])

		## Iterate over all option names, fetching values from
		## config file if not specified on the command line.
		## Values are userpath expanded, ie. a leading '~' is replaced
		## by the current user's home directory and a leading '~NAME'
		## is expanded by user NAME's home directory.
		for field in options.__dict__:
			config_value = options.__dict__[field]
			## fetch value from config file if neccessary and available
			if config_value is None \
				 and config.has_option('DEFAULT', field):
				config_value = config.get('DEFAULT', field)

			if config_value is not None \
					 and not isinstance(config_value, bool):
				options.__dict__[field] = os.path.expanduser(config_value)

		options.submit_tracks = not options.dont_submit

		self.options = options


	
	def checkAndPrepareRunEnvironment(self):
		options = self.options

		error_messages = []
		
		if options.submit_tracks \
				 and (options.username == ''
							or options.password == ''):
			error_messages.append(
				'Username or password are missing, '
				'both MUST be defined to be able '
				'to submit anything to last.fm.')
		
		if not self.options.dont_read_events \
				 and options.import_module not in self.import_modules:
			error_messages.append(
				'The specified import module %s does not exist.'
				% options.import_module)
		
		if options.dont_read_events and options.remove_pending_events:
			error_messages.append(
				'Removing listening events without reading them is '
				'not possible. You cannot specify both -n and -r.')

		## Configuration errors where found, inform the user.
		if len(error_messages) != 0:
			raise itsgotthevibezlib.ItsGotTheVibezConfigurationException(
				'\n'.join(error_messages))



	def performRequestedScrobblerAction(self, import_module_id):
		"""Dispatch action requested by the user."""
		options = self.options

		if not self.options.dont_read_events:
			import_module = self.import_modules[import_module_id]
			im_volatile_state = self.volatile_module_states[import_module_id]
			im_module_state = self.scrobbler_state.module_states[import_module_id]
			new_submit_records = import_module.importListeningEvents(
				options,
				im_volatile_state, im_module_state)

			print 'Import module %s returned %d new listening events.' \
						% (import_module_id, len(new_submit_records))

			self.scrobbler_state.agenda += new_submit_records

                self.scrobbler_state.agenda = filter(lambda x: 'rgen Salzer' not in x.artist, self.scrobbler_state.agenda)

		if self.options.list_pending_submissions:
			printAgenda(self.scrobbler_state.agenda)
		
		if self.options.submit_tracks and not self.options.dry_run:
			submitTracks(self.scrobbler_state,
									 options.username, options.password)



	def restoreScrobblerState(self):
		if not os.path.exists(self.options.state_file):
			## Statefile could not be found, so simulate reading
			## an empty one.
			print 'No statefile found, starting with a fresh one.'
			return itsgotthevibezlib.ScrobblerState()

		## Statefile exists...

		try:
			return restoreState(self.options.state_file)
		except StandardError, e:
			raise itsgotthevibezlib.ItsGotTheVibezRuntimeException(
				'Problem while loading state from file "%s": %s'
				% (self.options.state_file, e))



	def persistScrobblerState(self, scrobbler_state):
		## try to persist the current scrobbler state
		if self.options.dry_run:
			print 'Dry-Run: Not saving state.'
		else:
			state_file_name = self.options.state_file
			## Ensure that the state file target directory exists.
			state_file_dir = os.path.dirname(state_file_name)
			state_file_dir = os.path.realpath(state_file_dir)
			if not os.path.exists(state_file_dir):
				print 'Creating state file directory "%s"...' % state_file_dir
				os.makedirs(state_file_dir)
			print 'Saving state...'
			persistState(self.options.state_file, scrobbler_state)
			print 'Done.'





####################################################################
### Compatibility Cruft
####################################################################

class ScrobblerState( itsgotthevibezlib.ScrobblerState ):
	"""This class existst for compatibility with older It'sGotTheVibez
	versions only. It allows pickle to unserialize an "version 1"
	scrobbler state file."""
	pass

class TrackPlayState( importmodule_vibez_privatedb.TrackPlayState ):
	"""This class existst for compatibility with older It'sGotTheVibez
	versions only. It allows pickle to unserialize an "version 1"
	scrobbler state file."""
	pass



def migrateScrobblerStateIfNeccessary(app_object):
	'''This is function is responsible to migrate a stored application
	state object to a new version if neccessary.
	
	It may require slightly fragile code dependant which at least for
	the state object version 1 -> 2 migration depends on internals of
	the privatedb import module. This is caused by a design deficiency
	in the original scrobbler state file format (v1) and should not
	happen in the future.

	For future conversions from state file format v2 onwards, this
	method will only have to migrate changes to the scrobbler\'s
	state data, while changes to the import module\'s state objects
	are completely independant from this and will have to be
	performed by the affected import module itself. This separation
	of concerns should keep possible future migration code as clean
	and localized as possible.'''

	state_object = app_object.scrobbler_state
	migrated = False

	## This first check is somewhat ugly, as the original file
	## format was not designed to support different file format
	## versions.
	## From version 2 on we can simple check the version flag.
	if not hasattr(state_object, 'version') \
			 or (hasattr(state_object, 'known_tracks') \
					 and len(state_object.module_states) == 0):
		## We encountered a version 1 (original format) state file!
		migrateScrobblerStateFromV1toV3(state_object)
		migrated = True
	elif state_object.version == 2:
		migrateScrobblerStateFromV2toV3(state_object)
		migrated = True

	if migrated:
		print 'File format migration successful.'



def migrateScrobblerStateFromV1toV3(state_object):
	"""This function is responsible for migrating a "version 1"
	state file (the original state file format which was not aware
	of possible different state file versions) to the current
	state file format version used by this scrobbler.

	As the original file format was not designed to be upgradeable
	or to support different import modules, this conversion method
	is slightly more hairy and fragile than I would like.

	All other future migration method will be cleaner and simpler."""
	
	print 'Migrating state file from version 1 to version 3.'

	## Migrate agenda list. (Pending submission queue.)
	state_object.agenda = [
		lastfmsubmitter.TrackSubmitRecord(
		old_tsr.artist, old_tsr.title, old_tsr.duration,
		old_tsr.play_timestamp, None,
		old_tsr.musicbrainz_id,
		None, old_tsr.album)
		for old_tsr in state_object.agenda ]
	
	## v1 only knows the privatedb import module and keeps
	## its state information in the main state object.
	## Separate it.
	## This code performs surgery in the guts of an import module's
	## state object. :-(
	## Fortunately something like that will never be neccessary for
	## migration from v2 to future versions! :-)
	privatedb_state = importmodule_vibez_privatedb.ImPersistentStateImpl()
	privatedb_state.known_tracks = state_object.known_tracks
	del(state_object.known_tracks)
	state_object.module_states = {
		importmodule_vibez_privatedb.IMPORT_MODULE_NAME: privatedb_state }

	## Finished privatedb state migration. Now we have a valid
	## v3 state file carrying a valid v1 private db import module
	## state object. :-)
	state_object.version = 3



def migrateScrobblerStateFromV2toV3(state_object):
	"""This function is responsible for migrating a "version 2"
	state file (the original state file format which was not aware
	of possible different state file versions) to the current
	state file format version used by this scrobbler.

	Basically, only the TrackSubmitRecords changed a bit, as
	the "playing_duration" field was added."""
	
	## We encountered a version 2 (original format) state file!
	## Migrate to version 3
	print 'Migrating state file from version 2 to version 3.'

	## Nuke relicts from ancient times... ;)
	if hasattr(state_object, 'known_tracks'):
		del(state_object.known_tracks)

	## Migrate agenda list. (Pending submission queue.)
	state_object.agenda = [
		lastfmsubmitter.TrackSubmitRecord(
		old_tsr.artist, old_tsr.title, old_tsr.duration,
		old_tsr.play_timestamp, None,
		old_tsr.musicbrainz_id,
		old_tsr.trackno, old_tsr.album, old_tsr.rating,
		old_tsr.source)
		for old_tsr in state_object.agenda ]

	## Finished.
	state_object.version = 3


####################################################################
### End of Compatibility Cruft
####################################################################





if __name__ == '__main__':
	#state = restoreState('/home/gunter/.itsgotthevibez/testuser.bin')
	#print dir(state)
	#print 'version' in state.__dict__
	#state.version = 2
	#print 'version' in state.__dict__
	ItsGotTheVibez()
