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