diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst index 052c7781..05595418 100644 --- a/docs/api/frontends/index.rst +++ b/docs/api/frontends/index.rst @@ -8,11 +8,18 @@ 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 ========= +* :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: 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 419f7645..645cbd30 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -33,7 +33,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 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 ====================== diff --git a/docs/settings.rst b/docs/settings.rst index a39e6fd8..afdd39dc 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -41,3 +41,15 @@ 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, 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/__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/backends/base/playback.py b/mopidy/backends/base/playback.py index df588f39..3c887120 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -311,9 +311,10 @@ class BasePlaybackController(object): return original_cp_track = self.current_cp_track - if self.cp_track_at_eot: - self.play(self.cp_track_at_eot) + 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: @@ -346,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() @@ -400,6 +402,8 @@ class BasePlaybackController(object): if self.random and self.current_cp_track in self._shuffled: self._shuffled.remove(self.current_cp_track) + self._trigger_started_playing_event() + def _play(self, track): """ To be overridden by subclass. Implement your backend's play @@ -418,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): @@ -474,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): @@ -485,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, + }) diff --git a/mopidy/core.py b/mopidy/core.py index d3b2c94f..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 @@ -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,23 @@ 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: + 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): 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': 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 diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py new file mode 100644 index 00000000..13a8f6b4 --- /dev/null +++ b/mopidy/frontends/lastfm.py @@ -0,0 +1,140 @@ +import logging +import multiprocessing +import socket +import time + +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 +from mopidy.utils.process import BaseProcess + +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 play to your `Last.fm + `_ profile. + + .. note:: + + This frontend requires a free user account at Last.fm. + + **Dependencies:** + + - `pylast `_ >= 0.4.30 + + **Settings:** + + - :attr:`mopidy.settings.LASTFM_USERNAME` + - :attr:`mopidy.settings.LASTFM_PASSWORD` + """ + + 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 + self.last_start_time = None + + 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) + 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 process_message(self, message): + 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 started_playing(self, track): + artists = ', '.join([a.name for a in track.artists]) + 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), + 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) + + 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. (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) + try: + self.scrobbler.scrobble( + artists.encode(ENCODING), + track.name.encode(ENCODING), + time_started=self.last_start_time, + 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) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 8e7d65ab..6450889e 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. @@ -40,4 +45,4 @@ class MpdFrontend(object): connection = unpickle_connection(message['reply_to']) connection.send(response) else: - logger.warning(u'Cannot handle message: %s', message) + pass # Ignore messages for other frontends diff --git a/mopidy/settings.py b/mopidy/settings.py index 699eb16a..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`. #: @@ -58,6 +61,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`. 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