Skip to content

Commit

Permalink
+ Allow scrobbling to Libre.fm, closes mopidy#5.
Browse files Browse the repository at this point in the history
  • Loading branch information
0nse committed Feb 10, 2016
1 parent feff366 commit 3846559
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 50 deletions.
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.3.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 =
178 changes: 133 additions & 45 deletions mopidy_scrobbler/frontend.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import unicode_literals

import logging
import time
import os

import pykka
import pylast
Expand All @@ -11,37 +14,119 @@

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 getDuration(self, track):
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
import multiprocessing
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:
f = open(LIBREFM_SESSION_KEY_FILE, 'w')
f.write(session_key)
f.close()
logger.debug('Libre.fm session key retrieved and written to disk.')
break
except pylast.WSError:
remainingTime -= 1
time.sleep(1)
except PermissionError:
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 getArtists(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. '''
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
Expand All @@ -58,29 +143,31 @@ def getArtists(self, track):

def track_playback_started(self, tl_track):
track = tl_track.track
(artist, artists) = self.getArtists(track)
duration = self.getDuration(track)
(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(
artist,
(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. '''
''' 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
(artist, artists) = self.getArtists(track)
duration = self.getDuration(track)
(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 @@ -92,15 +179,16 @@ 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(
artist,
(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))

1 comment on commit 3846559

@0nse
Copy link
Owner Author

@0nse 0nse commented on 3846559 Feb 10, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @hugovk for his help given in pylast/pylast#168.

Please sign in to comment.