Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scrobble to Libre.fm & keep Last.fm stats clean by scrobbling only the first returned (album) artist. #18

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ Mopidy-Scrobbler
:alt: Test coverage

`Mopidy <http://www.mopidy.com/>`_ extension for scrobbling played tracks to
`Last.fm <http://www.last.fm/>`_.
`Last.fm <http://www.last.fm/>`_ and `Libre.fm <https://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
Expand All @@ -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
Expand All @@ -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)
-------------------

Expand Down
10 changes: 7 additions & 3 deletions mopidy_scrobbler/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import unicode_literals

import os

from mopidy import config, ext


__version__ = '1.1.1'
__version__ = '1.2.0'


class Extension(ext.Extension):
Expand All @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions mopidy_scrobbler/ext.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
[scrobbler]
enabled = true
username =
password =
lastfm_username =
lastfm_password =
librefm_username =
librefm_password =
202 changes: 163 additions & 39 deletions mopidy_scrobbler/frontend.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,183 @@
#!/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):
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)')
Expand All @@ -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))
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
[flake8]
application-import-names = mopidy_scrobbler,tests
exclude = .git,.tox

[wheel]
universal = 1
8 changes: 2 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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',
Expand Down
Loading