From f42d2264917f109b8cee1d641a475934a456aa61 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:23:59 +0200 Subject: [PATCH 01/26] Add a BaseFrontend --- mopidy/frontends/base.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 mopidy/frontends/base.py diff --git a/mopidy/frontends/base.py b/mopidy/frontends/base.py new file mode 100644 index 00000000..92545b73 --- /dev/null +++ b/mopidy/frontends/base.py @@ -0,0 +1,30 @@ +class BaseFrontend(object): + """ + Base class for frontends. + + :param core_queue: queue for messaging the core + :type core_queue: :class:`multiprocessing.Queue` + :param backend: the backend + :type backend: :class:`mopidy.backends.base.BaseBackend` + """ + + def __init__(self, core_queue, backend): + self.core_queue = core_queue + self.backend = backend + + def start(self): + """Start the frontend.""" + pass + + def destroy(self): + """Destroy the frontend.""" + pass + + def process_message(self, message): + """ + Process messages for the frontend. + + :param message: the message + :type message: dict + """ + raise NotImplementedError From 556e6ba4d9bc32384526501acbbc4c0c2b6f983e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:25:27 +0200 Subject: [PATCH 02/26] Make MpdFrontend a subclass of BaseFrontend --- mopidy/frontends/mpd/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 8e7d65ab..f1bfdd57 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,12 +1,13 @@ import logging +from mopidy.frontends.base import BaseFrontend from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.process import MpdProcess from mopidy.utils.process import unpickle_connection logger = logging.getLogger('mopidy.frontends.mpd') -class MpdFrontend(object): +class MpdFrontend(BaseFrontend): """ The MPD frontend. @@ -16,16 +17,20 @@ class MpdFrontend(object): - :attr:`mopidy.settings.MPD_SERVER_PORT` """ - def __init__(self, core_queue, backend): - self.core_queue = core_queue + def __init__(self, *args, **kwargs): + super(MpdFrontend, self).__init__(*args, **kwargs) self.process = None - self.dispatcher = MpdDispatcher(backend) + self.dispatcher = MpdDispatcher(self.backend) def start(self): """Starts the MPD server.""" self.process = MpdProcess(self.core_queue) self.process.start() + def destroy(self): + """Destroys the MPD server.""" + self.process.destroy() + def process_message(self, message): """ Processes messages with the MPD frontend as destination. From fc423146cf7f63378c22b3e8e4b1fb23f4fafe06 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:29:24 +0200 Subject: [PATCH 03/26] Add LastfmFrontend --- mopidy/frontends/lastfm.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 mopidy/frontends/lastfm.py diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py new file mode 100644 index 00000000..411eef60 --- /dev/null +++ b/mopidy/frontends/lastfm.py @@ -0,0 +1,14 @@ +from mopidy.frontends.base import BaseFrontend + +class LastfmFrontend(BaseFrontend): + def __init__(self, *args, **kwargs): + super(LastfmFrontend, self).__init__(*args, **kwargs) + + def start(self): + pass + + def destroy(self): + pass + + def process_message(self, message): + pass From 0abfb25a998dae187445daab5548701c7b8a2410 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:31:20 +0200 Subject: [PATCH 04/26] Add requirements-lastfm.txt --- requirements-lastfm.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 requirements-lastfm.txt diff --git a/requirements-lastfm.txt b/requirements-lastfm.txt new file mode 100644 index 00000000..642735be --- /dev/null +++ b/requirements-lastfm.txt @@ -0,0 +1 @@ +pylast >= 0.4.30 From 8cb015b54cb379ba18a4c889c1b0dfd6e5a8fe13 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 01:54:20 +0200 Subject: [PATCH 05/26] Add settings for LastfmFrontend --- mopidy/settings.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mopidy/settings.py b/mopidy/settings.py index 699eb16a..6426398f 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -58,6 +58,16 @@ FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) #: GSTREAMER_AUDIO_SINK = u'autoaudiosink' GSTREAMER_AUDIO_SINK = u'autoaudiosink' +#: Your `Last.fm `_ username. +#: +#: Used by :mod:`mopidy.frontends.lastfm`. +LASTFM_USERNAME = u'' + +#: Your `Last.fm `_ password. +#: +#: Used by :mod:`mopidy.frontends.lastfm`. +LASTFM_PASSWORD = u'' + #: Path to folder with local music. #: #: Used by :mod:`mopidy.backends.local`. From 5fb17ccf0311500e5ce14a49e246d1a6cbc427a4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:55:44 +0200 Subject: [PATCH 06/26] Make MpdFrontend ignore unknown messages --- mopidy/frontends/mpd/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index f1bfdd57..6450889e 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -45,4 +45,4 @@ class MpdFrontend(BaseFrontend): connection = unpickle_connection(message['reply_to']) connection.send(response) else: - logger.warning(u'Cannot handle message: %s', message) + pass # Ignore messages for other frontends From e67cf3805a7e7642fb1867960c345c158b287ab6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:56:20 +0200 Subject: [PATCH 07/26] Issue events from backend to frontend on 'now_playing' and 'end_of_track' --- mopidy/backends/base/playback.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index df588f39..67a86a28 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -323,6 +323,13 @@ class BasePlaybackController(object): if self.consume: self.backend.current_playlist.remove(cpid=original_cp_track[0]) + # Notify frontends of the end_of_track event + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'end_of_track', + 'track': original_cp_track[1], + }) + def on_current_playlist_change(self): """ Tell the playback controller that the current playlist has changed. @@ -400,6 +407,13 @@ class BasePlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) + # Notify frontends of the now_playing event + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'now_playing', + 'track': cp_track[1], + }) + def _play(self, track): """ To be overridden by subclass. Implement your backend's play From d4541bb505b5d20be2ac9c88e50d7a5dc6502726 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:56:44 +0200 Subject: [PATCH 08/26] Update CoreProcess to handle multiple frontends --- mopidy/core.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/mopidy/core.py b/mopidy/core.py index d3b2c94f..06149c82 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -18,7 +18,7 @@ class CoreProcess(BaseProcess): self.options = self.parse_options() self.output = None self.backend = None - self.frontend = None + self.frontends = [] def parse_options(self): parser = optparse.OptionParser(version='Mopidy %s' % get_version()) @@ -48,7 +48,7 @@ class CoreProcess(BaseProcess): self.setup_settings() self.output = self.setup_output(self.core_queue) self.backend = self.setup_backend(self.core_queue, self.output) - self.frontend = self.setup_frontend(self.core_queue, self.backend) + self.frontends = self.setup_frontends(self.core_queue, self.backend) def setup_logging(self): setup_logging(self.options.verbosity_level, self.options.dump) @@ -66,16 +66,20 @@ class CoreProcess(BaseProcess): def setup_backend(self, core_queue, output): return get_class(settings.BACKENDS[0])(core_queue, output) - def setup_frontend(self, core_queue, backend): - frontend = get_class(settings.FRONTENDS[0])(core_queue, backend) - frontend.start() - return frontend + def setup_frontends(self, core_queue, backend): + frontends = [] + for frontend_class_name in settings.FRONTENDS: + frontend = get_class(frontend_class_name)(core_queue, backend) + frontend.start() + frontends.append(frontend) + return frontends def process_message(self, message): if message.get('to') == 'output': self.output.process_message(message) elif message.get('to') == 'frontend': - self.frontend.process_message(message) + for frontend in self.frontends: + frontend.process_message(message) elif message['command'] == 'end_of_track': self.backend.playback.on_end_of_track() elif message['command'] == 'stop_playback': From 2ccd3000cdb032c0d272ad6c1515d4b7c0ed2414 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:57:05 +0200 Subject: [PATCH 09/26] Add LastfmFrontend to the FRONTENDS default --- mopidy/settings.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 6426398f..41f6f996 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -45,11 +45,14 @@ DUMP_LOG_FILENAME = u'dump.log' #: #: Default:: #: -#: FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) -#: -#: .. note:: -#: Currently only the first frontend in the list is used. -FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',) +#: FRONTENDS = ( +#: u'mopidy.frontends.mpd.MpdFrontend', +#: u'mopidy.frontends.lastfm.LastfmFrontend', +#: ) +FRONTENDS = ( + u'mopidy.frontends.mpd.MpdFrontend', + u'mopidy.frontends.lastfm.LastfmFrontend', +) #: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`. #: From e0f7fc741a5bd465ecd3174d1f2071637a8ada74 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 02:58:28 +0200 Subject: [PATCH 10/26] Working Last.fm scrobbler --- mopidy/frontends/lastfm.py | 70 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 411eef60..380a37d1 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,14 +1,80 @@ +import logging +import socket +import time + +import pylast + +from mopidy import get_version, settings, SettingsError from mopidy.frontends.base import BaseFrontend +logger = logging.getLogger('mopidy.frontends.lastfm') + +CLIENT_ID = u'mop' +CLIENT_VERSION = get_version() + class LastfmFrontend(BaseFrontend): + """ + Frontend which scrobbles the music you plays to your Last.fm profile. + + **Settings:** + + - :mod:`mopidy.settings.LASTFM_USERNAME` + - :mod:`mopidy.settings.LASTFM_PASSWORD` + """ + def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) + self.lastfm = None + self.scrobbler = None def start(self): - pass + # TODO Split into own thread/process + try: + username = settings.LASTFM_USERNAME + password_hash = pylast.md5(settings.LASTFM_PASSWORD) + self.lastfm = pylast.get_lastfm_network( + username=username, password_hash=password_hash) + self.scrobbler = self.lastfm.get_scrobbler( + CLIENT_ID, CLIENT_VERSION) + logger.info(u'Connected to Last.fm') + except SettingsError as e: + logger.info(u'Last.fm scrobbler did not start.') + logger.debug(u'Last.fm settings error: %s', e) + except (pylast.WSError, socket.error) as e: + logger.error(u'Last.fm connection error: %s', e) def destroy(self): pass def process_message(self, message): - pass + if message['command'] == 'now_playing': + self.report_now_playing(message['track']) + elif message['command'] == 'end_of_track': + self.scrobble(message['track']) + else: + pass # Ignore commands for other frontends + + def report_now_playing(self, track): + artists = ', '.join([a.name for a in track.artists]) + logger.debug(u'Now playing track: %s - %s', artists, track.name) + duration = track.length // 1000 + try: + self.scrobbler.report_now_playing(artists, track.name, + album=track.album.name, duration=duration, + track_number=track.track_no) + except (pylast.ScrobblingError, socket.error) as e: + logger.error(u'Last.fm now playing error: %s', e) + + def scrobble(self, track): + artists = ', '.join([a.name for a in track.artists]) + logger.debug(u'Scrobbling track: %s - %s', artists, track.name) + # FIXME Get actual time when track started playing + duration = track.length // 1000 + time_started = int(time.time()) - duration + try: + self.scrobbler.scrobble(artists, track.name, + time_started=time_started, source=pylast.SCROBBLE_SOURCE_USER, + mode=pylast.SCROBBLE_MODE_PLAYED, duration=duration, + album=track.album.name, track_number=track.track_no) + except (pylast.ScrobblingError, socket.error) as e: + logger.error(u'Last.fm scrobbling error: %s', e) From 67cae2ed4d1b7e068a3e0ba78fb67c50afd0a1c3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 03:03:30 +0200 Subject: [PATCH 11/26] Issue end_of_track event before we start playing the next track. Fixes 'now playing' on Last.fm for the second track in the playlist. --- mopidy/backends/base/playback.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 67a86a28..72b02a25 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -311,6 +311,14 @@ class BasePlaybackController(object): return original_cp_track = self.current_cp_track + + # Notify frontends of the end_of_track event + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'end_of_track', + 'track': original_cp_track[1], + }) + if self.cp_track_at_eot: self.play(self.cp_track_at_eot) @@ -323,13 +331,6 @@ class BasePlaybackController(object): if self.consume: self.backend.current_playlist.remove(cpid=original_cp_track[0]) - # Notify frontends of the end_of_track event - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'end_of_track', - 'track': original_cp_track[1], - }) - def on_current_playlist_change(self): """ Tell the playback controller that the current playlist has changed. From 866f9aac28b3f474c2e8f36eb58eef357a3ce575 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 03:05:26 +0200 Subject: [PATCH 12/26] Update remaining todos --- mopidy/frontends/lastfm.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 380a37d1..bf826141 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -22,13 +22,15 @@ class LastfmFrontend(BaseFrontend): - :mod:`mopidy.settings.LASTFM_PASSWORD` """ + # TODO Split into own thread/process + # TODO Add docs + def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) self.lastfm = None self.scrobbler = None def start(self): - # TODO Split into own thread/process try: username = settings.LASTFM_USERNAME password_hash = pylast.md5(settings.LASTFM_PASSWORD) @@ -68,6 +70,8 @@ class LastfmFrontend(BaseFrontend): def scrobble(self, track): artists = ', '.join([a.name for a in track.artists]) logger.debug(u'Scrobbling track: %s - %s', artists, track.name) + # TODO Scrobble if >50% or >240s of a track has been played + # TODO Do not scrobble if duration <30s # FIXME Get actual time when track started playing duration = track.length // 1000 time_started = int(time.time()) - duration From ec9356dc52329e4c7daefdb877b635cca08dadd2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 10:29:09 +0200 Subject: [PATCH 13/26] Add dependencies to docstring --- mopidy/frontends/lastfm.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index bf826141..3d65cb11 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -16,6 +16,10 @@ class LastfmFrontend(BaseFrontend): """ Frontend which scrobbles the music you plays to your Last.fm profile. + **Dependencies:** + + - `pylast `_ >= 0.4.30 + **Settings:** - :mod:`mopidy.settings.LASTFM_USERNAME` @@ -24,6 +28,7 @@ class LastfmFrontend(BaseFrontend): # TODO Split into own thread/process # TODO Add docs + # TODO Log nice error message if pylast isn't found def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) From 0030e2472b3fe14c0ac317ca93fcdbaa65289c7c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 11:56:02 +0200 Subject: [PATCH 14/26] Encode strings as UTF-8 before passing them to pylast --- mopidy/frontends/lastfm.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 3d65cb11..c0417248 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -12,6 +12,11 @@ logger = logging.getLogger('mopidy.frontends.lastfm') CLIENT_ID = u'mop' CLIENT_VERSION = get_version() +# pylast raises UnicodeEncodeError on conversion from unicode objects to +# ascii-encoded bytestrings, so we explicitly encode as utf-8 before passing +# strings to pylast. +ENCODING = u'utf-8' + class LastfmFrontend(BaseFrontend): """ Frontend which scrobbles the music you plays to your Last.fm profile. @@ -66,8 +71,11 @@ class LastfmFrontend(BaseFrontend): logger.debug(u'Now playing track: %s - %s', artists, track.name) duration = track.length // 1000 try: - self.scrobbler.report_now_playing(artists, track.name, - album=track.album.name, duration=duration, + self.scrobbler.report_now_playing( + artists.encode(ENCODING), + track.name.encode(ENCODING), + album=track.album.name.encode(ENCODING), + duration=duration, track_number=track.track_no) except (pylast.ScrobblingError, socket.error) as e: logger.error(u'Last.fm now playing error: %s', e) @@ -81,9 +89,14 @@ class LastfmFrontend(BaseFrontend): duration = track.length // 1000 time_started = int(time.time()) - duration try: - self.scrobbler.scrobble(artists, track.name, - time_started=time_started, source=pylast.SCROBBLE_SOURCE_USER, - mode=pylast.SCROBBLE_MODE_PLAYED, duration=duration, - album=track.album.name, track_number=track.track_no) + self.scrobbler.scrobble( + artists.encode(ENCODING), + track.name.encode(ENCODING), + time_started=time_started, + source=pylast.SCROBBLE_SOURCE_USER, + mode=pylast.SCROBBLE_MODE_PLAYED, + duration=duration, + album=track.album.name.encode(ENCODING), + track_number=track.track_no) except (pylast.ScrobblingError, socket.error) as e: logger.error(u'Last.fm scrobbling error: %s', e) From 448aa479235878e87832c753495c85487dd5ccdc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 18:28:59 +0200 Subject: [PATCH 15/26] Do Last.fm scrobbling in its own process --- mopidy/frontends/lastfm.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index c0417248..2f539a51 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,4 +1,5 @@ import logging +import multiprocessing import socket import time @@ -6,6 +7,7 @@ import pylast from mopidy import get_version, settings, SettingsError from mopidy.frontends.base import BaseFrontend +from mopidy.utils.process import BaseProcess logger = logging.getLogger('mopidy.frontends.lastfm') @@ -31,16 +33,41 @@ class LastfmFrontend(BaseFrontend): - :mod:`mopidy.settings.LASTFM_PASSWORD` """ - # TODO Split into own thread/process # TODO Add docs # TODO Log nice error message if pylast isn't found def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) + (self.connection, other_end) = multiprocessing.Pipe() + self.process = LastfmFrontendProcess(other_end) + + def start(self): + self.process.start() + + def destroy(self): + self.process.destroy() + + def process_message(self, message): + self.connection.send(message) + + +class LastfmFrontendProcess(BaseProcess): + def __init__(self, connection): + super(LastfmFrontendProcess, self).__init__() + self.name = u'LastfmFrontendProcess' + self.daemon = True + self.connection = connection self.lastfm = None self.scrobbler = None - def start(self): + def run_inside_try(self): + self.setup() + while True: + self.connection.poll(None) + message = self.connection.recv() + self.process_message(message) + + def setup(self): try: username = settings.LASTFM_USERNAME password_hash = pylast.md5(settings.LASTFM_PASSWORD) @@ -55,9 +82,6 @@ class LastfmFrontend(BaseFrontend): except (pylast.WSError, socket.error) as e: logger.error(u'Last.fm connection error: %s', e) - def destroy(self): - pass - def process_message(self, message): if message['command'] == 'now_playing': self.report_now_playing(message['track']) From 1ed711fb85503bd73bbac9c8dde32541ea12c16f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 18:45:32 +0200 Subject: [PATCH 16/26] Do not scrobble if duration is <30s --- mopidy/frontends/lastfm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 2f539a51..18f48cf2 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -105,12 +105,14 @@ class LastfmFrontendProcess(BaseProcess): logger.error(u'Last.fm now playing error: %s', e) def scrobble(self, track): + duration = track.length // 1000 artists = ', '.join([a.name for a in track.artists]) + if duration < 30: + logger.debug(u'Track too short to scrobble.') + return logger.debug(u'Scrobbling track: %s - %s', artists, track.name) # TODO Scrobble if >50% or >240s of a track has been played - # TODO Do not scrobble if duration <30s # FIXME Get actual time when track started playing - duration = track.length // 1000 time_started = int(time.time()) - duration try: self.scrobbler.scrobble( From a8f035e879ae990f8f8be2550cf5f358e1eac2ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:22:19 +0200 Subject: [PATCH 17/26] Trigger playing/stopped events at play, prev, next, stop, eot. --- mopidy/backends/base/playback.py | 51 ++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 72b02a25..3c887120 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -312,16 +312,9 @@ class BasePlaybackController(object): original_cp_track = self.current_cp_track - # Notify frontends of the end_of_track event - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'end_of_track', - 'track': original_cp_track[1], - }) - if self.cp_track_at_eot: + self._trigger_stopped_playing_event() self.play(self.cp_track_at_eot) - if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) else: @@ -354,6 +347,7 @@ class BasePlaybackController(object): return if self.cp_track_at_next: + self._trigger_stopped_playing_event() self.play(self.cp_track_at_next) else: self.stop() @@ -408,12 +402,7 @@ class BasePlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) - # Notify frontends of the now_playing event - self.backend.core_queue.put({ - 'to': 'frontend', - 'command': 'now_playing', - 'track': cp_track[1], - }) + self._trigger_started_playing_event() def _play(self, track): """ @@ -433,6 +422,7 @@ class BasePlaybackController(object): return if self.state == self.STOPPED: return + self._trigger_stopped_playing_event() self.play(self.cp_track_at_previous, on_error_step=-1) def resume(self): @@ -489,7 +479,10 @@ class BasePlaybackController(object): def stop(self): """Stop playing.""" - if self.state != self.STOPPED and self._stop(): + if self.state == self.STOPPED: + return + self._trigger_stopped_playing_event() + if self._stop(): self.state = self.STOPPED def _stop(self): @@ -500,3 +493,31 @@ class BasePlaybackController(object): :rtype: :class:`True` if successful, else :class:`False` """ raise NotImplementedError + + def _trigger_started_playing_event(self): + """ + Notifies frontends that a track has started playing. + + For internal use only. Should be called by the backend directly after a + track has started playing. + """ + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'started_playing', + 'track': self.current_track, + }) + + def _trigger_stopped_playing_event(self): + """ + Notifies frontends that a track has stopped playing. + + For internal use only. Should be called by the backend before a track + is stopped playing, e.g. at the next, previous, and stop actions and at + end-of-track. + """ + self.backend.core_queue.put({ + 'to': 'frontend', + 'command': 'stopped_playing', + 'track': self.current_track, + 'stop_position': self.time_position, + }) From 95de6687211eb34b573e4542d9bf9798c4f0e98c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:23:02 +0200 Subject: [PATCH 18/26] Scrobble at other events than EOT if >50% or >240s. --- mopidy/frontends/lastfm.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 18f48cf2..1ddd0774 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -59,6 +59,7 @@ class LastfmFrontendProcess(BaseProcess): self.connection = connection self.lastfm = None self.scrobbler = None + self.last_start_time = None def run_inside_try(self): self.setup() @@ -83,17 +84,18 @@ class LastfmFrontendProcess(BaseProcess): logger.error(u'Last.fm connection error: %s', e) def process_message(self, message): - if message['command'] == 'now_playing': - self.report_now_playing(message['track']) - elif message['command'] == 'end_of_track': - self.scrobble(message['track']) + if message['command'] == 'started_playing': + self.started_playing(message['track']) + elif message['command'] == 'stopped_playing': + self.stopped_playing(message['track'], message['stop_position']) else: pass # Ignore commands for other frontends - def report_now_playing(self, track): + def started_playing(self, track): artists = ', '.join([a.name for a in track.artists]) - logger.debug(u'Now playing track: %s - %s', artists, track.name) duration = track.length // 1000 + self.last_start_time = int(time.time()) + logger.debug(u'Now playing track: %s - %s', artists, track.name) try: self.scrobbler.report_now_playing( artists.encode(ENCODING), @@ -104,21 +106,25 @@ class LastfmFrontendProcess(BaseProcess): except (pylast.ScrobblingError, socket.error) as e: logger.error(u'Last.fm now playing error: %s', e) - def scrobble(self, track): - duration = track.length // 1000 + def stopped_playing(self, track, stop_position): artists = ', '.join([a.name for a in track.artists]) + duration = track.length // 1000 + stop_position = stop_position // 1000 if duration < 30: - logger.debug(u'Track too short to scrobble.') + logger.debug(u'Track too short to scrobble. (30s)') return + if stop_position < duration // 2 and stop_position < 240: + logger.debug( + u'Track not played long enough to scrobble. (50% or 240s)') + return + if self.last_start_time is None: + self.last_start_time = int(time.time()) - duration logger.debug(u'Scrobbling track: %s - %s', artists, track.name) - # TODO Scrobble if >50% or >240s of a track has been played - # FIXME Get actual time when track started playing - time_started = int(time.time()) - duration try: self.scrobbler.scrobble( artists.encode(ENCODING), track.name.encode(ENCODING), - time_started=time_started, + time_started=self.last_start_time, source=pylast.SCROBBLE_SOURCE_USER, mode=pylast.SCROBBLE_MODE_PLAYED, duration=duration, From 9d62ef4cf368f442e3a8fddc30d26ff75bbf4ca9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:31:02 +0200 Subject: [PATCH 19/26] Generate doc for BaseFrontend --- docs/api/frontends/index.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst index 052c7781..805f5295 100644 --- a/docs/api/frontends/index.rst +++ b/docs/api/frontends/index.rst @@ -8,8 +8,14 @@ A frontend is responsible for exposing Mopidy for a type of clients. Frontend API ============ -A stable frontend API is not available yet, as we've only implemented a single -frontend module. +.. warning:: + + A stable frontend API is not available yet, as we've only implemented a + couple of frontend modules. + +.. automodule:: mopidy.frontends.base + :synopsis: Base class for frontends + :members: Frontends From 67ab8ecbe54f4092433d84a50c2e7de04b2f8e07 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:31:30 +0200 Subject: [PATCH 20/26] Generate docs for lastfm frontend --- docs/api/frontends/index.rst | 1 + docs/api/frontends/lastfm.rst | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 docs/api/frontends/lastfm.rst diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst index 805f5295..05595418 100644 --- a/docs/api/frontends/index.rst +++ b/docs/api/frontends/index.rst @@ -21,4 +21,5 @@ Frontend API Frontends ========= +* :mod:`mopidy.frontends.lastfm` * :mod:`mopidy.frontends.mpd` diff --git a/docs/api/frontends/lastfm.rst b/docs/api/frontends/lastfm.rst new file mode 100644 index 00000000..bd3e218e --- /dev/null +++ b/docs/api/frontends/lastfm.rst @@ -0,0 +1,7 @@ +****************************** +:mod:`mopidy.frontends.lastfm` +****************************** + +.. automodule:: mopidy.frontends.lastfm + :synopsis: Last.fm scrobbler frontend + :members: From 02a1592d337a92256877360d3e91c19a74c617c8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:31:47 +0200 Subject: [PATCH 21/26] Fix references to settings --- mopidy/frontends/lastfm.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 1ddd0774..998d8ec1 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -3,6 +3,7 @@ import multiprocessing import socket import time +# TODO Log nice error message if pylast isn't found import pylast from mopidy import get_version, settings, SettingsError @@ -29,13 +30,10 @@ class LastfmFrontend(BaseFrontend): **Settings:** - - :mod:`mopidy.settings.LASTFM_USERNAME` - - :mod:`mopidy.settings.LASTFM_PASSWORD` + - :attr:`mopidy.settings.LASTFM_USERNAME` + - :attr:`mopidy.settings.LASTFM_PASSWORD` """ - # TODO Add docs - # TODO Log nice error message if pylast isn't found - def __init__(self, *args, **kwargs): super(LastfmFrontend, self).__init__(*args, **kwargs) (self.connection, other_end) = multiprocessing.Pipe() From b87515368dbfd60bbb49420917d524f232fc60cb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:34:10 +0200 Subject: [PATCH 22/26] Update changelog and roadmap --- docs/changes.rst | 2 +- docs/development/roadmap.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 986dd953..1122be14 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,7 +12,7 @@ No description yet. **Changes** -- None +- Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details. 0.1.0 (2010-08-23) diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 835df489..a0946a0e 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -32,7 +32,7 @@ Possible targets for the next version - A script for creating a tag cache. - An alternative to tag cache for caching metadata, i.e. Sqlite. -- Last.fm scrobbling. +- **[DONE]** Last.fm scrobbling. Stuff we want to do, but not right now, and maybe never From ab6e9fccfd824b54d37f55c6116f829c9dd7fa65 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:38:42 +0200 Subject: [PATCH 23/26] Add note to settings doc on setting up Last.fm scrobbling --- docs/settings.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/settings.rst b/docs/settings.rst index a39e6fd8..c657e0bf 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -41,3 +41,13 @@ Connecting from other machines on the network As a secure default, Mopidy only accepts connections from ``localhost``. If you want to open it for connections from other machines on your network, see the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`. + + +Scrobbling tracks to Last.fm +============================ + +If you want to submit the tracks you are playing to your `Last.fm +`_ profile, add the following to your settings file:: + + LASTFM_USERNAME = u'myusername' + LASTFM_PASSWORD = u'mysecret' From 7d545f889e06c61e0dc95a1d8c58b0e12f2f7cfe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:40:32 +0200 Subject: [PATCH 24/26] Update docstring --- docs/settings.rst | 4 +++- mopidy/frontends/lastfm.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index c657e0bf..afdd39dc 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -47,7 +47,9 @@ Scrobbling tracks to Last.fm ============================ If you want to submit the tracks you are playing to your `Last.fm -`_ profile, add the following to your settings file:: +`_ profile, make sure you've installed the dependencies +found at :mod:`mopidy.frontends.lastfm` and add the following to your settings +file:: LASTFM_USERNAME = u'myusername' LASTFM_PASSWORD = u'mysecret' diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 998d8ec1..3cab010a 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -22,7 +22,12 @@ ENCODING = u'utf-8' class LastfmFrontend(BaseFrontend): """ - Frontend which scrobbles the music you plays to your Last.fm profile. + Frontend which scrobbles the music you play to your `Last.fm + `_ profile. + + .. note:: + + This frontend requires a free user account at Last.fm. **Dependencies:** From 89c183e3811f95d5f7aca6f478999459f95db953 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 19:43:04 +0200 Subject: [PATCH 25/26] Add pylast to deps list --- docs/installation/index.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 14ebdebc..9577c383 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -54,6 +54,12 @@ Make sure you got the required dependencies installed. - No additional dependencies. +- Optional dependencies: + + - :mod:`mopidy.frontends.lastfm` + + - pylast >= 4.3.0 + Install latest release ====================== From 16e6a7fdc03f54e16b835809a1466b73a029ab00 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 24 Aug 2010 20:20:47 +0200 Subject: [PATCH 26/26] Fail nicely when optional dependencies are missing --- mopidy/__init__.py | 3 +++ mopidy/core.py | 11 +++++++---- mopidy/frontends/lastfm.py | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 46d55873..7d3052c4 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -22,6 +22,9 @@ class MopidyException(Exception): class SettingsError(MopidyException): pass +class OptionalDependencyError(MopidyException): + pass + from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy settings = SettingsProxy(default_settings_module) diff --git a/mopidy/core.py b/mopidy/core.py index 06149c82..a97d1e88 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -2,7 +2,7 @@ import logging import multiprocessing import optparse -from mopidy import get_version, settings +from mopidy import get_version, settings, OptionalDependencyError from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file @@ -69,9 +69,12 @@ class CoreProcess(BaseProcess): def setup_frontends(self, core_queue, backend): frontends = [] for frontend_class_name in settings.FRONTENDS: - frontend = get_class(frontend_class_name)(core_queue, backend) - frontend.start() - frontends.append(frontend) + try: + frontend = get_class(frontend_class_name)(core_queue, backend) + frontend.start() + frontends.append(frontend) + except OptionalDependencyError as e: + logger.info(u'Disabled: %s (%s)', frontend_class_name, e) return frontends def process_message(self, message): diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 3cab010a..13a8f6b4 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -3,8 +3,11 @@ import multiprocessing import socket import time -# TODO Log nice error message if pylast isn't found -import pylast +try: + import pylast +except ImportError as e: + from mopidy import OptionalDependencyError + raise OptionalDependencyError(e) from mopidy import get_version, settings, SettingsError from mopidy.frontends.base import BaseFrontend