1# Copyright (c) 2018 The GNOME Music Developers 2# 3# GNOME Music is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2 of the License, or 6# (at your option) any later version. 7# 8# GNOME Music is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License along 14# with GNOME Music; if not, write to the Free Software Foundation, Inc., 15# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 16# 17# The GNOME Music authors hereby grant permission for non-GPL compatible 18# GStreamer plugins to be used and distributed together with GStreamer 19# and GNOME Music. This permission is above and beyond the permissions 20# granted by the GPL license by which GNOME Music is covered. If you 21# modify this code, you may extend this exception to your version of the 22# code, but you are not obligated to do so. If you do not wish to do so, 23# delete this exception statement from your version. 24 25from enum import IntEnum 26from hashlib import md5 27 28import gi 29gi.require_version('Goa', '1.0') 30gi.require_version('Soup', '2.4') 31from gi.repository import Gio, GLib, Goa, GObject, Soup 32 33from gnomemusic.musiclogger import MusicLogger 34 35 36class GoaLastFM(GObject.GObject): 37 """Last.fm account handling through GOA 38 """ 39 40 class State(IntEnum): 41 """GoaLastFM account State. 42 43 NOT_AVAILABLE: GOA does not handle Last.fm accounts 44 NOT_CONFIGURED: GOA handles Last.fm but no user account has 45 been configured 46 DISABLED: a user account exists, but it is disabled 47 ENABLED: a user account exists and is enabled 48 """ 49 NOT_AVAILABLE = 0 50 NOT_CONFIGURED = 1 51 DISABLED = 2 52 ENABLED = 3 53 54 def __init__(self): 55 super().__init__() 56 57 self._log = MusicLogger() 58 59 self._client = None 60 self._state = GoaLastFM.State.NOT_AVAILABLE 61 self.notify("state") 62 self._reset_attributes() 63 Goa.Client.new(None, self._new_client_callback) 64 65 def _reset_attributes(self): 66 self._account = None 67 self._authentication = None 68 self._music_disabled_id = None 69 70 def _new_client_callback(self, source, result): 71 try: 72 self._client = source.new_finish(result) 73 except GLib.Error as error: 74 self._log.warning("Error: {}, {}".format( 75 error.code, error.message)) 76 return 77 78 manager = self._client.get_manager() 79 80 if manager is None: 81 self._log.info("GNOME Online Accounts is unavailable") 82 return 83 84 try: 85 manager.call_is_supported_provider( 86 "lastfm", None, self._lastfm_is_supported_cb) 87 except TypeError: 88 self._log.warning("Error: Unable to check if last.fm is supported") 89 90 def _lastfm_is_supported_cb(self, proxy, result): 91 try: 92 lastfm_supported = proxy.call_is_supported_provider_finish(result) 93 except GLib.Error as e: 94 self._log.warning( 95 "Error: Unable to check if last.fm is supported: {}".format( 96 e.message)) 97 return 98 99 if lastfm_supported is False: 100 return 101 102 self._state = GoaLastFM.State.NOT_CONFIGURED 103 self.notify("state") 104 self._client.connect("account-added", self._goa_account_added) 105 self._client.connect("account-removed", self._goa_account_removed) 106 self._find_lastfm_account() 107 108 def _goa_account_added(self, client, obj): 109 self._find_lastfm_account() 110 111 def _goa_account_removed(self, client, obj): 112 account = obj.get_account() 113 if account.props.provider_type == "lastfm": 114 self._account.disconnect(self._music_disabled_id) 115 self._state = GoaLastFM.State.NOT_CONFIGURED 116 self._reset_attributes() 117 self.notify("state") 118 119 def _find_lastfm_account(self): 120 accounts = self._client.get_accounts() 121 122 for obj in accounts: 123 account = obj.get_account() 124 if account.props.provider_type == "lastfm": 125 self._authentication = obj.get_oauth2_based() 126 self._account = account 127 self._music_disabled_id = self._account.connect( 128 'notify::music-disabled', self._goa_music_disabled) 129 self._goa_music_disabled(self._account) 130 break 131 132 def _goa_music_disabled(self, klass, args=None): 133 if self._account.props.music_disabled is True: 134 self._state = GoaLastFM.State.DISABLED 135 else: 136 self._state = GoaLastFM.State.ENABLED 137 138 self.notify("state") 139 140 @GObject.Property(type=int, default=0, flags=GObject.ParamFlags.READABLE) 141 def state(self): 142 """Retrieve the state for the Last.fm account 143 144 :returns: The account state 145 :rtype: GoaLastFM.State 146 """ 147 return self._state 148 149 def enable_music(self): 150 """Enable music suport of the Last.fm account""" 151 self._account.props.music_disabled = False 152 153 @GObject.Property(type=str, default="", flags=GObject.ParamFlags.READABLE) 154 def identity(self): 155 """Get Last.fm account identity 156 157 :returns: Last.fm account identity 158 :rtype: str 159 """ 160 return self._account.props.identity 161 162 @GObject.Property 163 def secret(self): 164 """Retrieve the Last.fm client secret""" 165 return self._authentication.props.client_secret 166 167 @GObject.Property 168 def client_id(self): 169 """Retrieve the Last.fm client id""" 170 return self._authentication.props.client_id 171 172 @GObject.Property 173 def session_key(self): 174 """Retrieve the Last.fm session key""" 175 try: 176 return self._authentication.call_get_access_token_sync(None)[0] 177 except GLib.Error as e: 178 self._log.warning( 179 "Error: Unable to retrieve last.fm session key: {}".format( 180 e.message)) 181 return None 182 183 def configure(self): 184 if self.props.state == GoaLastFM.State.NOT_AVAILABLE: 185 self._log.warning("Error, cannot configure a Last.fm account.") 186 return 187 188 Gio.bus_get(Gio.BusType.SESSION, None, self._get_connection_db, None) 189 190 def _get_connection_db(self, source, res, user_data=None): 191 try: 192 connection = Gio.bus_get_finish(res) 193 except GLib.Error as e: 194 self._log.warning( 195 "Error: Unable to get the DBus connection: {}".format( 196 e.message)) 197 return 198 199 Gio.DBusProxy.new( 200 connection, Gio.DBusProxyFlags.NONE, None, 201 "org.gnome.ControlCenter", "/org/gnome/ControlCenter", 202 "org.gtk.Actions", None, self._get_control_center_proxy_cb, None) 203 204 def _get_control_center_proxy_cb(self, source, res, user_data=None): 205 try: 206 settings_proxy = Gio.DBusProxy.new_finish(res) 207 except GLib.Error as e: 208 self._log.warning( 209 "Error: Unable to get a proxy: {}".format(e.message)) 210 return 211 212 if self._state == GoaLastFM.State.NOT_CONFIGURED: 213 params = [GLib.Variant("s", "add"), GLib.Variant("s", "lastfm")] 214 else: 215 params = [GLib.Variant("s", self._account.props.id)] 216 217 args = GLib.Variant("(sav)", ("online-accounts", params)) 218 variant = GLib.Variant("(sava{sv})", ("launch-panel", [args], {})) 219 settings_proxy.call( 220 "Activate", variant, Gio.DBusCallFlags.NONE, -1, None, 221 self._on_control_center_activated) 222 223 def _on_control_center_activated(self, proxy, res, user_data=None): 224 try: 225 proxy.call_finish(res) 226 except GLib.Error as e: 227 self._log.warning( 228 "Error: Failed to activate control-center: {}".format( 229 e.message)) 230 231 232class LastFmScrobbler(GObject.GObject): 233 """Scrobble songs to Last.fm""" 234 235 def __init__(self, application): 236 """Intialize LastFm Scrobbler 237 238 :param Application application: Application object 239 """ 240 super().__init__() 241 242 self._log = application.props.log 243 self._settings = application.props.settings 244 self._report = self._settings.get_boolean("lastfm-report") 245 246 self._scrobbled = False 247 self._account_state = GoaLastFM.State.NOT_AVAILABLE 248 249 self._goa_lastfm = GoaLastFM() 250 self._goa_lastfm.bind_property( 251 "state", self, "account-state", GObject.BindingFlags.SYNC_CREATE) 252 253 self._soup_session = Soup.Session.new() 254 self._scrobble_cache = [] 255 256 def configure(self): 257 self._goa_lastfm.configure() 258 259 @GObject.Property(type=str, default="", flags=GObject.ParamFlags.READABLE) 260 def identity(self): 261 """Get Last.fm account identity 262 263 :returns: Last.fm account identity 264 :rtype: str 265 """ 266 return self._goa_lastfm.props.identity 267 268 @GObject.Property(type=int, default=GoaLastFM.State.NOT_AVAILABLE) 269 def account_state(self): 270 """Get the Last.fm account state 271 272 :returns: state of the Last.fm account 273 :rtype: GoaLastFM.State 274 """ 275 return self._account_state 276 277 @account_state.setter # type: ignore 278 def account_state(self, value): 279 """Set the Last.fm account state 280 281 The account state depends on GoaLast.fm state property. 282 :param GoaLastFM.State value: new state 283 """ 284 self._account_state = value 285 self.notify("can-scrobble") 286 287 @GObject.Property(type=bool, default=False) 288 def can_scrobble(self): 289 """Get can scrobble status 290 291 Music is reported to Last.fm if the "lastfm-report" setting is 292 True and if a Goa Last.fm account is configured with music 293 support enabled. 294 295 :returns: True is music is reported to Last.fm 296 :rtype: bool 297 """ 298 return (self.props.account_state == GoaLastFM.State.ENABLED 299 and self._report is True) 300 301 @can_scrobble.setter # type: ignore 302 def can_scrobble(self, value): 303 """Set the can_scrobble status 304 305 If no account is configured, nothing happens. 306 If the new value is True, "lastfm-report" is changed and music 307 support in the Last.fm is enabled if necessary. 308 If the new value is False, "lastfm-report" is changed but the 309 Last.fm account is not changed. 310 :param bool value: new value 311 """ 312 if self.props.account_state == GoaLastFM.State.NOT_CONFIGURED: 313 return 314 315 if (value is True 316 and self.props.account_state == GoaLastFM.State.DISABLED): 317 self._goa_lastfm.enable_music() 318 319 self._settings.set_boolean("lastfm-report", value) 320 self._report = value 321 322 @GObject.Property(type=bool, default=False) 323 def scrobbled(self): 324 """Bool indicating current scrobble status""" 325 return self._scrobbled 326 327 @scrobbled.setter # type: ignore 328 def scrobbled(self, scrobbled): 329 self._scrobbled = scrobbled 330 331 def _lastfm_api_call(self, coresong, time_stamp, request_type_key): 332 """Internal method called by self.scrobble""" 333 api_key = self._goa_lastfm.client_id 334 sk = self._goa_lastfm.session_key 335 if sk is None: 336 self._log.warning( 337 "Error: Unable to perform last.fm api call {}".format( 338 request_type_key)) 339 return 340 secret = self._goa_lastfm.secret 341 342 artist = coresong.props.artist 343 title = coresong.props.title 344 345 request_type = { 346 "update now playing": "track.updateNowPlaying", 347 "scrobble": "track.scrobble" 348 } 349 350 # The album is optional. So only provide it when it is 351 # available. 352 album = coresong.props.album 353 354 request_dict = {} 355 if (request_type_key == "scrobble" 356 and time_stamp is not None): 357 self._scrobble_cache.append({ 358 "artist": artist, 359 "track": title, 360 "album": album, 361 "timestamp": time_stamp 362 }) 363 364 for index, data in enumerate(self._scrobble_cache): 365 request_dict.update({ 366 "artist[{}]".format(index): data['artist'], 367 "track[{}]".format(index): data['track'], 368 "timestamp[{}]".format(index): str(data['timestamp']), 369 }) 370 if album: 371 request_dict.update({ 372 "album[{}]".format(index): data['album'] 373 }) 374 else: 375 if album: 376 request_dict.update({ 377 "album": album 378 }) 379 380 if time_stamp is not None: 381 request_dict.update({ 382 "timestamp": str(time_stamp) 383 }) 384 385 request_dict.update({ 386 "artist": artist, 387 "track": title, 388 }) 389 390 request_dict.update({ 391 "api_key": api_key, 392 "method": request_type[request_type_key], 393 "sk": sk, 394 }) 395 396 sig = "" 397 for key in sorted(request_dict): 398 sig += key + request_dict[key] 399 400 sig += secret 401 402 api_sig = md5(sig.encode()).hexdigest() 403 request_dict.update({ 404 "api_sig": api_sig 405 }) 406 407 msg = Soup.form_request_new_from_hash( 408 "POST", "https://ws.audioscrobbler.com/2.0/", request_dict) 409 self._soup_session.queue_message( 410 msg, self._lastfm_api_callback, request_type_key) 411 412 def _lastfm_api_callback(self, session, msg, request_type_key): 413 """Internall callback method called by queue_message""" 414 status_code = msg.props.status_code 415 if status_code != 200: 416 self._log.debug("Failed to {} track {} : {}".format( 417 request_type_key, status_code, msg.props.reason_phrase)) 418 self._log.debug(msg.props.response_body.data) 419 elif (status_code == 200 420 and request_type_key == "scrobble"): 421 self._scrobble_cache.clear() 422 423 def scrobble(self, coresong, time_stamp): 424 """Scrobble a song to Last.fm. 425 426 If not connected to Last.fm nothing happens 427 428 :param coresong: CoreSong to scrobble 429 :param time_stamp: song loaded time (epoch time) 430 """ 431 self.props.scrobbled = True 432 433 if not self.props.can_scrobble: 434 return 435 436 self._lastfm_api_call(coresong, time_stamp, "scrobble") 437 438 def now_playing(self, coresong): 439 """Set now playing song to Last.fm 440 441 If not connected to Last.fm nothing happens 442 443 :param coresong: CoreSong to use for now playing 444 """ 445 self.props.scrobbled = False 446 447 if coresong is None: 448 return 449 450 if not self.props.can_scrobble: 451 return 452 453 self._lastfm_api_call(coresong, None, "update now playing") 454