Index: plugins/artdisplay/Makefile.am
===================================================================
RCS file: /cvs/gnome/rhythmbox/plugins/artdisplay/Makefile.am,v
retrieving revision 1.1
diff -u -r1.1 Makefile.am
--- plugins/artdisplay/Makefile.am	20 Apr 2006 17:25:18 -0000	1.1
+++ plugins/artdisplay/Makefile.am	9 May 2006 11:36:17 -0000
@@ -1,6 +1,8 @@
 # Art Display Python Plugin
+
+SUBDIRS = artdisplay
+
 plugindir = $(PLUGINDIR)
-plugin_PYTHON = artdisplay.py
 
 plugin_in_files = artdisplay.rb-plugin.desktop.in
 %.rb-plugin: %.rb-plugin.desktop.in $(INTLTOOL_MERGE) $(wildcard $(top_srcdir)/po/*po) ; $(INTLTOOL_MERGE) $(top_srcdir)/po $< $@ -d -u -c $(top_builddir)/po/.intltool-merge-cache
--- /dev/null	2006-05-07 14:17:55.416389500 -0700
+++ plugins/artdisplay/artdisplay/__init__.py	2006-05-09 03:25:56.000000000 -0700
@@ -0,0 +1,165 @@
+# -*- Mode: python; coding: utf-8; tab-width: 8; indent-tabs-mode: t; -*- 
+# artdisplay.py
+#
+# created by James Livingston, Gareth Murphy
+#
+# Copyright (C) 2006 - James Livingston
+#
+# 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, 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 St, Fifth Floor, Boston, MA 02110-1301  USA.
+
+import rhythmdb, rb
+import gtk, gobject
+
+from CoverArtDatabase import CoverArtDatabase
+
+
+FADE_STEPS = 10
+FADE_TOTAL_TIME = 1000
+
+class ArtDisplayPlugin (rb.Plugin):
+	def __init__ (self):
+		rb.Plugin.__init__ (self)
+		
+	def activate (self, shell):
+		self.shell = shell
+		sp = shell.get_player ()
+		self.pec_id = sp.connect ('playing-song-changed', self.playing_entry_changed)
+		self.pc_id = sp.connect ('playing-changed', self.playing_changed)
+		self.art_widget = gtk.Image ()
+		self.art_widget.set_padding (0, 5)
+		shell.add_widget (self.art_widget, rb.SHELL_UI_LOCATION_SIDEBAR)
+		self.sa_id = self.art_widget.connect ('size-allocate', self.size_allocated)
+		self.current_pixbuf = None
+		self.art_db = CoverArtDatabase ()
+		self.resize_id = 0
+		self.resize_in_progress = False
+		self.old_width = 0
+		self.fade_step = 0
+		self.fade_id = 0
+		self.current_entry = None
+		entry = sp.get_playing_entry ()
+		self.playing_entry_changed (sp, entry)
+	
+	def deactivate (self, shell):
+		self.shell = None
+		sp = shell.get_player ()
+		sp.disconnect (self.pec_id)
+		sp.disconnect (self.pc_id)
+		shell.remove_widget (self.art_widget, rb.SHELL_UI_LOCATION_SIDEBAR)
+		self.art_widget.disconnect(self.sa_id)
+		self.art_widget = None
+		self.current_pixbuf = None
+		self.art_db = None
+		self.action = None
+		self.action_group = None
+
+		if self.resize_id != 0:
+			gobject.source_remove (self.resize_id)
+		if self.fade_id != 0:
+			gobject.source_remove (self.fade_id)
+
+	def playing_changed (self, sp, playing):
+		self.set_entry(sp.get_playing_entry ())
+
+	def playing_entry_changed (self, sp, entry):
+		self.set_entry(entry)
+
+	def size_allocated (self, widget, allocation):
+		if self.old_width == allocation.width:
+			return
+		if self.resize_id == 0:
+			self.resize_id = gobject.idle_add (self.do_resize)
+		self.resize_in_progress = True
+		self.old_width = allocation.width
+
+	def do_resize(self):
+		if self.resize_in_progress:
+			self.resize_in_progress = False
+			self.update_displayed_art (True)
+			ret = True
+		else:
+			self.update_displayed_art (False)
+			self.resize_id = 0
+			ret = False
+		return ret
+
+	def set_entry (self, entry):
+		if entry != self.current_entry:
+			db = self.shell.get_property ("db")
+
+			# Intitates search in the database (which checks art cache, internet etc.)
+			self.current_entry = entry
+			self.art_db.get_pixbuf(db, entry, self.on_get_pixbuf_completed)
+
+	def on_get_pixbuf_completed(self, entry, pixbuf):
+		# Set the pixbuf for the entry returned from the art db
+		self.set_current_art (pixbuf)
+
+	def fade_art (self, old_pixbuf, new_pixbuf):
+		self.fade_step += 1.0 / FADE_STEPS
+
+		if self.fade_step <= 0.999:
+			# get pixbuf size
+			ow = old_pixbuf.get_width ()
+			nw = new_pixbuf.get_width ()
+			oh = old_pixbuf.get_height ()
+			nh = new_pixbuf.get_height ()
+
+			# find scale, widget size and alpha
+			ww = self.art_widget.parent.allocation.width 
+			wh = oh * ww / ow
+			sw = float(ww)/nw
+			sh = float(wh)/nh
+			alpha = int (self.fade_step * 255)
+
+			self.current_pixbuf = old_pixbuf.scale_simple (ww, wh, gtk.gdk.INTERP_NEAREST)
+			new_pixbuf.composite (self.current_pixbuf, 0, 0, ow, oh, 0, 0, sw, sh, gtk.gdk.INTERP_NEAREST, alpha)
+			self.art_widget.set_from_pixbuf (self.current_pixbuf)
+			self.art_widget.show ()
+			return True
+		else:
+			self.current_pixbuf = new_pixbuf
+			self.update_displayed_art (False);
+			self.fade_id = 0
+			return False
+
+	def set_current_art (self, pixbuf):
+		current_pb = self.art_widget.get_pixbuf ()
+		if self.fade_id != 0:
+			gobject.source_remove (self.fade_id)
+			self.fade_id = 0
+
+		if current_pb is not None and pixbuf is not None:
+			self.fade_step = 0.0
+			self.fade_art (current_pb, pixbuf)
+			self.fade_id = gobject.timeout_add ((FADE_TOTAL_TIME / FADE_STEPS), self.fade_art, current_pb, pixbuf)
+		else:
+			self.current_pixbuf = pixbuf;
+			self.update_displayed_art (False);
+
+	def update_displayed_art (self, quick):
+		if self.current_pixbuf is None:
+			# don't use  Image.clear(), not pygtk 2.6-compatible
+			self.art_widget.set_from_pixbuf (None)
+			self.art_widget.hide ()
+		else:
+			width = self.art_widget.parent.allocation.width 
+			height = self.current_pixbuf.get_height () * width / self.current_pixbuf.get_width ()
+			if quick:
+				mode = gtk.gdk.INTERP_NEAREST
+			else:
+				mode = gtk.gdk.INTERP_HYPER
+			self.art_widget.set_from_pixbuf (self.current_pixbuf.scale_simple (width, height, mode))
+			self.art_widget.show ()
--- /dev/null	2006-05-07 14:17:55.416389500 -0700
+++ plugins/artdisplay/artdisplay/AmazonCoverArtSearch.py	2006-05-09 04:32:38.000000000 -0700
@@ -0,0 +1,235 @@
+from xml.dom import minidom
+import re
+import locale
+import urllib
+
+import rhythmdb
+from Loader import Loader
+
+LICENSE_KEY = "18C3VZN9HCECM5G3HQG2"
+DEFAULT_LOCALE = "en_US"
+ASSOCIATE = "webservices-20"
+
+
+class Bag: pass
+
+class AmazonCoverArtSearch (object):
+	def __init__(self, loader):
+		self.searching = False
+		self.cancel = False
+		self.loader = loader
+		self._supportedLocales = {
+			"en_US" : ("us", "xml.amazon.com"),
+			"en_GB" : ("uk", "xml-eu.amazon.com"),
+			"de" : ("de", "xml-eu.amazon.com"),
+			"ja" : ("jp", "xml.amazon.co.jp")
+		}
+		self.db = None
+		self.entry = None
+
+	def __get_locale (self):
+		default = locale.getdefaultlocale ()
+		lc_id = DEFAULT_LOCALE
+		if default[0] is not None:
+			if self._supportedLocales.has_key (default[0]):
+				lc_id = default[0]
+
+		lc_host = self._supportedLocales[lc_id][1]
+		lc_name = self._supportedLocales[lc_id][0]
+		return ((lc_host, lc_name))
+
+	def search (self, db, entry, on_search_completed_callback, *args):
+		self.searching = True
+		self.cancel = False
+		self.db = db
+		self.entry = entry
+		self.on_search_completed_callback = on_search_completed_callback
+		self.args = args
+		self.keywords = []
+
+		st_artist = db.entry_get (entry, rhythmdb.PROP_ARTIST)
+		st_album = db.entry_get (entry, rhythmdb.PROP_ALBUM)
+
+		# Tidy up
+
+		# Replace quote characters
+		for char in ["\"", "'"]:
+			st_artist = st_artist.replace (char, '')
+			st_album = st_album.replace (char, '')
+
+
+		self.st_album = st_album
+		self.st_artist = st_artist
+
+		# Remove variants of Disc/CD [1-9] from album title before search
+		for exp in ["\([Dd]isc *[1-9]+\)", "\([Cc][Dd] *[1-9]+\)"]:
+			p = re.compile (exp)
+			st_album = p.sub ('', st_album)
+
+		st_album_no_vol = st_album
+		for exp in ["\(*[Vv]ol.*[1-9]+\)*"]:
+			p = re.compile (exp)
+			st_album_no_vol = p.sub ('', st_album_no_vol)
+
+		self.st_album_no_vol = st_album_no_vol
+
+		# Save current search's entry properties
+		self.search_album = st_album
+		self.search_artist = st_artist
+		self.search_album_no_vol = st_album_no_vol
+		
+		# TODO: Improve to decrease wrong cover downloads, maybe add severity?
+		# Assemble list of search keywords (and thus search queries)
+		if st_album == "Unknown":
+			self.keywords.append ("%s Best of" % (st_artist))
+			self.keywords.append ("%s Greatest Hits" % (st_artist))
+			self.keywords.append ("%s Essential" % (st_artist))
+			self.keywords.append ("%s Collection" % (st_artist))
+			self.keywords.append ("%s" % (st_artist))
+		elif st_artist == "Unknown":
+			self.keywords.append ("%s" % (st_album))
+			if st_album_no_vol != st_artist:
+				self.keywords.append ("%s" % (st_album_no_vol))
+			self.keywords.append ("Various %s" % (st_album))
+		else:
+			if st_album != st_artist:
+				self.keywords.append ("%s %s" % (st_artist, st_album))
+				if st_album_no_vol != st_artist:
+					self.keywords.append ("%s %s" % (st_artist, st_album_no_vol))
+				self.keywords.append ("%s" % (st_album))
+			self.keywords.append ("%s" % (st_artist))
+
+		# Initiate asynchronous search
+		self.search_next ();
+
+	def __build_url (self, keyword):
+		(lc_host, lc_name) = self.__get_locale ()
+
+		url = "http://" + lc_host + "/onca/xml3?f=xml"
+		url += "&t=%s" % ASSOCIATE
+		url += "&dev-t=%s" % LICENSE_KEY
+		url += "&type=%s" % 'lite'
+		url += "&locale=%s" % lc_name
+		url += "&mode=%s" % 'music'
+		url += "&%s=%s" % ('KeywordSearch', urllib.quote (keyword))
+
+		return url
+
+	def search_next (self):
+		self.searching = True
+		
+		if len(self.keywords)==0:
+			keyword = None
+		else:
+			keyword = self.keywords.pop(0)
+
+		if keyword is None:
+			# No keywords left to search -> no results
+			self.on_search_completed (None)
+		else:
+			# Retrieve search for keyword
+			url = self.__build_url (keyword.strip())
+			self.loader.get_url (url, self.on_search_response)
+
+	def __unmarshal (self, element):
+		rc = Bag ()
+		if isinstance (element, minidom.Element) and (element.tagName == 'Details'):
+			rc.URL = element.attributes["url"].value
+		childElements = [e for e in element.childNodes if isinstance (e, minidom.Element)]
+		if childElements:
+			for child in childElements:
+				key = child.tagName
+				if hasattr (rc, key):
+					if type (getattr (rc, key)) <> type([]):
+						setattr (rc, key, [getattr(rc, key)])
+					setattr (rc, key, getattr (rc, key) + [self.__unmarshal(child)])
+				elif isinstance(child, minidom.Element) and (child.tagName == 'Details'):
+					setattr (rc,key,[self.__unmarshal(child)])
+				else:
+					setattr (rc, key, self.__unmarshal(child))
+		else:
+			rc = "".join ([e.data for e in element.childNodes if isinstance (e, minidom.Text)])
+			if element.tagName == 'SalesRank':
+				rc = rc.replace ('.', '')
+				rc = rc.replace (',', '')
+				rc = int (rc)
+		return rc
+
+	def on_search_response (self, result_data):
+		if result_data is None:
+			self.search_next()
+			return
+
+		try:
+			xmldoc = minidom.parseString (result_data)
+		except:
+			self.search_next()
+			return
+		
+		data = self.__unmarshal (xmldoc).ProductInfo
+
+		if hasattr(data, 'ErrorMsg'):
+			# Search was unsuccessful, try next keyword
+			self.search_next ()
+		else:
+			# We got some search results
+			self.on_search_results (data.Details)
+
+	def on_search_results (self, results):
+		self.on_search_completed (results)
+
+	def on_search_completed (self, result):
+		self.on_search_completed_callback (self, self.entry, result, *self.args)
+		self.searching = False
+
+	def __tidy_up_string (self, s):
+		# Lowercase
+		s = s.lower ()
+		# Strip
+		s = s.strip ()
+
+		# TODO: Convert accented to unaccented (fixes matching Salomé vs Salome)
+		s = s.replace (" - "," ")	
+		s = s.replace (": "," ")
+
+		return s
+
+	def get_best_match (self, search_results):
+		# Default to "no match", our results must match our criteria
+		best_match = None
+
+		if self.search_album!="Unknown":
+			album_check = self.__tidy_up_string(self.search_album)
+			for item in search_results:
+				# Check for album name in ProductName
+				product_name = self.__tidy_up_string(item.ProductName)
+				if product_name == album_check:
+					# Found exact album, can not get better than that
+					best_match = item
+					break
+				# If we already found a best_match, just keep checking for exact one
+				elif (best_match is None) and (product_name.find (album_check) != -1):
+					best_match = item
+
+		# If we still have no definite hit, use first result where artist matches
+		if (self.search_artist!="Unknown"):
+			artist_check = self.__tidy_up_string(self.search_artist)
+			if best_match is None:
+				# Check if artist appears in the Artists list
+				hit = False
+				for item in search_results:
+					if type(item.Artists.Artist) <> type([]):
+						artists = [item.Artists.Artist]
+					else:
+						artists = item.Artists.Artist
+
+					for artist in artists:
+						artist = self.__tidy_up_string(artist)
+						if artist.find(artist_check) != -1:
+							best_match = item
+							hit = True						
+							break
+					if hit:
+						break
+ 
+		return best_match
--- /dev/null	2006-05-07 14:17:55.416389500 -0700
+++ plugins/artdisplay/artdisplay/CoverArtDatabase.py	2006-05-09 03:56:44.000000000 -0700
@@ -0,0 +1,101 @@
+import rhythmdb
+import os
+import gtk
+
+from AmazonCoverArtSearch import AmazonCoverArtSearch
+from Loader import Loader
+
+ART_FOLDER = '~/.gnome2/rhythmbox/covers'
+
+class CoverArtDatabase (object):
+	def __init__(self):
+		self.loader = Loader()
+
+	def create_search (self):
+		return AmazonCoverArtSearch (self.loader)
+
+	def build_art_cache_filename (self, album, artist, extension):
+		art_folder = os.path.expanduser (ART_FOLDER)
+		if not os.path.exists (art_folder):
+			os.mkdir (art_folder)
+		if extension is None:
+			extension = "jpg"
+
+		# FIXME: the following block of code is messy and needs to be redone ASAP
+		return art_folder + '/%s - %s.%s' % (artist.replace ('/', '-'), album.replace ('/', '-'), extension)	
+
+	def get_pixbuf (self, db, entry, callback):
+		if entry is None:
+			callback (entry, None)
+			return
+            
+		st_artist = db.entry_get (entry, rhythmdb.PROP_ARTIST)
+		st_album = db.entry_get (entry, rhythmdb.PROP_ALBUM)
+
+		# Handle special case
+		if st_album == "":
+			st_album = "Unknown"
+		if st_artist == "":
+			st_artist = "Unknown"
+
+		# If unknown artist and album there is no point continuing
+		if st_album == "Unknown" and st_artist == "Unknown":
+			callback (entry, None)
+			return
+
+		# replace quote characters
+		for char in ["\"", "'"]:
+			st_artist = st_artist.replace (char, '')
+			st_album = st_album.replace (char, '')
+
+		art_location = self.build_art_cache_filename (st_album, st_artist, "jpg")
+		blist_location = self.build_art_cache_filename (st_album, st_artist, "rb-blist")
+
+		# Check local cache
+		if os.path.exists (art_location):
+			pixbuf = gtk.gdk.pixbuf_new_from_file (art_location)	
+			callback (entry, pixbuf)
+		# Check for unsuccessful previous image download to prevent overhead search
+		elif os.path.exists (blist_location):
+			callback (entry, None)
+		else:
+			# Otherwise spawn (online) search-engine search
+			se = self.create_search ()
+			se.search (db, entry, self.on_search_engine_results, callback)
+
+	def on_search_engine_results (self, search_engine, entry, results, callback):
+		if results is None:
+			callback (entry, None)
+			return
+
+		# Get best match from results
+		best_match = search_engine.get_best_match (results)
+
+		# Attempt to download image for best match
+		pic_url = str(best_match.ImageUrlLarge)
+		self.loader.get_url (pic_url, self.on_image_data_received, search_engine, "large", callback, best_match)
+
+	def on_image_data_received (self, image_data, search_engine, image_version, callback, best_match):
+		if image_data is None:
+			search_engine.search_next()
+			return
+
+		if len(image_data) < 1000:
+			if image_version == "large":
+				# Fallback and try to load medium one
+				best_match = search_engine.get_best_match (best_match)
+				pic_url = str(best_match.ImageUrlMedium)
+				self.loader.get_url (pic_url, self.on_image_data_received, search_engine, "medium", callback)
+				return
+
+			blist_location = self.build_art_cache_filename(search_engine.st_album, search_engine.st_artist, "rb-blist")
+			f = file (blist_location, 'w')
+			f.close ()
+			search_engine.search_next()
+		else:
+			art_location = self.build_art_cache_filename(search_engine.st_album, search_engine.st_artist, "jpg")
+			f = file (art_location, 'wb')
+			f.write (image_data)
+			f.close ()
+			pixbuf = gtk.gdk.pixbuf_new_from_file (art_location)
+			callback (search_engine.entry, pixbuf)
--- /dev/null	2006-05-07 14:17:55.416389500 -0700
+++ plugins/artdisplay/artdisplay/Loader.py	2006-05-09 02:44:33.000000000 -0700
@@ -0,0 +1,55 @@
+import gobject
+
+try:
+	import gnomevfs
+	use_gnomevfs = True
+except:
+	import urllib
+	use_gnomevfs = False
+
+
+class GnomeVFSAsyncSrc(object):  
+	def __init__(self):
+	    	self.chunk = 4096
+
+	def read_cb(self, handle, buffer, exc_type, bytes_requested, (data, callback, args)):
+		if exc_type:
+			if issubclass (exc_type, gnomevfs.EOFError):
+				gobject.idle_add (callback, data, *args)
+				handle.close (lambda *args: None)
+			else:
+				gobject.idle_add (callback, None, *args)
+				handle.close (lambda *args: None)
+			return
+ 			
+		data += buffer
+		handle.read (self.chunk, self.read_cb, (data, callback, args))
+
+	def open_cb(self, handle, exc_type, (data, callback, args)):
+		if exc_type:
+			gobject.idle_add (callback, None, *args)
+			return
+
+		handle.read (self.chunk, self.read_cb, (data, callback, args))
+    
+	def get_url(self, url, callback, *args):
+		gnomevfs.async.open(url, self.open_cb, data=("", callback, args))
+
+
+class URLLibSrc(object):
+    def get_url(self, url, callback, *args):
+			try:
+				sock = urllib.urlopen (url)
+				data = sock.read ()
+				sock.close ()
+				callback (data, *args)
+			except:
+				callback (None, *args)
+				raise
+
+
+def Loader():
+	if use_gnomevfs:
+		return GnomeVFSAsyncSrc()
+	else:
+		return URLLibSrc()
--- /dev/null	2006-05-07 14:17:55.416389500 -0700
+++ plugins/artdisplay/artdisplay/Makefile.am	2006-05-09 02:44:33.000000000 -0700
@@ -0,0 +1,8 @@
+# Art Display Python Plugin
+
+plugindir = $(PLUGINDIR)/artdisplay
+plugin_PYTHON = 			\
+	AmazonCoverArtSearch.py		\
+	CoverArtDatabase.py		\
+	Loader.py			\
+	__init__.py
Index: configure.ac
===================================================================
RCS file: /cvs/gnome/rhythmbox/configure.ac,v
retrieving revision 1.234
diff -u -r1.234 configure.ac
--- configure.ac	6 May 2006 05:40:34 -0000	1.234
+++ configure.ac	9 May 2006 12:21:15 -0000
@@ -922,6 +922,7 @@
 plugins/sample-python/Makefile
 plugins/pythonconsole/Makefile
 plugins/artdisplay/Makefile
+plugins/artdisplay/artdisplay/Makefile
 bindings/Makefile
 bindings/python/Makefile
 help/Makefile
