diff --git a/README.rst b/README.rst
index e978c52..8abe0b8 100644
--- a/README.rst
+++ b/README.rst
@@ -19,9 +19,9 @@ Mopidy-Scrobbler
:alt: Test coverage
`Mopidy `_ extension for scrobbling played tracks to
-`Last.fm `_.
+`Last.fm `_ and `Libre.fm `_.
-This extension requires a free user account at Last.fm.
+This extension requires a free user account at Last.fm and/or Libre.fm.
Installation
@@ -39,18 +39,22 @@ Configuration
=============
The extension is enabled by default when it is installed. You just need to add
-your Last.fm username and password to your Mopidy configuration file, typically
-found at ``~/.config/mopidy/mopidy.conf``::
+your Last.fm and/or Libre.fm username and password to your Mopidy configuration
+file, typically found at ``~/.config/mopidy/mopidy.conf``::
[scrobbler]
- username = alice
- password = secret
+ lastfm_username = alice
+ lastfm_password = secret
+ librefm_username = bob
+ librefm_password = anotherSecret
The following configuration values are available:
- ``scrobbler/enabled``: If the scrobbler extension should be enabled or not.
-- ``scrobbler/username``: Your Last.fm username.
-- ``scrobbler/password``: Your Last.fm password.
+- ``scrobbler/lastfm_username``: Your Last.fm username.
+- ``scrobbler/lastfm_password``: Your Last.fm password.
+- ``scrobbler/librefm_username``: Your Libre.fm username.
+- ``scrobbler/librefm_password``: Your Libre.fm password.
Project resources
@@ -63,6 +67,24 @@ Project resources
Changelog
=========
+v1.2.0 (2016-02-10)
+-------------------
+
+- Allow scrobbling to Libre.fm. A session key must be created beforehand.
+ This authentication launches the default Web browser to do so. If Mopidy is
+ run by a user without a default Web browser or without access to the current
+ display session, the URL given in the log output must be opened manually.
+
+- This version introduces configuration changes. ``username`` and ``password``
+ are now ``lastfm_username``, ``lastfm_password`` and ``librefm_username``,
+ ``librefm_password`` for Last.fm and Libre.fm respectively.
+
+v1.1.2 (2016-01-06)
+-------------------
+
+- Scrobble only the first given artist instead of a concatenated string of
+ all existing artists to prevent Last.fm from creating bogus artist pages.
+
v1.1.1 (2014-12-29)
-------------------
diff --git a/mopidy_scrobbler/__init__.py b/mopidy_scrobbler/__init__.py
index 871083f..4d70099 100644
--- a/mopidy_scrobbler/__init__.py
+++ b/mopidy_scrobbler/__init__.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python
+# encoding: utf-8
from __future__ import unicode_literals
import os
@@ -5,7 +7,7 @@
from mopidy import config, ext
-__version__ = '1.1.1'
+__version__ = '1.2.0'
class Extension(ext.Extension):
@@ -20,8 +22,10 @@ def get_default_config(self):
def get_config_schema(self):
schema = super(Extension, self).get_config_schema()
- schema['username'] = config.String()
- schema['password'] = config.Secret()
+ schema['lastfm_username'] = config.String()
+ schema['lastfm_password'] = config.Secret()
+ schema['librefm_username'] = config.String()
+ schema['librefm_password'] = config.Secret()
return schema
def setup(self, registry):
diff --git a/mopidy_scrobbler/ext.conf b/mopidy_scrobbler/ext.conf
index 4fded92..6ad5449 100644
--- a/mopidy_scrobbler/ext.conf
+++ b/mopidy_scrobbler/ext.conf
@@ -1,4 +1,6 @@
[scrobbler]
enabled = true
-username =
-password =
+lastfm_username =
+lastfm_password =
+librefm_username =
+librefm_password =
diff --git a/mopidy_scrobbler/frontend.py b/mopidy_scrobbler/frontend.py
index 9ead742..44e0dfb 100644
--- a/mopidy_scrobbler/frontend.py
+++ b/mopidy_scrobbler/frontend.py
@@ -1,18 +1,23 @@
+#!/usr/bin/env python
+# encoding: utf-8
from __future__ import unicode_literals
import logging
+import os
import time
-import pykka
-import pylast
-
from mopidy.core import CoreListener
+import pykka
+
+import pylast
logger = logging.getLogger(__name__)
-API_KEY = '2236babefa8ebb3d93ea467560d00d04'
-API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd'
+LASTFM_API_KEY = '2236babefa8ebb3d93ea467560d00d04'
+LASTFM_API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd'
+LIBREFM_SESSION_KEY_FILE = os.path.join(os.path.expanduser('~'),
+ '.librefm_session_key')
class ScrobblerFrontend(pykka.ThreadingActor, CoreListener):
@@ -20,42 +25,159 @@ def __init__(self, config, core):
super(ScrobblerFrontend, self).__init__()
self.config = config
self.lastfm = None
+ self.librefm = None
+ self.networks = {}
self.last_start_time = None
def on_start(self):
+ if not (self.connect_to_lastfm() and self.connect_to_librefm()):
+ logger.warning("Couldn't connect to any scrobbling services. "
+ "Mopidy Scrobbler will stop.")
+ self.stop()
+
+ def connect_to_lastfm(self):
+ ''' Connect to Last.fm and return True on success. '''
+ lastfm_username = self.config['scrobbler']['lastfm_username']
+ lastfm_password = self.config['scrobbler']['lastfm_password']
+
try:
- self.lastfm = pylast.LastFMNetwork(
- api_key=API_KEY, api_secret=API_SECRET,
- username=self.config['scrobbler']['username'],
- password_hash=pylast.md5(self.config['scrobbler']['password']))
- logger.info('Scrobbler connected to Last.fm')
+ if lastfm_username and lastfm_password:
+ self.lastfm = pylast.LastFMNetwork(
+ api_key=LASTFM_API_KEY,
+ api_secret=LASTFM_API_SECRET,
+ username=lastfm_username,
+ password_hash=pylast.md5(lastfm_password))
+ logger.info('Scrobbler connected to Last.fm')
+ self.networks['Last.fm'] = self.lastfm
+ return True
except (pylast.NetworkError, pylast.MalformedResponseError,
pylast.WSError) as e:
- logger.error('Error during Last.fm setup: %s', e)
- self.stop()
+ logger.error('Error while connecting to Last.fm: %s', e)
+
+ return False
+
+ def connect_to_librefm(self):
+ ''' Connect to Libre.fm and return True on success. '''
+ librefm_username = self.config['scrobbler']['librefm_username']
+ librefm_password = self.config['scrobbler']['librefm_password']
+
+ try:
+ if librefm_username and librefm_password:
+ self.librefm = pylast.LibreFMNetwork(
+ username=librefm_username,
+ password_hash=pylast.md5(librefm_password))
+
+ if self.retrieve_librefm_session():
+ self.networks['Libre.fm'] = self.librefm
+ logger.info('Scrobbler connected to Libre.fm')
+ return True
+ else:
+ return False
+ except (pylast.NetworkError, pylast.MalformedResponseError,
+ pylast.WSError) as e:
+ logger.error('Error while connecting to Libre.fm: %s', e)
+
+ return False
+
+ def retrieve_librefm_session(self):
+ ''' Opens a Web browser to create a session key file if none
+ exists yet. Else, it is loaded from disk. Returns True on
+ success. '''
+ if not os.path.exists(LIBREFM_SESSION_KEY_FILE):
+ import webbrowser
+ logger.warning('The Libre.fm session key does not exist. A Web '
+ 'browser will open an authentication URL. Confirm '
+ 'access using your username and password. This '
+ 'has to be done only once.')
+
+ session_keygen = pylast.SessionKeyGenerator(self.librefm)
+ auth_url = session_keygen.get_web_auth_url()
+ webbrowser.open(auth_url)
+ logger.info('A Web browser may not be opened if you run Mopidy '
+ 'as a different user. In this case, you will have '
+ 'to manually open the link "{url}".'
+ .format(url=auth_url))
+
+ remainingTime = 30 # approximately 30 seconds before timeout
+ while remainingTime:
+ try:
+ session_key = session_keygen \
+ .get_web_auth_session_key(auth_url)
+ # if the file was created in the meantime, it will
+ # be blindly overwritten:
+ with open(LIBREFM_SESSION_KEY_FILE, 'w') as f:
+ f.write(session_key)
+ logger.debug('Libre.fm session key retrieved and written '
+ 'to disk.')
+ break
+ except pylast.WSError:
+ remainingTime -= 1
+ time.sleep(1)
+ except IOError:
+ logger.error('Cannot write to session key file "{path}"'
+ .format(path=LIBREFM_SESSION_KEY_FILE))
+ return False
+ if not remainingTime:
+ logger.error('Authenticating to Libre.fm timed out. Did you '
+ 'allow access in your Web browser?')
+ return False
+ else:
+ session_key = open(LIBREFM_SESSION_KEY_FILE).read()
+
+ self.librefm.session_key = session_key
+ return True
+
+ def get_duration(self, track):
+ return track.length and track.length // 1000 or 0
+
+ def get_artists(self, track):
+ ''' Return a tuple consisting of the first artist and a merged
+ string of artists. The first artist is considered to be the
+ primary artist of the track. The artists are joined by using
+ slashes as recommended in ID3v2.3. Prefer the album artist if
+ any is given. '''
+ if not len(track.artists):
+ logger.error('The track does not have any artists.')
+ raise ValueError
+ artists = [a.name for a in track.artists]
+ if track.album and track.album.artists:
+ artists = [a.name for a in track.album.artists]
+
+ metaArtists = ['compilation', 'split', 'various artists']
+ if artists[0].lower() in metaArtists:
+ artists = [a.name for a in track.artists]
+ primaryArtist = artists[0]
+ artists = '/'.join(artists)
+ return (primaryArtist, artists)
def track_playback_started(self, tl_track):
track = tl_track.track
- artists = ', '.join(sorted([a.name for a in track.artists]))
- duration = track.length and track.length // 1000 or 0
+ (artist, artists) = self.get_artists(track)
+ duration = self.get_duration(track)
self.last_start_time = int(time.time())
logger.debug('Now playing track: %s - %s', artists, track.name)
- try:
- self.lastfm.update_now_playing(
- artists,
- (track.name or ''),
- album=(track.album and track.album.name or ''),
- duration=str(duration),
- track_number=str(track.track_no or 0),
- mbid=(track.musicbrainz_id or ''))
- except (pylast.ScrobblingError, pylast.NetworkError,
- pylast.MalformedResponseError, pylast.WSError) as e:
- logger.warning('Error submitting playing track to Last.fm: %s', e)
+
+ for network in self.networks.items():
+ try:
+ network[1].update_now_playing(
+ artist=artist,
+ title=(track.name or ''),
+ album=(track.album and track.album.name or ''),
+ duration=str(duration),
+ track_number=str(track.track_no or 0),
+ mbid=(track.musicbrainz_id or ''))
+ except (pylast.ScrobblingError, pylast.NetworkError,
+ pylast.MalformedResponseError, pylast.WSError) as e:
+ logger.warning('Error submitting playing track to {network}: '
+ '{error}'.format(network=network[0], error=e))
def track_playback_ended(self, tl_track, time_position):
+ ''' Scrobble the current track but only submit the primary
+ artist instead of a combined string which could wrongfully
+ create new Last.FM artist pages. '''
track = tl_track.track
- artists = ', '.join(sorted([a.name for a in track.artists]))
- duration = track.length and track.length // 1000 or 0
+ (artist, artists) = self.get_artists(track)
+ duration = self.get_duration(track)
time_position = time_position // 1000
if duration < 30:
logger.debug('Track too short to scrobble. (30s)')
@@ -67,15 +189,17 @@ def track_playback_ended(self, tl_track, time_position):
if self.last_start_time is None:
self.last_start_time = int(time.time()) - duration
logger.debug('Scrobbling track: %s - %s', artists, track.name)
- try:
- self.lastfm.scrobble(
- artists,
- (track.name or ''),
- str(self.last_start_time),
- album=(track.album and track.album.name or ''),
- track_number=str(track.track_no or 0),
- duration=str(duration),
- mbid=(track.musicbrainz_id or ''))
- except (pylast.ScrobblingError, pylast.NetworkError,
- pylast.MalformedResponseError, pylast.WSError) as e:
- logger.warning('Error submitting played track to Last.fm: %s', e)
+ for network in self.networks.items():
+ try:
+ network[1].scrobble(
+ artist=artist,
+ title=(track.name or ''),
+ timestamp=str(self.last_start_time),
+ album=(track.album and track.album.name or ''),
+ track_number=str(track.track_no or 0),
+ duration=str(duration),
+ mbid=(track.musicbrainz_id or ''))
+ except (pylast.ScrobblingError, pylast.NetworkError,
+ pylast.MalformedResponseError, pylast.WSError) as e:
+ logger.warning('Error submitting played track to {network}: '
+ '{error}'.format(network=network[0], error=e))
diff --git a/setup.cfg b/setup.cfg
index 5e40900..093fc89 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,6 @@
+[flake8]
+application-import-names = mopidy_scrobbler,tests
+exclude = .git,.tox
+
[wheel]
universal = 1
diff --git a/setup.py b/setup.py
index fda7ac2..d3f2c56 100644
--- a/setup.py
+++ b/setup.py
@@ -1,7 +1,8 @@
from __future__ import unicode_literals
import re
-from setuptools import setup, find_packages
+
+from setuptools import find_packages, setup
def get_version(filename):
@@ -28,11 +29,6 @@ def get_version(filename):
'Pykka >= 1.1',
'pylast >= 0.5.7',
],
- test_suite='nose.collector',
- tests_require=[
- 'nose',
- 'mock >= 1.0',
- ],
entry_points={
'mopidy.ext': [
'scrobbler = mopidy_scrobbler:Extension',
diff --git a/tests/test_extension.py b/tests/test_extension.py
index 34eb142..a48304f 100644
--- a/tests/test_extension.py
+++ b/tests/test_extension.py
@@ -1,6 +1,7 @@
-import mock
import unittest
+import mock
+
from mopidy_scrobbler import Extension, frontend as frontend_lib
@@ -13,16 +14,20 @@ def test_get_default_config(self):
self.assertIn('[scrobbler]', config)
self.assertIn('enabled = true', config)
- self.assertIn('username =', config)
- self.assertIn('password =', config)
+ self.assertIn('lastfm_username =', config)
+ self.assertIn('lastfm_password =', config)
+ self.assertIn('librefm_username =', config)
+ self.assertIn('librefm_password =', config)
def test_get_config_schema(self):
ext = Extension()
schema = ext.get_config_schema()
- self.assertIn('username', schema)
- self.assertIn('password', schema)
+ self.assertIn('lastfm_username', schema)
+ self.assertIn('lastfm_password', schema)
+ self.assertIn('librefm_username', schema)
+ self.assertIn('librefm_password', schema)
def test_setup(self):
ext = Extension()
@@ -32,3 +37,6 @@ def test_setup(self):
registry.add.assert_called_once_with(
'frontend', frontend_lib.ScrobblerFrontend)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index 3bf5494..feb7ad9 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -1,11 +1,17 @@
-import mock
+import logging
+
import unittest
-import pylast
+import mock
from mopidy import models
+
+import pylast
+
from mopidy_scrobbler import frontend as frontend_lib
+logging.basicConfig()
+
@mock.patch('mopidy_scrobbler.frontend.pylast', spec=pylast)
class FrontendTest(unittest.TestCase):
@@ -13,12 +19,25 @@ class FrontendTest(unittest.TestCase):
def setUp(self):
self.config = {
'scrobbler': {
- 'username': 'alice',
- 'password': 'secret',
+ 'lastfm_username': 'alice',
+ 'lastfm_password': 'secret',
+ 'librefm_username': '',
+ 'librefm_password': '',
}
}
self.frontend = frontend_lib.ScrobblerFrontend(
self.config, mock.sentinel.core)
+ self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+ self.frontend.networks['Last.fm'] = self.frontend.lastfm
+
+ self.artists = [models.Artist(name='ABC'), models.Artist(name='XYZ')]
+ self.track = models.Track(
+ name='One Two Three',
+ artists=self.artists,
+ album=models.Album(name='The Collection'),
+ track_no=3,
+ length=180432,
+ musicbrainz_id='123-456')
def test_on_start_creates_lastfm_network(self, pylast_mock):
pylast_mock.md5.return_value = mock.sentinel.password_hash
@@ -26,8 +45,8 @@ def test_on_start_creates_lastfm_network(self, pylast_mock):
self.frontend.on_start()
pylast_mock.LastFMNetwork.assert_called_with(
- api_key=frontend_lib.API_KEY,
- api_secret=frontend_lib.API_SECRET,
+ api_key=frontend_lib.LASTFM_API_KEY,
+ api_secret=frontend_lib.LASTFM_API_SECRET,
username='alice',
password_hash=mock.sentinel.password_hash)
@@ -42,73 +61,47 @@ def test_on_start_stops_actor_on_error(self, pylast_mock):
self.frontend.stop.assert_called_with()
def test_track_playback_started_updates_now_playing(self, pylast_mock):
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
- artists = [models.Artist(name='ABC'), models.Artist(name='XYZ')]
- album = models.Album(name='The Collection')
- track = models.Track(
- name='One Two Three',
- artists=artists,
- album=album,
- track_no=3,
- length=180432,
- musicbrainz_id='123-456')
- tl_track = models.TlTrack(track=track, tlid=17)
+ tl_track = models.TlTrack(track=self.track, tlid=17)
self.frontend.track_playback_started(tl_track)
+ # get_artists() returns the primary artist and thus, we expect 'ABC'
+ # instead of 'ABC, XYZ'
self.frontend.lastfm.update_now_playing.assert_called_with(
- 'ABC, XYZ',
- 'One Two Three',
+ artist='ABC',
+ title='One Two Three',
duration='180',
album='The Collection',
track_number='3',
mbid='123-456')
- def test_track_playback_started_has_default_values(self, pylast_mock):
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
+ def test_track_playback_started_fails_on_missing_artists(self,
+ pylast_mock):
track = models.Track()
tl_track = models.TlTrack(track=track, tlid=17)
- self.frontend.track_playback_started(tl_track)
-
- self.frontend.lastfm.update_now_playing.assert_called_with(
- '',
- '',
- duration='0',
- album='',
- track_number='0',
- mbid='')
+ self.assertRaises(ValueError,
+ self.frontend.track_playback_started,
+ tl_track)
def test_track_playback_started_catches_pylast_error(self, pylast_mock):
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
pylast_mock.ScrobblingError = pylast.ScrobblingError
self.frontend.lastfm.update_now_playing.side_effect = (
pylast.ScrobblingError('foo'))
- track = models.Track()
- tl_track = models.TlTrack(track=track, tlid=17)
+ tl_track = models.TlTrack(track=self.track, tlid=17)
self.frontend.track_playback_started(tl_track)
def test_track_playback_ended_scrobbles_played_track(self, pylast_mock):
self.frontend.last_start_time = 123
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
- artists = [models.Artist(name='ABC'), models.Artist(name='XYZ')]
- album = models.Album(name='The Collection')
- track = models.Track(
- name='One Two Three',
- artists=artists,
- album=album,
- track_no=3,
- length=180432,
- musicbrainz_id='123-456')
- tl_track = models.TlTrack(track=track, tlid=17)
+ tl_track = models.TlTrack(track=self.track, tlid=17)
self.frontend.track_playback_ended(tl_track, 150000)
self.frontend.lastfm.scrobble.assert_called_with(
- 'ABC, XYZ',
- 'One Two Three',
- '123',
+ artist='ABC',
+ title='One Two Three',
+ timestamp='123',
duration='180',
album='The Collection',
track_number='3',
@@ -116,24 +109,22 @@ def test_track_playback_ended_scrobbles_played_track(self, pylast_mock):
def test_track_playback_ended_has_default_values(self, pylast_mock):
self.frontend.last_start_time = 123
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
- track = models.Track(length=180432)
+ track = models.Track(length=180432, artists=self.artists)
tl_track = models.TlTrack(track=track, tlid=17)
self.frontend.track_playback_ended(tl_track, 150000)
self.frontend.lastfm.scrobble.assert_called_with(
- '',
- '',
- '123',
+ artist='ABC',
+ title='',
+ timestamp='123',
duration='180',
album='',
track_number='0',
mbid='')
def test_does_not_scrobble_tracks_shorter_than_30_sec(self, pylast_mock):
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
- track = models.Track(length=20432)
+ track = models.Track(length=20432, artists=self.artists)
tl_track = models.TlTrack(track=track, tlid=17)
self.frontend.track_playback_ended(tl_track, 20432)
@@ -141,8 +132,7 @@ def test_does_not_scrobble_tracks_shorter_than_30_sec(self, pylast_mock):
self.assertEqual(self.frontend.lastfm.scrobble.call_count, 0)
def test_does_not_scrobble_if_played_less_than_half(self, pylast_mock):
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
- track = models.Track(length=180432)
+ track = models.Track(length=180432, artists=self.artists)
tl_track = models.TlTrack(track=track, tlid=17)
self.frontend.track_playback_ended(tl_track, 60432)
@@ -150,8 +140,7 @@ def test_does_not_scrobble_if_played_less_than_half(self, pylast_mock):
self.assertEqual(self.frontend.lastfm.scrobble.call_count, 0)
def test_does_scrobble_if_played_not_half_but_240_sec(self, pylast_mock):
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
- track = models.Track(length=880432)
+ track = models.Track(length=880432, artists=self.artists)
tl_track = models.TlTrack(track=track, tlid=17)
self.frontend.track_playback_ended(tl_track, 241432)
@@ -159,11 +148,13 @@ def test_does_scrobble_if_played_not_half_but_240_sec(self, pylast_mock):
self.assertEqual(self.frontend.lastfm.scrobble.call_count, 1)
def test_track_playback_ended_catches_pylast_error(self, pylast_mock):
- self.frontend.lastfm = mock.Mock(spec=pylast.LastFMNetwork)
pylast_mock.ScrobblingError = pylast.ScrobblingError
self.frontend.lastfm.scrobble.side_effect = (
pylast.ScrobblingError('foo'))
- track = models.Track(length=180432)
+ track = models.Track(length=180432, artists=self.artists)
tl_track = models.TlTrack(track=track, tlid=17)
self.frontend.track_playback_ended(tl_track, 150000)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tox.ini b/tox.ini
index ab631dd..0be4f00 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,13 +4,19 @@ envlist = py27, flake8
[testenv]
sitepackages = true
deps =
- coverage
mock
- nose
- mopidy==dev
+ pytest
+ pytest-cov
+ pytest-xdist
install_command = pip install --allow-unverified=mopidy --pre {opts} {packages}
-commands = nosetests -v --with-xunit --xunit-file=xunit-{envname}.xml --with-coverage --cover-package=mopidy_scrobbler
+commands =
+ py.test \
+ --basetemp={envtmpdir} \
+ --cov=mopidy_scrobbler --cov-report=term-missing \
+ {posargs}
[testenv:flake8]
deps = flake8
-commands = flake8 mopidy_scrobbler/ setup.py tests/
+ flake8-import-order
+skip_install = true
+commands = flake8