Merge branch 'feature/lastfm-scrobbling' into develop

This commit is contained in:
Stein Magnus Jodal 2010-08-24 20:21:25 +02:00
commit facf56ea54
14 changed files with 292 additions and 25 deletions

View File

@ -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`

View File

@ -0,0 +1,7 @@
******************************
:mod:`mopidy.frontends.lastfm`
******************************
.. automodule:: mopidy.frontends.lastfm
:synopsis: Last.fm scrobbler frontend
:members:

View File

@ -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)

View File

@ -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

View File

@ -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
======================

View File

@ -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
<http://www.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'

View File

@ -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)

View File

@ -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,
})

View File

@ -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':

30
mopidy/frontends/base.py Normal file
View File

@ -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

140
mopidy/frontends/lastfm.py Normal file
View File

@ -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
<http://www.last.fm>`_ profile.
.. note::
This frontend requires a free user account at Last.fm.
**Dependencies:**
- `pylast <http://code.google.com/p/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)

View File

@ -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

View File

@ -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 <http://www.last.fm/>`_ username.
#:
#: Used by :mod:`mopidy.frontends.lastfm`.
LASTFM_USERNAME = u''
#: Your `Last.fm <http://www.last.fm/>`_ password.
#:
#: Used by :mod:`mopidy.frontends.lastfm`.
LASTFM_PASSWORD = u''
#: Path to folder with local music.
#:
#: Used by :mod:`mopidy.backends.local`.

1
requirements-lastfm.txt Normal file
View File

@ -0,0 +1 @@
pylast >= 0.4.30