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 & 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(" & ", " & ") # 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