#!/usr/bin/python
# vim:fileencoding=utf-8
#
# lastfm-rip.py
#
# Copyright (c) 2009, Phillip Berndt
# All rights reserved.
#
# Changelog:
#  02/05/2011 Changed filename encoding for non-utf8-filesystems
#     (Thanks to Christian Köstlin for pointing that out and
#     providing a patch ☺)
#  05/14/2010 Added option to keep a list of downloaded songs
#   Added file-name cleanup code as suggested by Thomas
#  04/19/2010 Added option to stop after downloading n songs
#  07/28/2009 Enqueue songs in rythmbox if possible (by Jesper Zedlitz)
#  07/23/2009 Applied patch by Jesper Zedlitz <jesper ät zedlitz düd de>
#    to include GNOME keyring
#  06/24/2009 Applied patch by Christian Hammers to avoid crashes due to
#    wrong character encoding
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.  * Redistributions in binary
# form must reproduce the above copyright notice, this list of conditions and
# the following disclaimer in the documentation and/or other materials provided
# with the distribution.  * The names of its contributors may be used to
# endorse or promote products derived from this software without specific prior
# written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#

APP_NAME = 'lastfm-rip.py'

# Mandatory imports
try:
	import sys
	import xml.dom.minidom
	import urllib
	import getpass
	import hashlib
	import os
	import re
	import time
	import dbm
	from optparse import OptionParser
except:
	print "A required module was not found. Make sure you have the python"
	print "standard library installed."
	sys.exit(0)

# Check for eyeD3
hasEyeD3lib = False
try:
	import eyeD3
	hasEyeD3lib = True
except:
	print "Note: If you install eyeD3 I'll write tags for you!\n"

# Check for gnome keyring
hasGnomeKeyring = False
try:
	import gtk # sets app name
	import gnomekeyring
	import gconf
	hasGnomeKeyring = True
except:
	pass

# Check for unidecode
hasUnidecode = False
try:
	import unidecode
	hasUnidecode = True
except:
	pass

# Function to encode query strings
def encodeUrl(data):
	retval = ""
	allowedRange = map(chr,
		range(ord('a'), ord('z') + 1) + 
		range(ord('A'), ord('Z') + 1) + 
		range(ord('0'), ord('9') + 1))
	return "".join((
		"%%%02x" % ord(char) if char not in allowedRange else char
		for char in data))

# Parse command line
parser = OptionParser(usage="%prog [options] <lastfm-url>", epilog=("lastfm-rip.py - Copyright (c) 2009, Phillip "
	"Berndt. This software may be redistributed under the terms of the BSD-License"))
parser.add_option("-u", "--username", dest="username",
	help="Last.FM username")
parser.add_option("-p", "--password", dest="password",
	help="Last.FM password")
parser.add_option("-a", "--artist", dest="artist",
	help="Play similar artists")
parser.add_option("-d", "--directory", dest="directory",
	help="Store files to this directory", default=".")
parser.add_option("-f", "--filter", dest="filter",
	help="Apply argument as a regex to filter songs (artist - title)")
parser.add_option("-r", "--enqueue-rythmbox", dest="enqueue",
	default=False, action="store_true",
	help="Enqueue downloaded songs in Rythmbox (Requires dbus and pygtk)")
parser.add_option("-n", "--fetch-n-songs", dest="number",
	help="Try to fetch exactly n songs, then exit")
parser.add_option("-k", "--keep-list", dest="keep", action="store_true",
	help="Keep a persistent list of downloaded songs so this will never download the same song twice")
	
optlist, args = parser.parse_args()

numberOfSongs = -1
if optlist.number:
	try:
		numberOfSongs = int(optlist.number)
		assert(numberOfSongs > 0)
	except:
		parser.error("Parameter to -n should be numeric and greater than zero")
if optlist.filter:
	try:
		re.compile(optlist.filter)
	except:
		parser.error("--filter argument has a syntax error")

if optlist.artist and len(args) != 0:
	parser.error("Supply EITHER -a oder url")
if optlist.artist:
	args.append("lastfm://artist/%s/similarartists" % \
		encodeUrl(optlist.artist))
if not optlist.username:
	parser.error("--username is mandatory")
if len(args) != 1:
	print "You must supply a last-fm url. Using personal radio as default."
	args.append("lastfm://user/%s/personal" % optlist.username)

if optlist.enqueue:
	hasConnectionToRhythmbox = False
	try:
		import dbus
		session_bus = dbus.SessionBus()
		proxy_obj = session_bus.get_object('org.gnome.Rhythmbox', '/org/gnome/Rhythmbox/Shell')
		rhythmboxShell = dbus.Interface(proxy_obj, 'org.gnome.Rhythmbox.Shell')

		hasConnectionToRhythmbox = True
	except:
		pass


# try to read the password from GNOME'S keyring	
if (not optlist.password) and hasGnomeKeyring:
	gconf_key = "/apps/%s/%s/keyring_auth_token" % (APP_NAME, optlist.username )
	keyring = gnomekeyring.get_default_keyring_sync()
	auth_token = gconf.client_get_default().get_int(gconf_key)
	if auth_token > 0:
		item = gnomekeyring.item_get_info_sync(keyring,auth_token)
		optlist.password = item.get_secret()
	
if not optlist.password:
	# ask the user for a password
	password = getpass.getpass()
	if hasGnomeKeyring:
		# store password in GNOME's keyring
		gconf_key = "/apps/%s/%s/keyring_auth_token" % (APP_NAME, optlist.username )
		keyring = gnomekeyring.get_default_keyring_sync()
		auth_token = gnomekeyring.item_create_sync(
			keyring,
		        gnomekeyring.ITEM_GENERIC_SECRET,
		        "%s@last.fm" % optlist.username,
		        dict(appname=APP_NAME,username=optlist.username),
		        str(password), True)
		gconf.client_get_default().set_int(gconf_key, auth_token)
	passwordmd5 = hashlib.md5(password).hexdigest()
