Merge branch 'feature/lastfm-scrobbling' into develop
This commit is contained in:
commit
facf56ea54
@ -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`
|
||||
|
||||
7
docs/api/frontends/lastfm.rst
Normal file
7
docs/api/frontends/lastfm.rst
Normal file
@ -0,0 +1,7 @@
|
||||
******************************
|
||||
:mod:`mopidy.frontends.lastfm`
|
||||
******************************
|
||||
|
||||
.. automodule:: mopidy.frontends.lastfm
|
||||
:synopsis: Last.fm scrobbler frontend
|
||||
:members:
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
======================
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
30
mopidy/frontends/base.py
Normal 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
140
mopidy/frontends/lastfm.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
1
requirements-lastfm.txt
Normal file
@ -0,0 +1 @@
|
||||
pylast >= 0.4.30
|
||||
Loading…
Reference in New Issue
Block a user