1# -*- Mode: python; coding: utf-8; tab-width: 8; indent-tabs-mode: t; -*-
2#
3# Copyright (C) 2006 Adam Zimmerman  <adam_zimmerman@sfu.ca>
4# Copyright (C) 2006 James Livingston  <doclivingston@gmail.com>
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2, or (at your option)
9# any later version.
10#
11# The Rhythmbox authors hereby grant permission for non-GPL compatible
12# GStreamer plugins to be used and distributed together with GStreamer
13# and Rhythmbox. This permission is above and beyond the permissions granted
14# by the GPL license by which Rhythmbox is covered. If you modify this code
15# you may extend this exception to your version of the code, but you are not
16# obligated to do so. If you do not wish to do so, delete this exception
17# statement from your version.
18#
19# This program is distributed in the hope that it will be useful,
20# but WITHOUT ANY WARRANTY; without even the implied warranty of
21# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
22# GNU General Public License for more details.
23#
24# You should have received a copy of the GNU General Public License
25# along with this program; if not, write to the Free Software
26# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA.
27
28import os
29import sys
30import xml
31import urllib.parse, urllib.request
32import threading
33import zipfile
34
35import rb
36from gi.repository import RB
37from gi.repository import GObject, Gtk, Gdk, Gio, GLib
38
39from TrackListHandler import TrackListHandler
40from DownloadAlbumHandler import DownloadAlbumHandler, MagnatuneDownloadError
41import MagnatuneAccount
42
43import gettext
44gettext.install('rhythmbox', RB.locale_dir())
45
46magnatune_partner_id = "rhythmbox"
47
48# URIs
49magnatune_song_info_uri = "http://magnatune.com/info/song_info_xml.zip"
50magnatune_changed_uri = "http://magnatune.com/info/changed.txt"
51magnatune_buy_album_uri = "https://magnatune.com/buy/choose?"
52magnatune_api_download_uri = "http://%s:%s@download.magnatune.com/buy/membership_free_dl_xml?"
53
54magnatune_in_progress_dir = Gio.file_new_for_path(RB.user_data_dir()).resolve_relative_path('magnatune')
55magnatune_cache_dir = Gio.file_new_for_path(RB.user_cache_dir()).resolve_relative_path('magnatune')
56
57magnatune_song_info = os.path.join(magnatune_cache_dir.get_path(), 'song_info.xml')
58magnatune_song_info_temp = os.path.join(magnatune_cache_dir.get_path(), 'song_info.zip.tmp')
59magnatune_changes = os.path.join(magnatune_cache_dir.get_path(), 'changed.txt')
60
61
62class MagnatuneSource(RB.BrowserSource):
63	def __init__(self):
64		RB.BrowserSource.__init__(self)
65		self.hate = self
66
67		self.__popup = None
68		self.__settings = Gio.Settings.new("org.gnome.rhythmbox.plugins.magnatune")
69		# source state
70		self.__activated = False
71		self.__db = None
72		self.__info_screen = None # the loading screen
73
74		# track data
75		self.__sku_dict = {}
76		self.__home_dict = {}
77		self.__art_dict = {}
78
79		# catalogue stuff
80		self.__has_loaded = False # whether the catalog has been loaded yet
81		self.__update_id = 0 # GLib.idle_add id for catalog updates
82		self.__catalogue_loader = None
83		self.__catalogue_check = None
84		self.__load_progress = None
85		self.__download_progress = None
86
87		# album download stuff
88		self.__downloads = {} # keeps track of download progress for each file
89		self.__copies = {} # keeps copy objects for each file
90
91		self.__art_store = RB.ExtDB(name="album-art")
92
93	#
94	# RBSource methods
95	#
96
97	def do_show_entry_popup(self):
98		if self.__popup is None:
99			builder = Gtk.Builder()
100			builder.add_from_file(rb.find_plugin_file(self.props.plugin, "magnatune-popup.ui"))
101			self.__popup = builder.get_object("magnatune-popup")
102
103		menu = Gtk.Menu.new_from_model(self.__popup)
104		menu.attach_to_widget(self, None)
105		menu.popup(None, None, None, None, 3, Gtk.get_current_event_time())
106
107
108	def do_selected(self):
109		if not self.__activated:
110			shell = self.props.shell
111			self.__db = shell.props.db
112			self.__entry_type = self.props.entry_type
113
114			if not magnatune_in_progress_dir.query_exists(None):
115				magnatune_in_progress_path = magnatune_in_progress_dir.get_path()
116				os.mkdir(magnatune_in_progress_path, 0o700)
117
118			if not magnatune_cache_dir.query_exists(None):
119				magnatune_cache_path = magnatune_cache_dir.get_path()
120				os.mkdir(magnatune_cache_path, 0o700)
121
122			self.__activated = True
123			self.__show_loading_screen(True)
124
125			# start our catalogue updates
126			self.__update_id = GLib.timeout_add_seconds(6 * 60 * 60, self.__update_catalogue)
127			self.__update_catalogue()
128
129	def do_can_delete(self):
130		return False
131
132	def do_pack_content(self, content):
133		self.__paned_box = Gtk.VBox(homogeneous=False, spacing=5)
134		self.pack_start(self.__paned_box, True, True, 0)
135		self.__paned_box.pack_start(content, True, True, 0)
136
137
138	def do_delete_thyself(self):
139		if self.__update_id != 0:
140			GLib.source_remove(self.__update_id)
141			self.__update_id = 0
142
143		if self.__catalogue_loader is not None:
144			self.__catalogue_loader.cancel()
145			self.__catalogue_loader = None
146
147		if self.__catalogue_check is not None:
148			self.__catalogue_check.cancel()
149			self.__catalogue_check = None
150
151		RB.BrowserSource.do_delete_thyself(self)
152
153	#
154	# methods for use by plugin and UI
155	#
156
157	def display_artist_info(self):
158		screen = self.props.shell.props.window.get_screen()
159		tracks = self.get_entry_view().get_selected_entries()
160		if len(tracks) == 0:
161			return
162
163		tr = tracks[0]
164		sku = self.__sku_dict[tr.get_string(RB.RhythmDBPropType.LOCATION)]
165		url = self.__home_dict[sku]
166		Gtk.show_uri(screen, url, Gdk.CURRENT_TIME)
167
168
169	def download_redirect(self):
170		screen = self.props.shell.props.window.get_screen()
171		tracks = self.get_entry_view().get_selected_entries()
172		if len(tracks) == 0:
173			return
174
175		tr = tracks[0]
176		sku = self.__sku_dict[tr.get_string(RB.RhythmDBPropType.LOCATION)]
177		url = magnatune_buy_album_uri + urllib.parse.urlencode({ 'sku': sku, 'ref': magnatune_partner_id })
178		Gtk.show_uri(screen, url, Gdk.CURRENT_TIME)
179
180
181	def download_album(self):
182		if self.__settings['account-type'] != 'download':
183			# The user doesn't have a download account, so redirect them to the download signup page
184			self.download_redirect()
185			return
186
187		try:
188			# Just use the first library location
189			library = Gio.Settings.new("org.gnome.rhythmbox.rhythmdb")
190			library_location = library['locations'][0]
191		except IndexError as e:
192			RB.error_dialog(title = _("Couldn't download album"),
193				        message = _("You must have a library location set to download an album."))
194			return
195
196		tracks = self.get_entry_view().get_selected_entries()
197		skus = []
198
199		for track in tracks:
200			sku = self.__sku_dict[track.get_string(RB.RhythmDBPropType.LOCATION)]
201			if sku in skus:
202				continue
203			skus.append(sku)
204			self.__auth_download(sku)
205
206	#
207	# internal catalogue downloading and loading
208	#
209
210	def __update_catalogue(self):
211		def update_cb(remote_changes):
212			self.__catalogue_check = None
213			try:
214				f = open(magnatune_changes, 'rt')
215				local_changes = f.read().strip()
216			except:
217				local_changes = ""
218
219			remote_changes = remote_changes.strip().decode("iso-8859-1")
220			print("local checksum %s, remote checksum %s" % (local_changes, remote_changes))
221			if local_changes != remote_changes:
222				try:
223					f = open(magnatune_changes, 'wt')
224					f.write(remote_changes + "\n")
225					f.close()
226				except Exception as e:
227					print("unable to write local change id: %s" % str(e))
228
229				download_catalogue()
230			elif self.__has_loaded is False:
231				load_catalogue()
232
233		def download_catalogue():
234			def find_song_info(catalogue):
235				for info in catalogue.infolist():
236					if info.filename.endswith("song_info.xml"):
237						return info.filename;
238				return None
239
240			def download_progress(copy, complete, total, self):
241				self.__load_progress.props.task_progress = min(float(complete) / total, 1.0)
242
243			def download_finished(copy, success, self):
244				if not success:
245					print("catalog download failed")
246					print(copy.get_error())
247					return
248
249				print("catalog download successful")
250				# done downloading, unzip to real location
251				catalog_zip = zipfile.ZipFile(magnatune_song_info_temp)
252				catalog = open(magnatune_song_info, 'wb')
253				filename = find_song_info(catalog_zip)
254				if filename is None:
255					RB.error_dialog(title=_("Unable to load catalog"),
256							message=_("Rhythmbox could not understand the Magnatune catalog, please file a bug."))
257					return
258				catalog.write(catalog_zip.read(filename))
259				catalog.close()
260				catalog_zip.close()
261
262				df = Gio.file_new_for_path(magnatune_song_info_temp)
263				df.delete(None)
264				self.__catalogue_loader = None
265
266				self.__load_progress.props.task_outcome = RB.TaskOutcome.COMPLETE
267
268				load_catalogue()
269
270			try:
271				df = Gio.file_new_for_path(magnatune_song_info_temp)
272				df.delete(None)
273			except:
274				pass
275
276			self.__load_progress = RB.TaskProgressSimple.new()
277			self.__load_progress.props.task_label = _("Loading Magnatune catalog")
278			self.props.shell.props.task_list.add_task(self.__load_progress)
279
280			self.__catalog_loader = RB.AsyncCopy()
281			self.__catalog_loader.set_progress(download_progress, self)
282			self.__catalog_loader.start(magnatune_song_info_uri, magnatune_song_info_temp, download_finished, self)
283
284		def load_catalogue():
285
286			def catalogue_chunk_cb(loader, chunk, total, parser):
287				if chunk is None:
288					self.__load_progress.props.task_outcome = RB.TaskOutcome.COMPLETE
289					error = loader.get_error()
290					if error:
291						# report error somehow?
292						print("error loading catalogue: %s" % error)
293
294					try:
295						parser.close()
296					except xml.sax.SAXParseException as e:
297						# there isn't much we can do here
298						print("error parsing catalogue: %s" % e)
299
300					self.__show_loading_screen(False)
301					self.__catalogue_loader = None
302
303					# restart in-progress downloads
304					# (doesn't really belong here)
305					for f in magnatune_in_progress_dir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, None):
306						name = f.get_name()
307						if not name.startswith("in_progress_"):
308							continue
309						(result, uri, etag) = magnatune_in_progress_dir.resolve_relative_path(name).load_contents(None)
310						uri = uri.decode('utf-8')
311
312						print("restarting download from %s" % uri)
313						self.__download_album(uri, name[12:])
314				else:
315					# hack around some weird chars that show up in the catalogue for some reason
316					data = chunk.get_data().decode('utf-8', errors='replace')
317					data = data.replace("\x19", "'")
318					data = data.replace("\x13", "-")
319
320					# argh.
321					data = data.replace("Rock & Roll", "Rock &amp; Roll")
322
323					try:
324						parser.feed(data)
325					except xml.sax.SAXParseException as e:
326						print("error parsing catalogue: %s" % e)
327
328					load_size['size'] += len(data)
329					self.__load_progress.props.task_progress = min(float(load_size['size']) / total, 1.0)
330
331
332			self.__has_loaded = True
333			self.__load_progress = RB.TaskProgressSimple.new()
334			self.__load_progress.props.task_label = _("Loading Magnatune catalog")
335			self.props.shell.props.task_list.add_task(self.__load_progress)
336
337			load_size = {'size': 0}
338
339			parser = xml.sax.make_parser()
340			parser.setContentHandler(TrackListHandler(self.__db, self.__entry_type, self.__sku_dict, self.__home_dict, self.__art_dict))
341
342			self.__catalogue_loader = RB.ChunkLoader()
343			self.__catalogue_loader.set_callback(catalogue_chunk_cb, parser)
344			self.__catalogue_loader.start(magnatune_song_info, 64*1024)
345
346
347		self.__catalogue_check = rb.Loader()
348		self.__catalogue_check.get_url(magnatune_changed_uri, update_cb)
349
350
351	def __show_loading_screen(self, show):
352		if self.__info_screen is None:
353			# load the builder stuff
354			builder = Gtk.Builder()
355			builder.add_from_file(rb.find_plugin_file(self.props.plugin, "magnatune-loading.ui"))
356			self.__info_screen = builder.get_object("magnatune_loading_scrolledwindow")
357			self.pack_start(self.__info_screen, True, True, 0)
358			self.get_entry_view().set_no_show_all(True)
359			self.__info_screen.set_no_show_all(True)
360
361		self.__info_screen.set_property("visible", show)
362		self.__paned_box.set_property("visible", not show)
363
364	def __notify_status_changed(self):
365		pass
366
367	#
368	# internal purchasing code
369	#
370
371	def __auth_download(self, sku): # http://magnatune.com/info/api
372
373		def auth_data_cb(data, userpass):
374			(username, password) = userpass
375			dl_album_handler = DownloadAlbumHandler(self.__settings['format'])
376			auth_parser = xml.sax.make_parser()
377			auth_parser.setContentHandler(dl_album_handler)
378
379			if data is None:
380				# hmm.
381				return
382
383			try:
384				data = data.decode("utf-8")
385				data = data.replace("<br>", "") # get rid of any stray <br> tags that will mess up the parser
386				data = data.replace(" & ", " &amp; ") # clean up some missing escaping
387				# print data
388				auth_parser.feed(data)
389				auth_parser.close()
390
391				# process the URI: add authentication info, quote the filename component for some reason
392				parsed = urllib.parse.urlparse(dl_album_handler.url)
393				netloc = "%s:%s@%s" % (username, password, parsed.hostname)
394
395				spath = os.path.split(urllib.request.url2pathname(parsed.path))
396				basename = spath[1]
397				path = urllib.request.pathname2url(os.path.join(spath[0], urllib.parse.quote(basename)))
398
399				authed = (parsed[0], netloc, path) + parsed[3:]
400				audio_dl_uri = urllib.parse.urlunparse(authed)
401
402				print("download uri for %s is %s" % (sku, audio_dl_uri))
403				self.__download_album(audio_dl_uri, sku)
404
405			except MagnatuneDownloadError as e:
406				RB.error_dialog(title = _("Download Error"),
407						message = _("An error occurred while trying to authorize the download.\nThe Magnatune server returned:\n%s") % str(e))
408			except Exception as e:
409				sys.excepthook(*sys.exc_info())
410				RB.error_dialog(title = _("Error"),
411						message = _("An error occurred while trying to download the album.\nThe error text is:\n%s") % str(e))
412
413		print("downloading album: " + sku)
414		account = MagnatuneAccount.instance()
415		(account_type, username, password) = account.get()
416		url_dict = {
417			'id':	magnatune_partner_id,
418			'sku':	sku
419		}
420		url = magnatune_api_download_uri % (username, password)
421		url = url + urllib.parse.urlencode(url_dict)
422
423		l = rb.Loader()
424		l.get_url(url, auth_data_cb, (username, password))
425
426
427	def __download_album(self, audio_dl_uri, sku):
428		def update_progress(self):
429			if len(self.__downloads) == 0:
430				self.__download_progress.props.task_outcome = RB.TaskOutcome.COMPLETE
431				self.__download_progress = None
432			else:
433				complete, total = map(sum, zip(*self.__downloads.values()))
434				if total > 0:
435					self.__download_progress.props.task_progress = min(float(complete) / total, 1.0)
436
437		def download_progress(copy, complete, total, self):
438			self.__downloads[audio_dl_uri] = (complete, total)
439			update_progress(self)
440
441		def download_finished(copy, success, self):
442			del self.__downloads[audio_dl_uri]
443			del self.__copies[audio_dl_uri]
444
445			print("download of %s finished: %s" % (audio_dl_uri, success))
446			if success:
447				threading.Thread(target=unzip_album).start()
448			else:
449				remove_download_files()
450
451			update_progress(self)
452
453
454		def unzip_album():
455			# just use the first library location
456			library = Gio.Settings.new("org.gnome.rhythmbox.rhythmdb")
457			library_location = Gio.file_new_for_uri(library['locations'][0])
458
459			print("unzipping %s" % dest.get_path())
460			album = zipfile.ZipFile(dest.get_path())
461			for track in album.namelist():
462				track_uri = library_location.resolve_relative_path(track).get_uri()
463				print("zip file entry: %s => %s" % (track, track_uri))
464
465				track_uri = RB.sanitize_uri_for_filesystem(track_uri)
466				RB.uri_create_parent_dirs(track_uri)
467
468				track_out = Gio.file_new_for_uri(track_uri).create(Gio.FileCreateFlags.NONE, None)
469				if track_out is not None:
470					track_out.write(album.read(track), None)
471					track_out.close(None)
472					print("adding %s to library" % track_uri)
473					self.__db.add_uri(track_uri)
474
475			album.close()
476			remove_download_files()
477
478		def remove_download_files():
479			print("removing download files")
480			in_progress.delete(None)
481			dest.delete(None)
482
483		in_progress = magnatune_in_progress_dir.resolve_relative_path("in_progress_" + sku)
484		dest = magnatune_in_progress_dir.resolve_relative_path(sku)
485
486		in_progress.replace_contents(audio_dl_uri.encode('utf-8'),
487					     None,
488					     False,
489					     Gio.FileCreateFlags.PRIVATE|Gio.FileCreateFlags.REPLACE_DESTINATION,
490					     None)
491
492		try:
493			# For some reason, Gio.FileCopyFlags.OVERWRITE doesn't work for copy_async
494			dest.delete(None)
495		except:
496			pass
497
498		if self.__download_progress is None:
499			self.__download_progress = RB.TaskProgressSimple.new()
500			self.__download_progress.props.task_label = _("Downloading from Magnatune")
501			self.__download_progress.connect('cancel-task', self.cancel_downloads)
502			self.props.shell.props.task_list.add_task(self.__download_progress)
503
504		dl = RB.AsyncCopy()
505		dl.set_progress(download_progress, self)
506		dl.start(audio_dl_uri, dest.get_uri(), download_finished, self)
507		self.__downloads[audio_dl_uri] = (0, 0) # (current, total)
508		self.__copies[audio_dl_uri] = dl
509
510	def cancel_downloads(self, task):
511		for download in self.__copies.values():
512			download.cancel()
513
514		task.props.task_outcome = RB.TaskOutcome.CANCELLED
515
516	def playing_entry_changed(self, entry):
517		if not self.__db or not entry:
518			return
519		if entry.get_entry_type() != self.__db.entry_type_get_by_name("MagnatuneEntryType"):
520			return
521
522		sku = self.__sku_dict[entry.get_string(RB.RhythmDBPropType.LOCATION)]
523		key = RB.ExtDBKey.create_storage("album", entry.get_string(RB.RhythmDBPropType.ALBUM))
524		key.add_field("artist", entry.get_string(RB.RhythmDBPropType.ARTIST))
525		self.__art_store.store_uri(key, self.__art_dict[sku])
526
527GObject.type_register(MagnatuneSource)
528