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