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