else:
	passwordmd5 = hashlib.md5(optlist.password).hexdigest()
if optlist.directory:
	try:
		os.chdir(optlist.directory)
	except:
		print "Failed to chdir into ", optlist.directory
		sys.exit(1)

# Login to last.fm / Perform handshake
try:
	handshakeUrl = (
		"http://ws.audioscrobbler.com/radio/handshake.php?version=1.4." +
		"2.58240&platform=linux&platformversion=Unix%2FLinux&username=" + optlist.username +
		"&passwordmd5=" + passwordmd5 +
		"&language=en" )
	handshakeDataRaw = urllib.urlopen(handshakeUrl).read()
	handshakeData = dict(filter(lambda x: len(x) == 2, ( x.split("=", 1) for x in handshakeDataRaw.split() )))
except:
	print "Handshake failed."
	try:
		if handshakeDataRaw:
			print "Information:"
			print handshakeDataRaw
	except: pass
	sys.exit(1)

if handshakeData["session"] == "FAILED":
	print "Login failed."
	sys.exit(1)

# Adjust session to radio station
adjustUrl = (
	"http://ws.audioscrobbler.com/radio/adjust.php?session=" + handshakeData["session"] + 
	"&url=" + args[0] )
adjustDataRaw = urllib.urlopen(adjustUrl).read()
adjustData = dict(filter(lambda x: len(x) == 2, ( x.split("=", 1) for x in adjustDataRaw.split() )))
if "response" not in adjustData or adjustData["response"] != "OK":
	print "Failed to tune station"
	sys.exit(1)

# Receive playlists (they contain all information and auto-update on reretrieval)
def getTracks():
	trackUrl = "http://ws.audioscrobbler.com/radio/xspf.php?sk=" + handshakeData["session"] + "&discovery=0&desktop=1.4.2.58240"
	xmlData = urllib.urlopen(trackUrl).read()
	try:
		playlist = xml.dom.minidom.parseString(xmlData)
	except KeyboardInterrupt:
		print "Caught interrupt. The latest file may be corrupted."
		sys.exit(0)
	except:
		print "Failed to parse XML."
		#print xmlData
		sys.exit(1)
	for track in playlist.getElementsByTagName("track"):
		try:
			attrs = [ "title", "location", "album", "creator" ]
			toyield = {}
			for attr in attrs:
				try:
					toyield[attr] = track.getElementsByTagName(attr)[0].firstChild.toxml()
				except:
					if attr == "title":
						raise Exception("Mandatory field title missing")
					toyield[attr] = ""
			yield toyield
		except KeyboardInterrupt:
			print "Caught interrupt. The latest file may be corrupted."
			sys.exit(0)
		except:
			print "Failed to extract data from XML node."
			#print track.toxml()
			sys.exit(1)

def displayDownloadStatus(blocks, blockSize, totalSize):
	percent = blocks * blockSize * 100 / totalSize;
	megaBytes = blocks * blockSize * 1.0 / 1024**2
	print "  %d%% (%.2f MB)\r" % (percent, megaBytes),
	sys.stdout.flush()

if optlist.keep:
	database = dbm.open("knownsongs", "c")

fetchedCount = 0
while True:
	for track in getTracks():
		# Check against filter / for existance
		storeAs = "%s - %s.mp3" % (track["creator"], track["title"])
		storeAs = storeAs.translate("/")
		trackHash = hashlib.md5(storeAs).hexdigest()
		if sys.getfilesystemencoding() != "UTF-8":
			if hasUnidecode:
				storeAs = unidecode.unidecode(storeAs)
			else:
				storeAs = storeAs.encode(sys.getfilesystemencoding(), "replace")
		if optlist.filter and not re.search(optlist.filter, storeAs, re.I):
			print "Skipping", storeAs, "(filter failed)"
			try:
				time.sleep(3)
			except KeyboardInterrupt:
				print "Caught interrupt. The latest file may be corrupted."
				sys.exit(0)
			continue
		if optlist.keep and database.has_key(trackHash):
			print "Skipping", storeAs, "(downloaded before)"
			continue
		if os.access(storeAs, os.F_OK):
			print "Skipping", storeAs, "(exists)"
			continue

		# Download file
		if numberOfSongs > 0:
			fetchedCount += 1
			if fetchedCount > numberOfSongs:
				sys.exit(0)
		print "Receiving", storeAs
		try:
			urllib.urlretrieve(track["location"], storeAs, displayDownloadStatus)
		except KeyboardInterrupt:
			os.unlink(storeAs)
			print "Caught interrupt. The latest file may be corrupted."
			sys.exit(0)

		if optlist.keep:
			database[trackHash] = ""

		if hasEyeD3lib:
			print "  Writing ID3 tag                  \r",
			tag = eyeD3.Tag()
			tag.link(storeAs)
			tag.header.setVersion(eyeD3.ID3_V2_3)
			tag.setTextEncoding(eyeD3.frames.UTF_16_ENCODING)
			tag.setArtist(track["creator"])
			tag.setTitle(track["title"])
			if track["album"]:
				tag.setAlbum(track["album"])
			tag.update()
		if optlist.enqueue and hasConnectionToRhythmbox:
			song = 'file://' + ( os.getcwd() + '/' + storeAs).replace(' ','%20')
			print song
			rhythmboxShell.loadURI(song,False)
			rhythmboxShell.addToQueue(song)
