diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index e77617c..0000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[report] -omit = - */pyshared/* - */python?.?/* - */site-packages/nose/* diff --git a/.gitignore b/.gitignore index d2d6f36..4b9cd78 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,10 @@ lib64 pip-log.txt # Unit test / coverage reports +.cache .coverage .tox -nosetests.xml +xunit-*.xml # Translations *.mo @@ -33,3 +34,6 @@ nosetests.xml .mr.developer.cfg .project .pydevproject + +# PyCharm/IDEA +.idea diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..ec2e4cb --- /dev/null +++ b/.mailmap @@ -0,0 +1,4 @@ +Ronald Hecht +Ronald Hecht +Hans Elias J. +Shae Erisson diff --git a/.travis.yml b/.travis.yml index 415b10c..bb8c240 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,26 +1,34 @@ +sudo: false + language: python -install: - - "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -" - - "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list" - - "sudo apt-get update || true" - - "sudo apt-get install mopidy" - - "pip install --allow-unverified=mopidy coveralls flake8 mopidy==dev gmusicapi" +python: + - "2.7_with_system_site_packages" + +addons: + apt: + sources: + - mopidy-stable + packages: + - mopidy -before_script: - - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" +env: + - TOX_ENV=py27 + - TOX_ENV=flake8 + +install: + - "pip install tox" script: - - "flake8 $(find . -iname '*.py')" - - "nosetests --with-coverage --cover-package=mopidy_gmusic" + - "tox -e $TOX_ENV" after_success: - - "coveralls" + - "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi" notifications: - email: - recipients: - - ronald.hecht@gmx.de + irc: + channels: + - "irc.freenode.org#mopidy" on_success: change on_failure: change use_notice: true diff --git a/MANIFEST.in b/MANIFEST.in index e2bf029..639aa2a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,9 @@ -include .coveragerc +include .mailmap include .travis.yml include LICENSE include MANIFEST.in include README.rst include mopidy_gmusic/ext.conf +include tox.ini recursive-include tests *.py diff --git a/README.rst b/README.rst index d619edf..a3ecc48 100644 --- a/README.rst +++ b/README.rst @@ -2,38 +2,55 @@ Mopidy-GMusic ************* -.. image:: https://pypip.in/v/Mopidy-GMusic/badge.png +.. image:: https://img.shields.io/pypi/v/Mopidy-GMusic.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-GMusic/ :alt: Latest PyPI version -.. image:: https://pypip.in/d/Mopidy-GMusic/badge.png +.. image:: https://img.shields.io/pypi/dm/Mopidy-GMusic.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-GMusic/ :alt: Number of PyPI downloads -.. image:: https://travis-ci.org/hechtus/mopidy-gmusic.png?branch=master - :target: https://travis-ci.org/hechtus/mopidy-gmusic +.. image:: https://img.shields.io/travis/mopidy/mopidy-gmusic/develop.svg?style=flat + :target: https://travis-ci.org/mopidy/mopidy-gmusic :alt: Travis CI build status -.. image:: https://coveralls.io/repos/hechtus/mopidy-gmusic/badge.png?branch=master - :target: https://coveralls.io/r/hechtus/mopidy-gmusic?branch=master +.. image:: https://img.shields.io/coveralls/mopidy/mopidy-gmusic/develop.svg?style=flat + :target: https://coveralls.io/r/mopidy/mopidy-gmusic :alt: Test coverage `Mopidy `_ extension for playing music from `Google Play Music `_. +Maintainer wanted +================= + +Mopidy-GMusic is currently kept on life support by the Mopidy core developers. +It is in need of a more dedicated maintainer. + +If you want to be the maintainer of Mopidy-GMusic, please: + +1. Make 2-3 good pull requests improving any part of the project. + +2. Read and get familiar with all of the project's open issues. + +3. Send a pull request removing this section and adding yourself as the + "Current maintainer" in the "Credits" section below. In the pull request + description, please refer to the previous pull requests and state that + you've familiarized yourself with the open issues. + +As a maintainer, you'll be given push access to the repo and the authority to +make releases to PyPI when you see fit. + + Dependencies ============ -- You must have a Google account and some music and playlists in your - library. +You must have a Google account, and either: -- You must have an Android device registered for Google Play Music. +- have some music uploaded to your Google Play Music library, or -- The `Unofficial Google Music API - `_ is - needed to access Google Play Music. It will automatically be installed - together with Mopidy-GMusic. +- have a subscription for Google Play Music All Access. Installation @@ -47,22 +64,61 @@ Install the Mopidy-GMusic extension by running:: Configuration ============= -Before starting Mopidy, you must add your Google username, password, -and Android mobile device ID to your Mopidy configuration file:: +Before starting Mopidy, you must add your Google username and password to your +Mopidy configuration file:: [gmusic] username = alice password = secret + +If you use 2-step verification to access your Google account, which you should, +you must create an application password in your Google account for +Mopidy-GMusic. See Google's docs on `how to make an app password +`_ if you're not already +familiar with this. + +All Access subscribers may enable All Access integration by adding this line:: + + [gmusic] + all_access = true + +By default, the bitrate is set to 160 kbps. You can change this to either 128 +or 320 kbps by setting:: + + [gmusic] + bitrate = 320 + +All Access radios are available as browsable content or playlist. The following +are the default config values:: + + [gmusic] + # Show radio stations in content browser + radio_stations_in_browse = true + # Show radio stations as playlists + radio_stations_as_playlists = false + # Limit the number of radio stations, unlimited if unset + radio_stations_count = + # Limit the number or tracks for each radio station + radio_tracks_count = 25 + +Google Play Music requires all clients to provide a device ID. By default, +Mopidy-GMusic will use your system's MAC address as the device ID. As Google +`puts some limits `_ on +how many different devices you can associate with an account, you might want to +control what device ID is used. You can set the ``gmusic/deviceid`` config to +e.g. the device ID from your phone where you also use Google Play Music:: + + [gmusic] deviceid = 0123456789abcdef -The mobile device ID is a 16-digit hexadecimal string (without a '0x' -prefix) identifying the Android device registered for Google Play -Music. You can obtain this ID by dialing ``*#*#8255#*#*`` on your -phone (see the aid) or using this `App -`_ -(see the Google Service Framework ID Key). You may also leave this -field empty. Mopidy will try to find the ID by itself. See the Mopidy -logs for more information. +The Android device ID is a 16 character long string identifying the Android +device registered for Google Play Music, excluding the ``0x`` prefix. You can +obtain this ID by dialing ``*#*#8255#*#*`` on your phone (see the aid) or using +this `app `_ +(see the Google Service Framework ID Key). + +On iOS the device ID is an UUID with the ``ios:`` prefix included. (TODO: +Include instructions on how to retrieve this.) Usage @@ -78,15 +134,48 @@ your library. Mopidy will able to play them. Project resources ================= -- `Source code `_ -- `Issue tracker `_ -- `Download development snapshot - `_ +- `Source code `_ +- `Issue tracker `_ + + +Credits +======= + +- Original author: `Ronald Hecht `_ +- Current maintainer: None. Maintainer wanted, see section above. +- `Contributors `_ Changelog ========= +v1.0.0 (2015-10-23) +------------------- + +- Require Mopidy >= 1.0. +- Require gmusicapi >= 6.0. +- Update to work with new playback API in Mopidy 1.0. (PR: #75) +- Update to work with new search API in Mopidy 1.0. +- Fix crash when tracks lack album or artist information. (Fixes: #74, PR: #24, + also thanks to PRs #27, #64) +- Log error on login failure instead of swallowing the error. (PR: #36) +- Add support for All Access search and lookup (PR: #34) +- Add dynamic playlist based on top rated tracks. +- Add support for radio stations in browser and/or as playlists. +- Add support for browsing artists and albums in the cached library. +- Add cover art to ``Album.images`` model field. +- Add background refreshing of library and playlists. (Fixes: #21) +- Fix authentication issues. (Fixes: #82, #87) +- Add LRU cache for All Access albums and tracks. +- Increment Google's play count if 50% or 240s of the track has been played. + (PR: #51, and later changes) +- Let gmusicapi use the device's MAC address as device ID by default. +- Fix increasing of play counts in Google Play Music. (Fixes: #96) +- Fix scrobbling of tracks to Last.fm through Mopidy-Scrobbler. (Fixes: #60) +- Fix unhandled crashes on network connectivity issues. (Fixes: #85) +- Add ``gmusic/bitrate`` config to select streaming bitrate. + + v0.3.0 (2014-01-28) ------------------- @@ -112,7 +201,7 @@ v0.2.1 (2013-10-11) v0.2 (2013-10-11) -------------------- +----------------- - Issue #12: Now able to play music from Google All Access - Issue #9: Switched to the Mobileclient API of Google Music API diff --git a/mopidy_gmusic/__init__.py b/mopidy_gmusic/__init__.py index 7277ca6..dae505a 100644 --- a/mopidy_gmusic/__init__.py +++ b/mopidy_gmusic/__init__.py @@ -5,7 +5,7 @@ from mopidy import config, ext -__version__ = '0.3.0' +__version__ = '1.0.0' class GMusicExtension(ext.Extension): @@ -20,11 +20,29 @@ def get_default_config(self): def get_config_schema(self): schema = super(GMusicExtension, self).get_config_schema() + schema['username'] = config.String() schema['password'] = config.Secret() + + schema['bitrate'] = config.Integer(choices=(128, 160, 320)) + schema['deviceid'] = config.String(optional=True) + + schema['all_access'] = config.Boolean(optional=True) + + schema['refresh_library'] = config.Integer(minimum=-1, optional=True) + schema['refresh_playlists'] = config.Integer(minimum=-1, optional=True) + + schema['radio_stations_in_browse'] = config.Boolean(optional=True) + schema['radio_stations_as_playlists'] = config.Boolean(optional=True) + schema['radio_stations_count'] = config.Integer( + minimum=1, optional=True) + schema['radio_tracks_count'] = config.Integer(minimum=1, optional=True) + return schema def setup(self, registry): from .actor import GMusicBackend + from .scrobbler_frontend import GMusicScrobblerFrontend registry.add('backend', GMusicBackend) + registry.add('frontend', GMusicScrobblerFrontend) diff --git a/mopidy_gmusic/actor.py b/mopidy_gmusic/actor.py index 34fd099..322e520 100644 --- a/mopidy_gmusic/actor.py +++ b/mopidy_gmusic/actor.py @@ -1,25 +1,47 @@ from __future__ import unicode_literals -import pykka +import logging +import time + +from threading import Lock from mopidy import backend +import pykka + from .library import GMusicLibraryProvider from .playback import GMusicPlaybackProvider from .playlists import GMusicPlaylistsProvider +from .repeating_timer import RepeatingTimer +from .scrobbler_frontend import GMusicScrobblerListener from .session import GMusicSession +logger = logging.getLogger(__name__) + + +class GMusicBackend( + pykka.ThreadingActor, backend.Backend, GMusicScrobblerListener): -class GMusicBackend(pykka.ThreadingActor, backend.Backend): def __init__(self, config, audio): super(GMusicBackend, self).__init__() self.config = config + self._refresh_library_rate = \ + config['gmusic']['refresh_library'] * 60.0 + self._refresh_playlists_rate = \ + config['gmusic']['refresh_playlists'] * 60.0 + self._refresh_library_timer = None + self._refresh_playlists_timer = None + self._refresh_lock = Lock() + self._refresh_last = 0 + # do not run playlist refresh around library refresh + self._refresh_threshold = self._refresh_playlists_rate * 0.3 + self.library = GMusicLibraryProvider(backend=self) self.playback = GMusicPlaybackProvider(audio=audio, backend=self) self.playlists = GMusicPlaylistsProvider(backend=self) - self.session = GMusicSession() + self.session = GMusicSession(all_access=config['gmusic']['all_access']) self.uri_schemes = ['gmusic'] @@ -27,8 +49,65 @@ def on_start(self): self.session.login(self.config['gmusic']['username'], self.config['gmusic']['password'], self.config['gmusic']['deviceid']) - self.library.refresh() - self.playlists.refresh() + self.library.set_all_access(self.config['gmusic']['all_access']) + + # wait a few seconds to let mopidy settle + # then refresh google music content asynchronously + self._refresh_library_timer = RepeatingTimer( + self._refresh_library, + self._refresh_library_rate) + self._refresh_library_timer.start() + # schedule playlist refresh as desired + if self._refresh_playlists_rate > 0: + self._refresh_playlists_timer = RepeatingTimer( + self._refresh_playlists, + self._refresh_playlists_rate) + self._refresh_playlists_timer.start() def on_stop(self): + if self._refresh_library_timer: + self._refresh_library_timer.cancel() + self._refresh_library_timer = None + if self._refresh_playlists_timer: + self._refresh_playlists_timer.cancel() + self._refresh_playlists_timer = None self.session.logout() + + def increment_song_playcount(self, track_id): + # Called through GMusicScrobblerListener + self.session.increment_song_playcount(track_id) + + def _refresh_library(self): + with self._refresh_lock: + t0 = round(time.time()) + logger.info('Start refreshing Google Music library') + self.library.refresh() + self.playlists.refresh() + t = round(time.time()) - t0 + logger.info('Finished refreshing Google Music content in %ds', t) + self._refresh_last = t0 + + def _refresh_playlists(self): + if not self._refresh_lock.acquire(False): + # skip, if library is already loading + logger.debug('Skip refresh playlist: library refresh is running.') + return + t0 = round(time.time()) + if 0 < self._refresh_library_rate \ + < self._refresh_threshold + t0 - self._refresh_last: + # skip, upcoming library refresh + logger.debug('Skip refresh playlist: ' + + 'library refresh is around the corner') + self._refresh_lock.release() + return + if self._refresh_last > t0 - self._refresh_threshold: + # skip, library was just updated + logger.debug('Skip refresh playlist: ' + + 'library just finished') + self._refresh_lock.release() + return + logger.info('Start refreshing Google Music playlists') + self.playlists.refresh() + t = round(time.time()) - t0 + logger.info('Finished refreshing Google Music content in %ds', t) + self._refresh_lock.release() diff --git a/mopidy_gmusic/ext.conf b/mopidy_gmusic/ext.conf index aaac5ad..52fb0d2 100644 --- a/mopidy_gmusic/ext.conf +++ b/mopidy_gmusic/ext.conf @@ -2,4 +2,12 @@ enabled = true username = password = +bitrate = 160 deviceid = +all_access = false +refresh_library = 1440 +refresh_playlists = 60 +radio_stations_in_browse = true +radio_stations_as_playlists = false +radio_stations_count = +radio_tracks_count = 25 diff --git a/mopidy_gmusic/library.py b/mopidy_gmusic/library.py index d120414..588f977 100644 --- a/mopidy_gmusic/library.py +++ b/mopidy_gmusic/library.py @@ -1,72 +1,155 @@ from __future__ import unicode_literals -import logging import hashlib +import logging from mopidy import backend -from mopidy.models import Artist, Album, Track, SearchResult +from mopidy.models import Album, Artist, Ref, SearchResult, Track + +from mopidy_gmusic.lru_cache import LruCache logger = logging.getLogger(__name__) class GMusicLibraryProvider(backend.LibraryProvider): + root_directory = Ref.directory(uri='gmusic:directory', name='Google Music') - def find_exact(self, query=None, uris=None): - if query is None: - query = {} - self._validate_query(query) - result_tracks = self.tracks.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - if field == 'track_no': - q = self._convert_to_int(value) - else: - q = value.strip() - - uri_filter = lambda t: q == t.uri - track_name_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr(t, 'album', Album()).name - artist_filter = lambda t: filter( - lambda a: q == a.name, t.artists) or filter( - lambda a: q == a.name, getattr(t, 'album', - Album()).artists) - albumartist_filter = lambda t: any([ - q == a.name - for a in getattr(t.album, 'artists', [])]) - track_no_filter = lambda t: q == t.track_no - date_filter = lambda t: q == t.date - any_filter = lambda t: ( - uri_filter(t) or - track_name_filter(t) or - album_filter(t) or - artist_filter(t) or - albumartist_filter(t) or - date_filter(t)) - - if field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'track_name': - result_tracks = filter(track_name_filter, result_tracks) - elif field == 'album': - result_tracks = filter(album_filter, result_tracks) - elif field == 'artist': - result_tracks = filter(artist_filter, result_tracks) - elif field == 'albumartist': - result_tracks = filter(albumartist_filter, result_tracks) - elif field == 'track_no': - result_tracks = filter(track_no_filter, result_tracks) - elif field == 'date': - result_tracks = filter(date_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - - return SearchResult(uri='gmusic:search', tracks=result_tracks) + def __init__(self, *args, **kwargs): + super(GMusicLibraryProvider, self).__init__(*args, **kwargs) + self.tracks = {} + self.albums = {} + self.artists = {} + self.aa_artists = {} + self.aa_tracks = LruCache() + self.aa_albums = LruCache() + self.all_access = False + self._radio_stations_in_browse = ( + self.backend.config['gmusic']['radio_stations_in_browse']) + self._radio_stations_count = ( + self.backend.config['gmusic']['radio_stations_count']) + self._radio_tracks_count = ( + self.backend.config['gmusic']['radio_tracks_count']) + self._root = [] + self._root.append(Ref.directory(uri='gmusic:album', name='Albums')) + self._root.append(Ref.directory(uri='gmusic:artist', name='Artists')) + # browsing all tracks results in connection timeouts + # self._root.append(Ref.directory(uri='gmusic:track', name='Tracks')) + + if self._radio_stations_in_browse: + self._root.append(Ref.directory(uri='gmusic:radio', + name='Radios')) + # show root only if there is something to browse + if len(self._root) > 0: + GMusicLibraryProvider.root_directory = Ref.directory( + uri='gmusic:directory', name='Google Music') + + def set_all_access(self, all_access): + self.all_access = all_access + + def _browse_albums(self): + refs = [] + for album in self.albums.values(): + refs.append(self._album_to_ref(album)) + refs.sort(key=lambda ref: ref.name) + return refs + + def _browse_album(self, uri): + refs = [] + for track in self._lookup_album(uri): + refs.append(self._track_to_ref(track, True)) + return refs + + def _browse_artists(self): + refs = [] + for artist in self.artists.values(): + refs.append(self._artist_to_ref(artist)) + refs.sort(key=lambda ref: ref.name) + return refs + + def _browse_artist(self, uri): + refs = [] + for album in self._get_artist_albums(uri): + refs.append(self._album_to_ref(album)) + refs.sort(key=lambda ref: ref.name) + if len(refs) > 0: + refs.insert(0, Ref.directory(uri=uri + ':all', name='All Tracks')) + return refs + else: + # Show all tracks if no album is available + return self._browse_artist_all_tracks(uri) + + def _browse_artist_all_tracks(self, uri): + artist_uri = ':'.join(uri.split(':')[:3]) + refs = [] + tracks = self._lookup_artist(artist_uri, True) + for track in tracks: + refs.append(self._track_to_ref(track)) + return refs + + def browse(self, uri): + logger.debug('browse: %s', str(uri)) + if not uri: + return [] + if uri == self.root_directory.uri: + return self._root + + parts = uri.split(':') + + # albums + if uri == 'gmusic:album': + return self._browse_albums() + + # a single album + # uri == 'gmusic:album:album_id' + if len(parts) == 3 and parts[1] == 'album': + return self._browse_album(uri) + + # artists + if uri == 'gmusic:artist': + return self._browse_artists() + + # a single artist + # uri == 'gmusic:artist:artist_id' + if len(parts) == 3 and parts[1] == 'artist': + return self._browse_artist(uri) + + # all tracks of a single artist + # uri == 'gmusic:artist:artist_id:all' + if len(parts) == 4 and parts[1] == 'artist' and parts[3] == 'all': + return self._browse_artist_all_tracks(uri) + + # all radio stations + if uri == 'gmusic:radio': + stations = self.backend.session.get_radio_stations( + self._radio_stations_count) + # create Ref objects + refs = [] + for station in stations: + refs.append(Ref.directory(uri='gmusic:radio:' + station['id'], + name=station['name'])) + return refs + + # a single radio station + # uri == 'gmusic:radio:station_id' + if len(parts) == 3 and parts[1] == 'radio': + station_id = parts[2] + tracks = self.backend.session.get_station_tracks( + station_id, self._radio_tracks_count) + # create Ref objects + refs = [] + for track in tracks: + track_id = track['nid'] + # some clients request a lookup themself, some don't + # we do not want to ask the API for every track twice + # we'll fetch some information directly from provided object + track_name = '%s - %s' % (track['artist'], track['title']) + refs.append(Ref.track(uri='gmusic:track:' + track_id, + name=track_name)) + return refs + + logger.debug('Unknown uri for browse request: %s', uri) + + return [] def lookup(self, uri): if uri.startswith('gmusic:track:'): @@ -79,51 +162,196 @@ def lookup(self, uri): return [] def _lookup_track(self, uri): - if uri.startswith('gmusic:track:T'): + is_all_access = uri.startswith('gmusic:track:T') + + if is_all_access and self.all_access: + track = self.aa_tracks.hit(uri) + if track: + return [track] song = self.backend.session.get_track_info(uri.split(':')[2]) if song is None: + logger.warning('There is no song %r', uri) + return [] + if 'artistId' not in song: + logger.warning('Failed to lookup %r', uri) return [] return [self._aa_to_mopidy_track(song)] - try: - return [self.tracks[uri]] - except KeyError: - logger.debug('Failed to lookup %r', uri) + elif not is_all_access: + try: + return [self.tracks[uri]] + except KeyError: + logger.debug('Failed to lookup %r', uri) + return [] + else: return [] def _lookup_album(self, uri): - try: - album = self.albums[uri] - except KeyError: + is_all_access = uri.startswith('gmusic:album:B') + if self.all_access and is_all_access: + tracks = self.aa_albums.hit(uri) + if tracks: + return tracks + album = self.backend.session.get_album_info( + uri.split(':')[2], include_tracks=True) + if album is None or not album['tracks']: + logger.warning('Failed to lookup %r: %r', uri, album) + return [] + tracks = [ + self._aa_to_mopidy_track(track) for track in album['tracks']] + self.aa_albums[uri] = tracks + return sorted(tracks, key=lambda t: (t.disc_no, + t.track_no)) + elif not is_all_access: + try: + album = self.albums[uri] + except KeyError: + logger.debug('Failed to lookup %r', uri) + return [] + tracks = self._find_exact( + dict(album=album.name, + artist=[artist.name for artist in album.artists], + date=album.date)).tracks + return sorted(tracks, key=lambda t: (t.disc_no, + t.track_no)) + else: logger.debug('Failed to lookup %r', uri) return [] - tracks = self.find_exact( - dict(album=album.name, - artist=[artist.name for artist in album.artists], - date=album.date)).tracks - return sorted(tracks, key=lambda t: (t.disc_no, - t.track_no)) - - def _lookup_artist(self, uri): + + def _get_artist_albums(self, uri): + is_all_access = uri.startswith('gmusic:artist:A') + + artist_id = uri.split(':')[2] + if is_all_access: + # all access + artist_infos = self.backend.session.get_artist_info( + artist_id, max_top_tracks=0, max_rel_artist=0) + if artist_infos is None or 'albums' not in artist_infos: + return [] + albums = [] + for album in artist_infos['albums']: + albums.append( + self._aa_search_album_to_mopidy_album({'album': album})) + return albums + elif self.all_access and artist_id in self.aa_artists: + albums = self._get_artist_albums( + 'gmusic:artist:%s' % self.aa_artists[artist_id]) + if len(albums) > 0: + return albums + # else fall back to non aa albums + if uri in self.artists: + artist = self.artists[uri] + return [album for album in self.albums.values() + if artist in album.artists] + else: + logger.debug('0 albums available for artist %r', uri) + return [] + + def _lookup_artist(self, uri, exact_match=False): + sorter = lambda t: (t.album.date, + t.album.name, + t.disc_no, + t.track_no) + if self.all_access: + try: + all_access_id = self.aa_artists[uri.split(':')[2]] + artist_infos = self.backend.session.get_artist_info( + all_access_id, max_top_tracks=0, max_rel_artist=0) + if not artist_infos or not artist_infos['albums']: + logger.warning('Failed to lookup %r', artist_infos) + tracks = [ + self._lookup_album('gmusic:album:' + album['albumId']) + for album in artist_infos['albums']] + tracks = reduce(lambda a, b: (a + b), tracks) + return sorted(tracks, key=sorter) + except KeyError: + pass try: artist = self.artists[uri] except KeyError: logger.debug('Failed to lookup %r', uri) return [] - tracks = self.find_exact( + + tracks = self._find_exact( dict(artist=artist.name)).tracks - return sorted(tracks, key=lambda t: (t.album.date, - t.album.name, - t.disc_no, - t.track_no)) + if exact_match: + tracks = filter(lambda t: artist in t.artists, tracks) + return sorted(tracks, key=sorter) def refresh(self, uri=None): self.tracks = {} self.albums = {} self.artists = {} + self.aa_artists = {} for song in self.backend.session.get_all_songs(): self._to_mopidy_track(song) - def search(self, query=None, uris=None): + def search(self, query=None, uris=None, exact=False): + if exact: + return self._find_exact(query=query, uris=uris) + + lib_tracks, lib_artists, lib_albums = self._search_library(query, uris) + + if query and self.all_access: + aa_tracks, aa_artists, aa_albums = self._search_all_access( + query, uris) + for aa_artist in aa_artists: + lib_artists.add(aa_artist) + + for aa_album in aa_albums: + lib_albums.add(aa_album) + + lib_tracks = set(lib_tracks) + + for aa_track in aa_tracks: + lib_tracks.add(aa_track) + + return SearchResult(uri='gmusic:search', + tracks=lib_tracks, + artists=lib_artists, + albums=lib_albums) + + def _find_exact(self, query=None, uris=None): + # Find exact can only be done on gmusic library, + # since one can't filter all access searches + lib_tracks, lib_artists, lib_albums = self._search_library(query, uris) + + return SearchResult(uri='gmusic:search', + tracks=lib_tracks, + artists=lib_artists, + albums=lib_albums) + + def _search_all_access(self, query=None, uris=None): + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + + # Since gmusic does not support search filters, just search for the + # first 'searchable' filter + if field in [ + 'track_name', 'album', 'artist', 'albumartist', 'any']: + logger.info( + 'Searching Google Play Music All Access for: %s', + values[0]) + res = self.backend.session.search_all_access( + values[0], max_results=50) + if res is None: + return [], [], [] + + albums = [ + self._aa_search_album_to_mopidy_album(album_res) + for album_res in res['album_hits']] + artists = [ + self._aa_search_artist_to_mopidy_artist(artist_res) + for artist_res in res['artist_hits']] + tracks = [ + self._aa_search_track_to_mopidy_track(track_res) + for track_res in res['song_hits']] + + return tracks, artists, albums + + return [], [], [] + + def _search_library(self, query=None, uris=None): if query is None: query = {} self._validate_query(query) @@ -186,10 +414,7 @@ def search(self, query=None, uris=None): result_artists |= track.artists result_albums.add(track.album) - return SearchResult(uri='gmusic:search', - tracks=result_tracks, - artists=result_artists, - albums=result_albums) + return result_tracks, result_artists, result_albums def _validate_query(self, query): for (_, values) in query.iteritems(): @@ -215,28 +440,45 @@ def _to_mopidy_track(self, song): return track def _to_mopidy_album(self, song): - name = song.get('album', '') - artist = self._to_mopidy_album_artist(song) - date = unicode(song.get('year', 0)) - uri = 'gmusic:album:' + self._create_id(artist.name + name + date) - album = Album( - uri=uri, - name=name, - artists=[artist], - num_tracks=song.get('totalTrackCount', 1), - num_discs=song.get('totalDiscCount', song.get('discNumber', 1)), - date=date) - self.albums[uri] = album - return album + # First try to process the album as an aa album + # (Difference being that non aa albums don't have albumId) + try: + album = self._aa_to_mopidy_album(song) + return album + except KeyError: + name = song.get('album', '') + artist = self._to_mopidy_album_artist(song) + date = unicode(song.get('year', 0)) + uri = 'gmusic:album:' + self._create_id(artist.name + name + date) + images = self._get_images(song) + album = Album( + uri=uri, + name=name, + artists=[artist], + num_tracks=song.get('totalTrackCount', 1), + num_discs=song.get( + 'totalDiscCount', song.get('discNumber', 1)), + date=date, + images=images) + self.albums[uri] = album + return album def _to_mopidy_artist(self, song): name = song.get('artist', '') uri = 'gmusic:artist:' + self._create_id(name) - artist = Artist( - uri=uri, - name=name) - self.artists[uri] = artist - return artist + + # First try to process the artist as an aa artist + # (Difference being that non aa artists don't have artistId) + try: + artist = self._aa_to_mopidy_artist(song) + self.artists[uri] = artist + return artist + except KeyError: + artist = Artist( + uri=uri, + name=name) + self.artists[uri] = artist + return artist def _to_mopidy_album_artist(self, song): name = song.get('albumArtist', '') @@ -253,7 +495,7 @@ def _aa_to_mopidy_track(self, song): uri = 'gmusic:track:' + song['storeId'] album = self._aa_to_mopidy_album(song) artist = self._aa_to_mopidy_artist(song) - return Track( + track = Track( uri=uri, name=song['title'], artists=[artist], @@ -263,32 +505,141 @@ def _aa_to_mopidy_track(self, song): date=album.date, length=int(song['durationMillis']), bitrate=320) + self.aa_tracks[uri] = track + return track def _aa_to_mopidy_album(self, song): - album_info = self.backend.session.get_album_info(song['albumId'], - include_tracks=False) - if album_info is None: - return None - name = album_info['name'] - artist = self._aa_to_mopidy_album_artist(album_info) - date = unicode(album_info.get('year', 0)) + uri = 'gmusic:album:' + song['albumId'] + name = song['album'] + artist = self._aa_to_mopidy_album_artist(song) + date = unicode(song.get('year', 0)) + images = self._get_images(song) return Album( + uri=uri, name=name, artists=[artist], - date=date) + date=date, + images=images) def _aa_to_mopidy_artist(self, song): name = song.get('artist', '') + artist_id = self._create_id(name) + uri = 'gmusic:artist:' + artist_id + self.aa_artists[artist_id] = song['artistId'][0] return Artist( + uri=uri, name=name) - def _aa_to_mopidy_album_artist(self, album_info): - name = album_info.get('albumArtist', '') + def _aa_to_mopidy_album_artist(self, song): + name = song.get('albumArtist', '') if name.strip() == '': - name = album_info.get('artist', '') + name = song['artist'] + uri = 'gmusic:artist:' + self._create_id(name) return Artist( + uri=uri, name=name) + def _aa_search_track_to_mopidy_track(self, search_track): + track = search_track['track'] + + aa_artist_id = self._create_id(track['artist']) + if 'artistId' in track: + self.aa_artists[aa_artist_id] = track['artistId'][0] + else: + logger.warning('No artistId for Track %r', track) + + artist = Artist( + uri='gmusic:artist:' + aa_artist_id, + name=track['artist']) + + album = Album( + uri='gmusic:album:' + track['albumId'], + name=track['album'], + artists=[artist], + date=unicode(track.get('year', 0))) + + return Track( + uri='gmusic:track:' + track['storeId'], + name=track['title'], + artists=[artist], + album=album, + track_no=track.get('trackNumber', 1), + disc_no=track.get('discNumber', 1), + date=unicode(track.get('year', 0)), + length=int(track['durationMillis']), + bitrate=320) + + def _aa_search_artist_to_mopidy_artist(self, search_artist): + artist = search_artist['artist'] + artist_id = self._create_id(artist['name']) + uri = 'gmusic:artist:' + artist_id + self.aa_artists[artist_id] = artist['artistId'] + return Artist( + uri=uri, + name=artist['name']) + + def _aa_search_album_to_mopidy_album(self, search_album): + album = search_album['album'] + uri = 'gmusic:album:' + album['albumId'] + name = album['name'] + artist = self._aa_search_artist_album_to_mopidy_artist_album(album) + date = unicode(album.get('year', 0)) + return Album( + uri=uri, + name=name, + artists=[artist], + date=date) + + def _aa_search_artist_album_to_mopidy_artist_album(self, album): + name = album.get('albumArtist', '') + if name.strip() == '': + name = album.get('artist', '') + uri = 'gmusic:artist:' + self._create_id(name) + return Artist( + uri=uri, + name=name) + + def _album_to_ref(self, album): + name = '' + for artist in album.artists: + if len(name) > 0: + name += ', ' + name += artist.name + if (len(name)) > 0: + name += ' - ' + if album.name: + name += album.name + else: + name += 'Unknown Album' + return Ref.directory(uri=album.uri, name=name) + + def _artist_to_ref(self, artist): + if artist.name: + name = artist.name + else: + name = 'Unknown artist' + return Ref.directory(uri=artist.uri, name=name) + + def _track_to_ref(self, track, with_track_no=False): + if with_track_no and track.track_no > 0: + name = '%d - ' % track.track_no + else: + name = '' + for artist in track.artists: + if len(name) > 0: + name += ', ' + name += artist.name + if (len(name)) > 0: + name += ' - ' + name += track.name + return Ref.track(uri=track.uri, name=name) + + def _get_images(self, song): + if 'albumArtRef' in song: + return [art_ref['url'] + for art_ref in song['albumArtRef'] + if 'url' in art_ref] + def _create_id(self, u): return hashlib.md5(u.encode('utf-8')).hexdigest() diff --git a/mopidy_gmusic/lru_cache.py b/mopidy_gmusic/lru_cache.py new file mode 100644 index 0000000..cbd58d0 --- /dev/null +++ b/mopidy_gmusic/lru_cache.py @@ -0,0 +1,38 @@ +import logging + +from collections import OrderedDict + +logger = logging.getLogger(__name__) + + +class LruCache(OrderedDict): + def __init__(self, max_size=1024): + if max_size <= 0: + raise ValueError('Invalid size') + OrderedDict.__init__(self) + self._max_size = max_size + self._check_limit() + + def get_max_size(self): + return self._max_size + + def hit(self, key): + if key in self: + val = self[key] + self[key] = val + # logger.debug('HIT: %r -> %r', key, val) + return val + # logger.debug('MISS: %r', key) + return None + + def __setitem__(self, key, value): + if key in self: + del self[key] + OrderedDict.__setitem__(self, key, value) + self._check_limit() + + def _check_limit(self): + while len(self) > self._max_size: + # delete oldest entries + k = self.keys()[0] + del self[k] diff --git a/mopidy_gmusic/playback.py b/mopidy_gmusic/playback.py index ae49f87..2168e18 100644 --- a/mopidy_gmusic/playback.py +++ b/mopidy_gmusic/playback.py @@ -1,14 +1,27 @@ from __future__ import unicode_literals +import logging + from mopidy import backend +logger = logging.getLogger(__name__) + + +BITRATES = { + 128: 'low', + 160: 'med', + 320: 'hi', +} + class GMusicPlaybackProvider(backend.PlaybackProvider): + def translate_uri(self, uri): + track_id = uri.rsplit(':')[-1] + + # TODO Support medium and low bitrate + quality = BITRATES[self.backend.config['gmusic']['bitrate']] + stream_uri = self.backend.session.get_stream_url( + track_id, quality=quality) - def play(self, track): - url = self.backend.session.get_stream_url(track.uri.split(':')[2]) - if url is None: - return False - self.audio.prepare_change() - self.audio.set_uri(url).get() - return self.audio.start_playback().get() + logger.debug('Translated: %s -> %s', uri, stream_uri) + return stream_uri diff --git a/mopidy_gmusic/playlists.py b/mopidy_gmusic/playlists.py index 83e9e61..55511a3 100644 --- a/mopidy_gmusic/playlists.py +++ b/mopidy_gmusic/playlists.py @@ -1,15 +1,38 @@ from __future__ import unicode_literals import logging +import operator from mopidy import backend -from mopidy.models import Playlist +from mopidy.models import Playlist, Ref logger = logging.getLogger(__name__) class GMusicPlaylistsProvider(backend.PlaylistsProvider): + def __init__(self, *args, **kwargs): + super(GMusicPlaylistsProvider, self).__init__(*args, **kwargs) + self._radio_stations_as_playlists = ( + self.backend.config['gmusic']['radio_stations_as_playlists']) + self._radio_stations_count = ( + self.backend.config['gmusic']['radio_stations_count']) + self._radio_tracks_count = ( + self.backend.config['gmusic']['radio_tracks_count']) + self._playlists = {} + + def as_list(self): + refs = [ + Ref.playlist(uri=pl.uri, name=pl.name) + for pl in self._playlists.values()] + return sorted(refs, key=operator.attrgetter('name')) + + def get_items(self, uri): + playlist = self._playlists.get(uri) + if playlist is None: + return None + return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] + def create(self, name): pass # TODO @@ -17,13 +40,27 @@ def delete(self, uri): pass # TODO def lookup(self, uri): - for playlist in self.playlists: - if playlist.uri == uri: - return playlist + return self._playlists.get(uri) def refresh(self): - playlists = [] + playlists = {} + # add thumbs up playlist + tracks = [] + for track in self.backend.session.get_promoted_songs(): + trackId = None + if 'trackId' in track: + trackId = track['trackId'] + elif 'storeId' in track: + trackId = track['storeId'] + if trackId: + tracks += self.backend.library.lookup( + 'gmusic:track:' + trackId) + if len(tracks) > 0: + uri = 'gmusic:playlist:promoted' + playlists[uri] = Playlist(uri=uri, name='Promoted', tracks=tracks) + + # load user playlists for playlist in self.backend.session.get_all_user_playlist_contents(): tracks = [] for track in playlist['tracks']: @@ -31,11 +68,12 @@ def refresh(self): tracks += self.backend.library.lookup('gmusic:track:' + track['trackId']) - playlist = Playlist(uri='gmusic:playlist:' + playlist['id'], - name=playlist['name'], - tracks=tracks) - playlists.append(playlist) + uri = 'gmusic:playlist:' + playlist['id'] + playlists[uri] = Playlist(uri=uri, + name=playlist['name'], + tracks=tracks) + # load shared playlists for playlist in self.backend.session.get_all_playlists(): if playlist.get('type') == 'SHARED': tracks = [] @@ -44,12 +82,34 @@ def refresh(self): for track in tracklist: tracks += self.backend.library.lookup('gmusic:track:' + track['trackId']) - playlist = Playlist(uri='gmusic:playlist:' + playlist['id'], - name=playlist['name'], - tracks=tracks) - playlists.append(playlist) + uri = 'gmusic:playlist:' + playlist['id'] + playlists[uri] = Playlist(uri=uri, + name=playlist['name'], + tracks=tracks) + + l = len(playlists) + logger.info('Loaded %d playlists from Google Music', len(playlists)) + + # load radios as playlists + if self._radio_stations_as_playlists: + logger.info('Starting to loading radio stations') + stations = self.backend.session.get_radio_stations( + self._radio_stations_count) + for station in stations: + tracks = [] + tracklist = self.backend.session.get_station_tracks( + station['id'], self._radio_tracks_count) + for track in tracklist: + tracks += self.backend.library.lookup('gmusic:track:' + + track['nid']) + uri = 'gmusic:playlist:' + station['id'] + playlists[uri] = Playlist(uri=uri, + name=station['name'], + tracks=tracks) + logger.info('Loaded %d radios from Google Music', + len(playlists) - l) - self.playlists = playlists + self._playlists = playlists backend.BackendListener.send('playlists_loaded') def save(self, playlist): diff --git a/mopidy_gmusic/repeating_timer.py b/mopidy_gmusic/repeating_timer.py new file mode 100644 index 0000000..9f44810 --- /dev/null +++ b/mopidy_gmusic/repeating_timer.py @@ -0,0 +1,19 @@ +from threading import Event, Thread + + +class RepeatingTimer(Thread): + def __init__(self, method, interval=0): + Thread.__init__(self) + self._stop_event = Event() + self._interval = interval + self._method = method + + def run(self): + self._method() + while self._interval > 0 and not self._stop_event.wait(self._interval): + # wait for interval + # call method over and over again + self._method() + + def cancel(self): + self._stop_event.set() diff --git a/mopidy_gmusic/scrobbler_frontend.py b/mopidy_gmusic/scrobbler_frontend.py new file mode 100644 index 0000000..09553e7 --- /dev/null +++ b/mopidy_gmusic/scrobbler_frontend.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals + +import logging + +from mopidy import core, listener + +import pykka + + +logger = logging.getLogger(__name__) + + +class GMusicScrobblerFrontend(pykka.ThreadingActor, core.CoreListener): + def __init__(self, config, core): + super(GMusicScrobblerFrontend, self).__init__() + + def track_playback_ended(self, tl_track, time_position): + track = tl_track.track + + duration = track.length and track.length // 1000 or 0 + time_position = time_position // 1000 + + if time_position < duration // 2 and time_position < 240: + logger.debug( + 'Track not played long enough too scrobble. (50% or 240s)') + return + + track_id = track.uri.rsplit(':')[-1] + logger.debug('Increasing play count: %s', track_id) + listener.send( + GMusicScrobblerListener, + 'increment_song_playcount', track_id=track_id) + + +class GMusicScrobblerListener(listener.Listener): + def increment_song_playcount(self, track_id): + pass diff --git a/mopidy_gmusic/session.py b/mopidy_gmusic/session.py index 62efc9e..dda7f72 100644 --- a/mopidy_gmusic/session.py +++ b/mopidy_gmusic/session.py @@ -1,101 +1,147 @@ from __future__ import unicode_literals +import functools import logging -from gmusicapi import Mobileclient, Webclient, CallFailure +import gmusicapi + +import requests + logger = logging.getLogger(__name__) +def endpoint(default=None, require_all_access=False): + default = default() if callable(default) else default + + def outer_wrapper(func): + + @functools.wraps(func) + def inner_wrapper(self, *args, **kwargs): + if require_all_access and not self.all_access: + logger.warning( + 'Google Play Music All Access is required for %s()', + func.__name__) + return default + + if not self.api.is_authenticated(): + return default + + try: + return func(self, *args, **kwargs) + except gmusicapi.CallFailure: + logger.exception('Call to Google Music failed') + return default + except requests.exceptions.RequestException: + logger.exception('HTTP request to Google Music failed') + return default + + return inner_wrapper + + return outer_wrapper + + class GMusicSession(object): - def __init__(self): - super(GMusicSession, self).__init__() - logger.info('Mopidy uses Google Music') - self.api = Mobileclient() + def __init__(self, all_access, api=None): + self.all_access = all_access + if api is None: + self.api = gmusicapi.Mobileclient() + else: + self.api = api - def login(self, username, password, deviceid): + def login(self, username, password, device_id): if self.api.is_authenticated(): self.api.logout() - try: - self.api.login(username, password) - except CallFailure as error: - logger.error(u'Failed to login as "%s": %s', username, error) - if self.api.is_authenticated(): - if deviceid is None: - self.deviceid = self.get_deviceid(username, password) - else: - self.deviceid = deviceid + + if device_id is None: + device_id = gmusicapi.Mobileclient.FROM_MAC_ADDRESS + + authenticated = self.api.login(username, password, device_id) + + if authenticated: + logger.info('Logged in to Google Music') else: - return False + logger.error('Failed to login to Google Music as "%s"', username) + return authenticated + @endpoint(default=None) def logout(self): - if self.api.is_authenticated(): - return self.api.logout() - else: - return True + return self.api.logout() + @endpoint(default=list) def get_all_songs(self): - if self.api.is_authenticated(): - return self.api.get_all_songs() - else: - return {} + return self.api.get_all_songs() - def get_stream_url(self, song_id): - if self.api.is_authenticated(): - try: - return self.api.get_stream_url(song_id, self.deviceid) - except CallFailure as error: - logger.error(u'Failed to lookup "%s": %s', song_id, error) + @endpoint(default=None) + def get_stream_url(self, song_id, quality='hi'): + return self.api.get_stream_url(song_id, quality=quality) + + @endpoint(default=list) + def get_all_playlists(self): + return self.api.get_all_playlists() + @endpoint(default=list) def get_all_user_playlist_contents(self): - if self.api.is_authenticated(): - return self.api.get_all_user_playlist_contents() - else: - return {} + return self.api.get_all_user_playlist_contents() - def get_shared_playlist_contents(self, shareToken): - if self.api.is_authenticated(): - return self.api.get_shared_playlist_contents(shareToken) - else: - return {} + @endpoint(default=list) + def get_shared_playlist_contents(self, share_token): + return self.api.get_shared_playlist_contents(share_token) - def get_all_playlists(self): - if self.api.is_authenticated(): - return self.api.get_all_playlists() - else: - return {} - - def get_deviceid(self, username, password): - logger.warning(u'No mobile device ID configured. ' - u'Trying to detect one.') - webapi = Webclient(validate=False) - webapi.login(username, password) - devices = webapi.get_registered_devices() - deviceid = None - for device in devices: - if device['type'] == 'PHONE' and device['id'][0:2] == u'0x': - # Omit the '0x' prefix - deviceid = device['id'][2:] - break - webapi.logout() - if deviceid is None: - logger.error(u'No valid mobile device ID found. ' - u'Playing songs will not work.') - else: - logger.info(u'Using mobile device ID %s', deviceid) - return deviceid + @endpoint(default=list) + def get_promoted_songs(self): + return self.api.get_promoted_songs() + @endpoint(default=None, require_all_access=True) def get_track_info(self, store_track_id): - if self.api.is_authenticated(): - try: - return self.api.get_track_info(store_track_id) - except CallFailure as error: - logger.error(u'Failed to get All Access track info: %s', error) - - def get_album_info(self, albumid, include_tracks=True): - if self.api.is_authenticated(): - try: - return self.api.get_album_info(albumid, include_tracks) - except CallFailure as error: - logger.error(u'Failed to get All Access album info: %s', error) + return self.api.get_track_info(store_track_id) + + @endpoint(default=None, require_all_access=True) + def get_album_info(self, album_id, include_tracks=True): + return self.api.get_album_info( + album_id, include_tracks=include_tracks) + + @endpoint(default=None, require_all_access=True) + def get_artist_info( + self, artist_id, include_albums=True, max_top_tracks=5, + max_rel_artist=5): + return self.api.get_artist_info( + artist_id, + include_albums=include_albums, + max_top_tracks=max_top_tracks, + max_rel_artist=max_rel_artist) + + @endpoint(default=None, require_all_access=True) + def search_all_access(self, query, max_results=50): + return self.api.search_all_access(query, max_results=max_results) + + @endpoint(default=list) + def get_all_stations(self): + return self.api.get_all_stations() + + def get_radio_stations(self, num_stations=None): + stations = self.get_all_stations() + + # Last played radio first + stations.reverse() + + # Add IFL radio on top + # XXX This causes a crash. See simon-weber/gmusicapi#365 + # stations.insert(0, {'id': 'IFL', 'name': 'I\'m Feeling Lucky'}) + + if num_stations is not None and num_stations > 0: + # Limit radio stations + stations = stations[:num_stations] + + return stations + + @endpoint(default=list, require_all_access=True) + def get_station_tracks(self, station_id, num_tracks=25): + return self.api.get_station_tracks( + station_id, num_tracks=num_tracks) + + @endpoint(default=None) + def increment_song_playcount(self, song_id, plays=1, playtime=None): + return self.api.increment_song_playcount( + song_id, plays=plays, playtime=playtime) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1e87441 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[flake8] +application-import-names = mopidy_gmusic,tests +exclude = .git,.tox + +[wheel] +universal = 1 diff --git a/setup.py b/setup.py index ff7c1c0..2c8ab15 100644 --- a/setup.py +++ b/setup.py @@ -1,36 +1,34 @@ from __future__ import unicode_literals import re -from setuptools import setup, find_packages + +from setuptools import find_packages, setup def get_version(filename): - content = open(filename).read() - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content)) - return metadata['version'] + with open(filename) as fh: + metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read())) + return metadata['version'] + setup( name='Mopidy-GMusic', version=get_version('mopidy_gmusic/__init__.py'), - url='http://github.com/hechtus/mopidy-gmusic/', + url='https://github.com/mopidy/mopidy-gmusic', license='Apache License, Version 2.0', author='Ronald Hecht', author_email='ronald.hecht@gmx.de', - description='Google Play Music extension for Mopidy', + description='Mopidy extension for playing music from Google Play Music', long_description=open('README.rst').read(), packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'setuptools', - 'Mopidy >= 0.18', + 'Mopidy >= 1.0', 'Pykka >= 1.1', - 'gmusicapi >= 3.0.0', - ], - test_suite='nose.collector', - tests_require=[ - 'nose', - 'mock >= 1.0', + 'gmusicapi >= 6.0', + 'requests >= 2.0', ], entry_points={ 'mopidy.ext': [ diff --git a/tests/test_extension.py b/tests/test_extension.py index 940a9a6..11d4fd9 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,11 +1,28 @@ -import mock import unittest -from mopidy_gmusic import GMusicExtension, actor as backend_lib +import mock + +from mopidy_gmusic import ( + GMusicExtension, actor as backend_lib, scrobbler_frontend) class ExtensionTest(unittest.TestCase): + @staticmethod + def get_config(): + config = {} + config['username'] = 'testuser@gmail.com' + config['password'] = 'secret_password' + config['deviceid'] = '1234567890' + config['all_access'] = False + config['radio_stations_in_browse'] = True + config['radio_stations_as_playlists'] = False + config['radio_stations_count'] = 0 + config['radio_tracks_count'] = 25 + config['refresh_library'] = 1440 + config['refresh_playlists'] = 60 + return {'gmusic': config} + def test_get_default_config(self): ext = GMusicExtension() @@ -13,6 +30,10 @@ def test_get_default_config(self): self.assertIn('[gmusic]', config) self.assertIn('enabled = true', config) + self.assertIn('all_access = false', config) + self.assertIn('radio_stations_in_browse = true', config) + self.assertIn('radio_stations_count =', config) + self.assertIn('radio_tracks_count = 25', config) def test_get_config_schema(self): ext = GMusicExtension() @@ -22,6 +43,13 @@ def test_get_config_schema(self): self.assertIn('username', schema) self.assertIn('password', schema) self.assertIn('deviceid', schema) + self.assertIn('refresh_library', schema) + self.assertIn('refresh_playlists', schema) + self.assertIn('all_access', schema) + self.assertIn('radio_stations_in_browse', schema) + self.assertIn('radio_stations_as_playlists', schema) + self.assertIn('radio_stations_count', schema) + self.assertIn('radio_tracks_count', schema) def test_get_backend_classes(self): registry = mock.Mock() @@ -29,5 +57,16 @@ def test_get_backend_classes(self): ext = GMusicExtension() ext.setup(registry) - registry.add.assert_called_once_with( - 'backend', backend_lib.GMusicBackend) + self.assertIn( + mock.call('backend', backend_lib.GMusicBackend), + registry.add.mock_calls) + self.assertIn( + mock.call('frontend', scrobbler_frontend.GMusicScrobblerFrontend), + registry.add.mock_calls) + + def test_init_backend(self): + backend = backend_lib.GMusicBackend( + ExtensionTest.get_config(), None) + self.assertIsNotNone(backend) + backend.on_start() + backend.on_stop() diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..41987b0 --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,99 @@ +import unittest + +from mopidy_gmusic import actor as backend_lib + +from tests.test_extension import ExtensionTest + + +class LibraryTest(unittest.TestCase): + + def setUp(self): + config = ExtensionTest.get_config() + self.backend = backend_lib.GMusicBackend(config=config, audio=None) + + def test_browse_radio_deactivated(self): + config = ExtensionTest.get_config() + config['gmusic']['radio_stations_in_browse'] = False + self.backend = backend_lib.GMusicBackend(config=config, audio=None) + + refs = self.backend.library.browse('gmusic:directory') + for ref in refs: + self.assertNotEqual(ref.uri, 'gmusic:radio') + + def test_browse_none(self): + refs = self.backend.library.browse(None) + self.assertEqual(refs, []) + + def test_browse_invalid(self): + refs = self.backend.library.browse('gmusic:invalid_uri') + self.assertEqual(refs, []) + + def test_browse_root(self): + refs = self.backend.library.browse('gmusic:directory') + found = False + for ref in refs: + if ref.uri == 'gmusic:album': + found = True + break + self.assertTrue(found, 'ref \'gmusic:album\' not found') + found = False + for ref in refs: + if ref.uri == 'gmusic:artist': + found = True + break + self.assertTrue(found, 'ref \'gmusic:artist\' not found') + found = False + for ref in refs: + if ref.uri == 'gmusic:radio': + found = True + break + self.assertTrue(found, 'ref \'gmusic:radio\' not found') + + def test_browse_artist(self): + refs = self.backend.library.browse('gmusic:artist') + self.assertIsNotNone(refs) + + def test_browse_artist_id_invalid(self): + refs = self.backend.library.browse('gmusic:artist:artist_id') + self.assertIsNotNone(refs) + self.assertEqual(refs, []) + + def test_browse_album(self): + refs = self.backend.library.browse('gmusic:album') + self.assertIsNotNone(refs) + + def test_browse_album_id_invalid(self): + refs = self.backend.library.browse('gmusic:album:album_id') + self.assertIsNotNone(refs) + self.assertEqual(refs, []) + + def test_browse_radio(self): + refs = self.backend.library.browse('gmusic:radio') + # tests should be unable to fetch stations :( + self.assertIsNotNone(refs) + self.assertEqual(refs, []) + + def test_browse_station(self): + refs = self.backend.library.browse('gmusic:radio:invalid_stations_id') + # tests should be unable to fetch stations :( + self.assertEqual(refs, []) + + def test_lookup_invalid(self): + refs = self.backend.library.lookup('gmusic:invalid_uri') + # tests should be unable to fetch any content :( + self.assertEqual(refs, []) + + def test_lookup_invalid_album(self): + refs = self.backend.library.lookup('gmusic:album:invalid_uri') + # tests should be unable to fetch any content :( + self.assertEqual(refs, []) + + def test_lookup_invalid_artist(self): + refs = self.backend.library.lookup('gmusic:artis:invalid_uri') + # tests should be unable to fetch any content :( + self.assertEqual(refs, []) + + def test_lookup_invalid_track(self): + refs = self.backend.library.lookup('gmusic:track:invalid_uri') + # tests should be unable to fetch any content :( + self.assertEqual(refs, []) diff --git a/tests/test_lru_cache.py b/tests/test_lru_cache.py new file mode 100644 index 0000000..1698891 --- /dev/null +++ b/tests/test_lru_cache.py @@ -0,0 +1,55 @@ +import unittest + +from mopidy_gmusic.lru_cache import LruCache + + +class ExtensionTest(unittest.TestCase): + + def test_init(self): + c = LruCache() + self.assertIsNotNone(c) + self.assertTrue(c.get_max_size() > 0, + 'Size should be greater then zero!') + + def test_init_size(self): + c = LruCache(5) + self.assertEqual(c.get_max_size(), 5) + + def test_init_error(self): + for size in [0, -1]: + self.assertRaises(ValueError, LruCache, size) + + def test_add(self): + c = LruCache(2) + c['a'] = 1 + c['b'] = 2 + c['c'] = 3 + self.assertNotIn('a', c) + self.assertIn('b', c) + self.assertIn('c', c) + + def test_update(self): + c = LruCache(2) + c['a'] = 1 + c['b'] = 2 + c['a'] = 4 + c['c'] = 3 + self.assertIn('a', c) + self.assertNotIn('b', c) + self.assertIn('c', c) + + def test_hit(self): + c = LruCache(2) + c['a'] = 1 + c['b'] = 2 + self.assertEqual(c.hit('a'), 1) + c['c'] = 3 + self.assertIn('a', c) + self.assertNotIn('b', c) + self.assertIn('c', c) + + def test_miss(self): + c = LruCache(2) + c['a'] = 1 + c['b'] = 2 + self.assertIsNone(c.hit('c')) diff --git a/tests/test_playback.py b/tests/test_playback.py new file mode 100644 index 0000000..9e48771 --- /dev/null +++ b/tests/test_playback.py @@ -0,0 +1,48 @@ +from __future__ import unicode_literals + +import mock + +import pytest + +from mopidy_gmusic import playback + + +@pytest.fixture +def backend(): + backend_mock = mock.Mock() + backend_mock.config = { + 'gmusic': { + 'bitrate': 160, + } + } + return backend_mock + + +@pytest.fixture +def provider(backend): + return playback.GMusicPlaybackProvider(audio=None, backend=backend) + + +def test_translate_invalid_uri(backend, provider): + backend.session.get_stream_url.return_value = None + + assert provider.translate_uri('gmusic:track:invalid_uri') is None + + +def test_change_track_valid(backend, provider): + stream_url = 'http://stream.example.com/foo.mp3' + backend.session.get_stream_url.return_value = stream_url + + assert provider.translate_uri('gmusic:track:valid_uri') == stream_url + backend.session.get_stream_url.assert_called_once_with( + 'valid_uri', quality='med') + + +def test_changed_bitrate(backend, provider): + stream_url = 'http://stream.example.com/foo.mp3' + backend.session.get_stream_url.return_value = stream_url + backend.config['gmusic']['bitrate'] = 320 + + assert provider.translate_uri('gmusic:track:valid_uri') == stream_url + backend.session.get_stream_url.assert_called_once_with( + 'valid_uri', quality='hi') diff --git a/tests/test_playlist.py b/tests/test_playlist.py new file mode 100644 index 0000000..f3bd857 --- /dev/null +++ b/tests/test_playlist.py @@ -0,0 +1,65 @@ +import unittest + +import mock + +from mopidy.models import Playlist, Ref, Track + +from mopidy_gmusic.playlists import GMusicPlaylistsProvider + +from tests.test_extension import ExtensionTest + + +class PlaylistsTest(unittest.TestCase): + + def setUp(self): + backend = mock.Mock() + backend.config = ExtensionTest.get_config() + self.provider = GMusicPlaylistsProvider(backend) + self.provider._playlists = { + 'gmusic:playlist:foo': Playlist( + uri='gmusic:playlist:foo', + name='foo', + tracks=[Track(uri='gmusic:track:test_track', name='test')]), + 'gmusic:playlist:boo': Playlist( + uri='gmusic:playlist:boo', name='boo', tracks=[]), + } + + def test_as_list(self): + result = self.provider.as_list() + + self.assertEqual(len(result), 2) + self.assertEqual( + result[0], Ref.playlist(uri='gmusic:playlist:boo', name='boo')) + self.assertEqual( + result[1], Ref.playlist(uri='gmusic:playlist:foo', name='foo')) + + def test_get_items(self): + result = self.provider.get_items('gmusic:playlist:foo') + + self.assertEqual(len(result), 1) + self.assertEqual( + result[0], Ref.track(uri='gmusic:track:test_track', name='test')) + + def test_get_items_for_unknown_playlist(self): + result = self.provider.get_items('gmusic:playlist:bar') + + self.assertIsNone(result) + + def test_create(self): + self.provider.create('foo') + + def test_delete(self): + self.provider.delete('gmusic:playlist:foo') + + def test_save(self): + self.provider.save(Playlist()) + + def test_lookup_valid(self): + result = self.provider.lookup('gmusic:playlist:foo') + + self.assertIsNotNone(result) + + def test_lookup_invalid(self): + result = self.provider.lookup('gmusic:playlist:bar') + + self.assertIsNone(result) diff --git a/tests/test_repeating_timer.py b/tests/test_repeating_timer.py new file mode 100644 index 0000000..33e9982 --- /dev/null +++ b/tests/test_repeating_timer.py @@ -0,0 +1,35 @@ +import logging + +import unittest + +from threading import Event + +from mopidy_gmusic.repeating_timer import RepeatingTimer + +logger = logging.getLogger(__name__) + + +class ExtensionTest(unittest.TestCase): + + def setUp(self): + self._event = Event() + + def _run_by_timer(self): + self._event.set() + logger.debug('Tick.') + + def test_init(self): + timer = RepeatingTimer(self._run_by_timer, 0.5) + timer.start() + self.assertTrue(self._event.wait(1), 'timer was not running') + self._event.clear() + self.assertTrue(self._event.wait(1), 'timer was not running') + timer.cancel() + + def test_stop(self): + timer = RepeatingTimer(self._run_by_timer, 10) + timer.start() + self.assertTrue(timer.isAlive(), 'timer is not running') + timer.cancel() + timer.join(1) + self.assertFalse(timer.isAlive(), 'timer is still alive') diff --git a/tests/test_scrobbler_frontend.py b/tests/test_scrobbler_frontend.py new file mode 100644 index 0000000..e7f0b5c --- /dev/null +++ b/tests/test_scrobbler_frontend.py @@ -0,0 +1,40 @@ +from __future__ import unicode_literals + +import mock + +from mopidy import models + +import pytest + +from mopidy_gmusic import scrobbler_frontend + + +@pytest.yield_fixture +def send_mock(): + patcher = mock.patch.object(scrobbler_frontend.listener, 'send') + yield patcher.start() + patcher.stop() + + +@pytest.fixture +def frontend(send_mock): + return scrobbler_frontend.GMusicScrobblerFrontend(config={}, core=None) + + +def test_aborts_if_less_than_half_is_played(frontend, send_mock): + track = models.Track(uri='gmusic:track:foo', length=60000) + tl_track = models.TlTrack(tlid=17, track=track) + + frontend.track_playback_ended(tl_track, 20000) + + assert send_mock.call_count == 0 + + +def test_scrobbles_if_more_than_half_is_played(frontend, send_mock): + track = models.Track(uri='gmusic:track:foo', length=60000) + tl_track = models.TlTrack(tlid=17, track=track) + + frontend.track_playback_ended(tl_track, 40000) + + send_mock.assert_called_once_with( + mock.ANY, 'increment_song_playcount', track_id='foo') diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..7427534 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,290 @@ +from __future__ import unicode_literals + +import gmusicapi + +import mock + +import pytest + +import requests + +from mopidy_gmusic import session as session_lib + + +@pytest.fixture +def offline_session(): + api_mock = mock.Mock(spec=gmusicapi.Mobileclient) + api_mock.is_authenticated.return_value = False + return session_lib.GMusicSession(all_access=True, api=api_mock) + + +@pytest.fixture +def online_session(): + api_mock = mock.Mock(spec=gmusicapi.Mobileclient) + api_mock.is_authenticated.return_value = True + return session_lib.GMusicSession(all_access=True, api=api_mock) + + +# TODO login + + +class TestLogout(object): + + def test_when_offline(self, offline_session): + assert offline_session.logout() is None + + assert offline_session.api.logout.call_count == 0 + + def test_when_online(self, online_session): + online_session.api.logout.return_value = mock.sentinel.rv + + assert online_session.logout() is mock.sentinel.rv + + online_session.api.logout.assert_called_once_with() + + def test_when_call_failure(self, online_session, caplog): + online_session.api.logout.side_effect = gmusicapi.CallFailure( + 'foo', 'bar') + + assert online_session.logout() is None + assert 'Call to Google Music failed' in caplog.text() + + def test_when_connection_error(self, online_session, caplog): + online_session.api.logout.side_effect = ( + requests.exceptions.ConnectionError) + + assert online_session.logout() is None + assert 'HTTP request to Google Music failed' in caplog.text() + + +class TestGetAllSongs(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_all_songs() == [] + + def test_when_online(self, online_session): + online_session.api.get_all_songs.return_value = mock.sentinel.rv + + assert online_session.get_all_songs() is mock.sentinel.rv + + online_session.api.get_all_songs.assert_called_once_with() + + +class TestGetStreamUrl(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_stream_url('abc') is None + + def test_when_online(self, online_session): + online_session.api.get_stream_url.return_value = mock.sentinel.rv + + assert online_session.get_stream_url('abc') is mock.sentinel.rv + + online_session.api.get_stream_url.assert_called_once_with( + 'abc', quality='hi') + + +class TestGetAllPlaylists(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_all_playlists() == [] + + def test_when_online(self, online_session): + online_session.api.get_all_playlists.return_value = ( + mock.sentinel.rv) + + assert online_session.get_all_playlists() is mock.sentinel.rv + + online_session.api.get_all_playlists.assert_called_once_with() + + +class TestGetAllUserPlaylistContents(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_all_user_playlist_contents() == [] + + def test_when_online(self, online_session): + online_session.api.get_all_user_playlist_contents.return_value = ( + mock.sentinel.rv) + + assert ( + online_session.get_all_user_playlist_contents() + is mock.sentinel.rv) + + (online_session.api.get_all_user_playlist_contents + .assert_called_once_with()) + + +class TestGetSharedPlaylistContents(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_shared_playlist_contents('token') == [] + + def test_when_online(self, online_session): + online_session.api.get_shared_playlist_contents.return_value = ( + mock.sentinel.rv) + + assert ( + online_session.get_shared_playlist_contents('token') + is mock.sentinel.rv) + + (online_session.api.get_shared_playlist_contents + .assert_called_once_with('token')) + + +class TestGetPromotedSongs(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_promoted_songs() == [] + + def test_when_online(self, online_session): + online_session.api.get_promoted_songs.return_value = ( + mock.sentinel.rv) + + assert online_session.get_promoted_songs() is mock.sentinel.rv + + online_session.api.get_promoted_songs.assert_called_once_with() + + +class TestGetTrackInfo(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_track_info('id') is None + + def test_when_online(self, online_session): + online_session.api.get_track_info.return_value = mock.sentinel.rv + + assert online_session.get_track_info('id') is mock.sentinel.rv + + online_session.api.get_track_info.assert_called_once_with('id') + + def test_without_all_access(self, online_session, caplog): + online_session.all_access = False + + assert online_session.get_track_info('id') is None + assert ( + 'Google Play Music All Access is required for get_track_info()' + in caplog.text()) + + +class TestGetAlbumInfo(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_album_info('id') is None + + def test_when_online(self, online_session): + online_session.api.get_album_info.return_value = mock.sentinel.rv + + result = online_session.get_album_info('id', include_tracks=False) + + assert result is mock.sentinel.rv + online_session.api.get_album_info.assert_called_once_with( + 'id', include_tracks=False) + + def test_without_all_access(self, online_session, caplog): + online_session.all_access = False + + assert online_session.get_album_info('id') is None + assert ( + 'Google Play Music All Access is required for get_album_info()' + in caplog.text()) + + +class TestGetArtistInfo(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_artist_info('id') is None + + def test_when_online(self, online_session): + online_session.api.get_artist_info.return_value = mock.sentinel.rv + + result = online_session.get_artist_info( + 'id', include_albums=False, max_rel_artist=3, max_top_tracks=4) + + assert result is mock.sentinel.rv + online_session.api.get_artist_info.assert_called_once_with( + 'id', include_albums=False, max_rel_artist=3, max_top_tracks=4) + + def test_without_all_access(self, online_session, caplog): + online_session.all_access = False + + assert online_session.get_artist_info('id') is None + assert ( + 'Google Play Music All Access is required for get_artist_info()' + in caplog.text()) + + +class TestSearchAllAccess(object): + + def test_when_offline(self, offline_session): + assert offline_session.search_all_access('abba') is None + + def test_when_online(self, online_session): + online_session.api.search_all_access.return_value = mock.sentinel.rv + + result = online_session.search_all_access('abba', max_results=10) + + assert result is mock.sentinel.rv + online_session.api.search_all_access.assert_called_once_with( + 'abba', max_results=10) + + def test_without_all_access(self, online_session, caplog): + online_session.all_access = False + + assert online_session.search_all_access('abba') is None + assert ( + 'Google Play Music All Access is required for search_all_access()' + in caplog.text()) + + +class TestGetAllStations(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_all_stations() == [] + + def test_when_online(self, online_session): + online_session.api.get_all_stations.return_value = mock.sentinel.rv + + assert online_session.get_all_stations() is mock.sentinel.rv + + online_session.api.get_all_stations.assert_called_once_with() + + +class TestGetStationTracks(object): + + def test_when_offline(self, offline_session): + assert offline_session.get_station_tracks('IFL') == [] + + def test_when_online(self, online_session): + online_session.api.get_station_tracks.return_value = mock.sentinel.rv + + result = online_session.get_station_tracks('IFL', num_tracks=5) + + assert result is mock.sentinel.rv + online_session.api.get_station_tracks.assert_called_once_with( + 'IFL', num_tracks=5) + + def test_without_all_access(self, online_session, caplog): + online_session.all_access = False + + assert online_session.get_station_tracks('IFL') == [] + assert ( + 'Google Play Music All Access is required for get_station_tracks()' + in caplog.text()) + + +class TestIncrementSongPlayCount(object): + + def test_when_offline(self, offline_session): + assert offline_session.increment_song_playcount('foo') is None + + def test_when_online(self, online_session): + online_session.api.increment_song_playcount.return_value = ( + mock.sentinel.rv) + + result = online_session.increment_song_playcount( + 'foo', plays=2, playtime=1000000000) + + assert result is mock.sentinel.rv + online_session.api.increment_song_playcount.assert_called_once_with( + 'foo', plays=2, playtime=1000000000) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..179ba77 --- /dev/null +++ b/tox.ini @@ -0,0 +1,27 @@ +[tox] +envlist = py27, flake8 + +[testenv] +sitepackages = true +# Deps: gmusicapi -> gpsoauth using PKCS1_OAEP -> pycrypto>=2.5 +deps = + mopidy==dev + pycrypto>=2.5 + pytest + pytest-capturelog + pytest-cov + pytest-xdist +install_command = pip install --allow-unverified=mopidy --pre {opts} {packages} +commands = + py.test \ + --basetemp={envtmpdir} \ + --junit-xml=xunit-{envname}.xml \ + --cov=mopidy_gmusic --cov-report=term-missing \ + {posargs} + +[testenv:flake8] +deps = + flake8 + flake8-import-order +skip_install = true +commands = flake8