class _GP(object): """ Interface to :class:`gmusicapi.Mobileclient`. Implements asynchronous API calls, caching and some other perks. Singleton. """ # TODO: Switch to urwid signals for more explicitness? caches_invalidated = EventHook() def __init__(self): # self.is_debug = os.getenv('CLAY_DEBUG') self.mobile_client = Mobileclient() self.mobile_client._make_call = self._make_call_proxy( self.mobile_client._make_call) # if self.is_debug: # self.debug_file = open('/tmp/clay-api-log.json', 'w') # self._last_call_index = 0 self.cached_tracks = None self.cached_liked_songs = LikedSongs() self.cached_playlists = None self.cached_stations = None self.invalidate_caches() self.auth_state_changed = EventHook() def _make_call_proxy(self, func): """ Return a function that wraps *fn* and logs args & return values. """ def _make_call(protocol, *args, **kwargs): """ Wrapper function. """ logger.debug('GP::{}(*{}, **{})'.format(protocol.__name__, args, kwargs)) result = func(protocol, *args, **kwargs) # self._last_call_index += 1 # call_index = self._last_call_index # self.debug_file.write(json.dumps([ # call_index, # protocol.__name__, args, kwargs, # result # ]) + '\n') # self.debug_file.flush() return result return _make_call def invalidate_caches(self): """ Clear cached tracks & playlists & stations. """ self.cached_tracks = None self.cached_playlists = None self.cached_stations = None self.caches_invalidated.fire() @synchronized def login(self, email, password, device_id, **_): """ Log in into Google Play Music. """ self.mobile_client.logout() self.invalidate_caches() # prev_auth_state = self.is_authenticated result = self.mobile_client.login(email, password, device_id) # if prev_auth_state != self.is_authenticated: self.auth_state_changed.fire(self.is_authenticated) return result login_async = asynchronous(login) @synchronized def use_authtoken(self, authtoken, device_id): """ Try to use cached token to log into Google Play Music. """ # pylint: disable=protected-access self.mobile_client.session._authtoken = authtoken self.mobile_client.session.is_authenticated = True self.mobile_client.android_id = device_id del self.mobile_client.is_subscribed if self.mobile_client.is_subscribed: self.auth_state_changed.fire(True) return True del self.mobile_client.is_subscribed self.mobile_client.android_id = None self.mobile_client.session.is_authenticated = False self.auth_state_changed.fire(False) return False use_authtoken_async = asynchronous(use_authtoken) def get_authtoken(self): """ Return currently active auth token. """ # pylint: disable=protected-access return self.mobile_client.session._authtoken @synchronized def get_all_tracks(self): """ Cache and return all tracks from "My library". Each track will have "id" and "storeId" keys. """ if self.cached_tracks: return self.cached_tracks data = self.mobile_client.get_all_songs() self.cached_tracks = Track.from_data(data, Track.SOURCE_LIBRARY, True) return self.cached_tracks get_all_tracks_async = asynchronous(get_all_tracks) def get_stream_url(self, stream_id): """ Returns playable stream URL of track by id. """ return self.mobile_client.get_stream_url(stream_id) get_stream_url_async = asynchronous(get_stream_url) def increment_song_playcount(self, track_id): """ Increments the playcount of the song given by track_id by one. """ return gp.mobile_client.increment_song_playcount(track_id) increment_song_playcount_async = asynchronous(increment_song_playcount) @synchronized def get_all_user_station_contents(self, **_): """ Return list of :class:`.Station` instances. """ if self.cached_stations: return self.cached_stations self.get_all_tracks() self.cached_stations = Station.from_data( self.mobile_client.get_all_stations(), True) return self.cached_stations get_all_user_station_contents_async = ( # pylint: disable=invalid-name asynchronous(get_all_user_station_contents)) @synchronized def get_all_user_playlist_contents(self, **_): """ Return list of :class:`.Playlist` instances. """ if self.cached_playlists: return [self.cached_liked_songs] + self.cached_playlists self.get_all_tracks() self.cached_playlists = Playlist.from_data( self.mobile_client.get_all_user_playlist_contents(), True) return [self.cached_liked_songs] + self.cached_playlists get_all_user_playlist_contents_async = ( # pylint: disable=invalid-name asynchronous(get_all_user_playlist_contents)) def get_cached_tracks_map(self): """ Return a dictionary of tracks where keys are strings with track IDs and values are :class:`.Track` instances. """ return {track.id: track for track in self.cached_tracks} def get_track_by_id(self, any_id): """ Return track by id or store_id. """ for track in self.cached_tracks: if any_id in (track.library_id, track.store_id, track.playlist_item_id): return track return None def search(self, query): """ Find tracks and return an instance of :class:`.SearchResults`. """ results = self.mobile_client.search(query) return SearchResults.from_data(results) search_async = asynchronous(search) def add_to_my_library(self, track): """ Add a track to my library. """ result = self.mobile_client.add_store_tracks(track.id) if result: self.invalidate_caches() return result def remove_from_my_library(self, track): """ Remove a track from my library. """ result = self.mobile_client.delete_songs(track.id) if result: self.invalidate_caches() return result @property def is_authenticated(self): """ Return True if user is authenticated on Google Play Music, false otherwise. """ return self.mobile_client.is_authenticated() @property def is_subscribed(self): """ Return True if user is subscribed on Google Play Music, false otherwise. """ return self.mobile_client.is_subscribed
class _GP(object): """ Interface to :class:`gmusicapi.Mobileclient`. Implements asynchronous API calls, caching and some other perks. Singleton. """ # TODO: Switch to urwid signals for more explicitness? caches_invalidated = EventHook() parsed_songs = EventHook() def __init__(self): # self.is_debug = os.getenv('CLAY_DEBUG') self.mobile_client = Mobileclient() self.mobile_client._make_call = self._make_call_proxy( self.mobile_client._make_call) # if self.is_debug: # self.debug_file = open('/tmp/clay-api-log.json', 'w') # self._last_call_index = 0 self.cached_tracks = None self.cached_playlists = None self.cached_stations = None self.cached_artists = {} self.cached_albums = {} self.liked_songs = LikedSongs() self.invalidate_caches() self.auth_state_changed = EventHook() def _make_call_proxy(self, func): """ Return a function that wraps *fn* and logs args & return values. """ def _make_call(protocol, *args, **kwargs): """ Wrapper function. """ logger.debug('GP::{}(*{}, **{})'.format(protocol.__name__, args, kwargs)) result = func(protocol, *args, **kwargs) # self._last_call_index += 1 # call_index = self._last_call_index # self.debug_file.write(json.dumps([ # call_index, # protocol.__name__, args, kwargs, # result # ]) + '\n') # self.debug_file.flush() return result return _make_call def invalidate_caches(self): """ Clear cached tracks & playlists & stations. """ self.cached_tracks = None self.cached_playlists = None self.cached_stations = None self.cached_artist = None self.caches_invalidated.fire() @synchronized def login(self, email, password, device_id, **_): """ Log in into Google Play Music. """ self.mobile_client.logout() self.invalidate_caches() from os.path import exists CRED_FILE = "/home/thor/.config/clay/google_auth.cred" if not exists(CRED_FILE): from oauth2client.client import FlowExchangeError try: self.mobile_client.perform_oauth(CRED_FILE, open_browser=True) except FlowExchangeError: raise RuntimeError("OAuth authentication failed, try again") result = self.mobile_client.oauth_login( self.mobile_client.FROM_MAC_ADDRESS, CRED_FILE) self.auth_state_changed.fire(self.is_authenticated) return result login_async = asynchronous(login) @synchronized def get_artist_info(self, artist_id): """ Get the artist info """ return self.mobile_client.get_artist_info(artist_id, max_rel_artist=0, max_top_tracks=15) @synchronized def get_album_tracks(self, album_id): """ Get album tracks """ return self.mobile_client.get_album_info(album_id, include_tracks=True)['tracks'] @synchronized def add_album_song(self, id_, album_name, track): """ Adds an album to an artist and adds the specified track to it Args: id_ (`str`): the album ID (currently the same as the album title) album_name (`str`): the name of the album track (`clay.gp.Track`): the track in the album """ if album_name == '': id_ = track.artist album_name = "Unknown Album" if id_ not in self.cached_albums: self.cached_albums[id_] = Album(track.album_artist, { 'albumId': id_, 'name': album_name }) self.cached_albums[id_].add_track(track) return self.cached_albums[id_] @synchronized def add_artist(self, artist_id, name): """ Creates or lookup an artist object and return it. Args: artist_id (`str`): The Artist id given by Google Play Music Returns: The artist class """ name = ("Unknown Artist" if name == '' else name) lname = name.lower() if lname not in self.cached_artists: self.cached_artists[lname] = Artist(artist_id, name) return self.cached_artists[lname] @synchronized def use_authtoken(self, authtoken, device_id): """ Try to use cached token to log into Google Play Music. """ self.mobile_client.session._authtoken = authtoken self.mobile_client.session.is_authenticated = True self.mobile_client.android_id = device_id del self.mobile_client.is_subscribed if self.mobile_client.is_subscribed: self.auth_state_changed.fire(True) return True del self.mobile_client.is_subscribed self.mobile_client.android_id = None self.mobile_client.session.is_authenticated = False self.auth_state_changed.fire(False) return False use_authtoken_async = asynchronous(use_authtoken) def get_authtoken(self): """ Return currently active auth token. """ return self.mobile_client.session._authtoken @synchronized def get_all_tracks(self): """ Cache and return all tracks from "My library". Each track will have "id" and "storeId" keys. """ if self.cached_tracks: return self.cached_tracks data = self.mobile_client.get_all_songs() self.cached_tracks = Track.from_data(data, Source.library, True) self.parsed_songs.fire() return self.cached_tracks get_all_tracks_async = asynchronous(get_all_tracks) def get_stream_url(self, stream_id): """ Returns playable stream URL of track by id. """ return self.mobile_client.get_stream_url(stream_id) get_stream_url_async = asynchronous(get_stream_url) def increment_song_playcount(self, track_id): """ increments the playcount of a song with a given `track_id` by one Args: track_id (`int`): The track id of the song to increment the playcount Returns: Nothing """ gp.mobile_client.increment_song_playcount(track_id) increment_song_playcount_async = asynchronous(increment_song_playcount) @synchronized def get_all_user_station_contents(self, **_): """ Return list of :class:`.Station` instances. """ if self.cached_stations: return self.cached_stations self.get_all_tracks() self.cached_stations = Station.from_data( self.mobile_client.get_all_stations(), True) self.cached_stations.insert(0, IFLStation()) return self.cached_stations get_all_user_station_contents_async = ( asynchronous(get_all_user_station_contents)) @synchronized def get_all_user_playlist_contents(self, **_): """ Return list of :class:`.Playlist` instances. """ if self.cached_playlists: return self.cached_playlists self.get_all_tracks() self.cached_playlists = Playlist.from_data( self.mobile_client.get_all_user_playlist_contents(), True) self.refresh_liked_songs() self.cached_playlists.insert(0, self.liked_songs) return self.cached_playlists get_all_user_playlist_contents_async = ( asynchronous(get_all_user_playlist_contents)) def refresh_liked_songs(self, **_): """ Refresh the liked songs playlist """ self.liked_songs.refresh_tracks(self.mobile_client.get_top_songs()) refresh_liked_songs_async = asynchronous(refresh_liked_songs) def get_cached_tracks_map(self): """ Return a dictionary of tracks where keys are strings with track IDs and values are :class:`.Track` instances. """ return {track.id: track for track in self.cached_tracks} def get_track_by_id(self, any_id): """ Return track by id or store_id. """ for track in self.cached_tracks: if any_id in (track.id_, track.nid, track.store_id): return track return None def search(self, query): """ Find tracks and return an instance of :class:`.SearchResults`. """ results = self.mobile_client.search(query) return SearchResults.from_data(results) search_async = asynchronous(search) def add_to_my_library(self, track): """ Add a track to my library. """ result = self.mobile_client.add_store_tracks(track.id) if result: self.invalidate_caches() return result def remove_from_my_library(self, track): """ Remove a track from my library. """ result = self.mobile_client.delete_songs(track.id) if result: self.invalidate_caches() return result @property def is_authenticated(self): """ Return True if user is authenticated on Google Play Music, false otherwise. """ return self.mobile_client.is_authenticated() @property def is_subscribed(self): """ Return True if user is subscribed on Google Play Music, false otherwise. """ return self.mobile_client.is_subscribed
class MusicProviderGoogle(MusicproviderBase): music_service = 'gmusic' gmusic_client_id = gmusic_client_id api = None station_current = None # current station station_recently_played = None # list of tracks already seen from current station station_current_tracks = None # list of tracks got last time from station station_current_unseen = None # count of tracks in current tracks list that were unseen in this playback session station_now_playing = None # index of 'currently playing' track (points to track returned by last station_get_next_track call) def login(self): # log in to google music suppressing it's debugging messages oldloglevel = logging.getLogger().level logging.getLogger().setLevel(logging.ERROR) self.api = Mobileclient(debug_logging=False) logging.getLogger("gmusicapi.Mobileclient1").setLevel(logging.WARNING) rv = self.api.oauth_login(self.gmusic_client_id) logging.getLogger().setLevel(oldloglevel) if not rv: raise ExBpmCrawlGeneric( f"Please login to Google Music first (run gmusic-login.py)") def get_playlist(self, playlist_id_uri_name): if re.match('^https?://', playlist_id_uri_name, re.I): # this is uri of shared playlist try: self.api.get_shared_playlist_contents( urllib.parse.unquote(playlist_id_uri_name)) except Exception as e: debug(f"{self.whoami()}: got exception:", exc_info=True) raise ExBpmCrawlGeneric( f"failed to get shared playlist: {e}, enable debug for more" ) # find previously created playlist with specified name playlists = self.api.get_all_user_playlist_contents() playlist = None for pl in playlists: if pl["name"] == playlist_id_uri_name: playlist = pl break return playlist def get_or_create_my_playlist(self, playlist_name): playlist = self.get_playlist(playlist_name) if not playlist: debug( f"{whoami}: playlist {playlist_name} not found, creating it..." ) id = self.api.create_playlist(playlist_name) debug(f"{whoami}: created playlist, id {id}") playlists = self.api.get_all_playlists() for pl in playlists: if pl["id"] == id: playlist = pl break if not playlist: raise ExBpmCrawlGeneric( f"Failed to find or create playlist {playlist_name}") return playlist def get_playlist_tracks(self, playlist): # get track ids of playlist tracks_in_playlist = [] if "tracks" in playlist: for track in playlist["tracks"]: if "track" in track: # it is google play music's track, not some local crap tracks_in_playlist.append(track["track"]) return tracks_in_playlist def add_track_to_playlist(self, playlist, track_id): added = self.api.add_songs_to_playlist(playlist["id"], track_id) if len(added): return True else: return False def get_station_from_url(self, url): if url == "IFL": return {"id": "IFL", "name": "I'm Feeling Lucky"} station_id_str = urllib.parse.unquote(url).rsplit("/", 1)[-1].split( "?", 1)[0].split('#', 1)[0] stations = self.api.get_all_stations() for station in stations: if 'seed' in station and 'curatedStationId' in station['seed']: if station['seed']['curatedStationId'] == station_id_str: debug(f"{whoami()}: found station {station['id']}") return station raise ExBpmCrawlGeneric( f"Failed to find station by string '{station_id_str}' (from url '{url}')" ) def get_station_name(self, station): return station['name'] def station_prepare(self, station): self.station_recently_played = [] self.station_current_tracks = None self.station_current_unseen = 0 self.station_now_playing = None self.station_current = station def station_get_next_track(self): need_new_tracks = False if self.station_current_tracks is None: need_new_tracks = True else: while not need_new_tracks: self.station_now_playing += 1 if self.station_now_playing >= len( self.station_current_tracks): if self.station_current_unseen == 0: # all played tracks already seen, so probably we've seen all tracks of station, let's stop it debug( f"{self.whoami()}: all played tracks were already seen, stopping this playback cycle" ) return None else: need_new_tracks = True else: track = self.station_current_tracks[ self.station_now_playing] track_id = self.get_track_id(track) if track_id in self.station_recently_played: # try to get next track debug( f"{self.whoami()}: (level 1) skipping already seen track {track_id}" ) continue else: self.station_current_unseen += 1 self.station_recently_played.append(track_id) debug( f"{self.whoami()}: (level 1) returning next track {track_id}, station_current_unseen=={self.station_current_unseen}" ) return track # here we are only if we need more tracks from station debug(f"{self.whoami()}: getting new set of tracks") self.station_current_tracks = self.api.get_station_tracks( self.station_current['id'], num_tracks=25, recently_played_ids=self.station_recently_played) self.station_current_unseen = 0 self.station_now_playing = 0 if not self.station_current_tracks or len( self.station_current_tracks) == 0: debug( f"{self.whoami()}: got no tracks, stopping this playback cycle" ) return None debug( f"{self.whoami()}: got {len(self.station_current_tracks)} tracks") while not (self.station_now_playing >= len( self.station_current_tracks)): track = self.station_current_tracks[self.station_now_playing] track_id = self.get_track_id(track) if track_id in self.station_recently_played: # try to get next track debug( f"{self.whoami()}: (level 2) skipping already seen track {track_id}" ) self.station_now_playing += 1 continue else: self.station_current_unseen += 1 self.station_recently_played.append(track_id) debug( f"{self.whoami()}: (level 2) returning next track {track_id}, station_current_unseen=={self.station_current_unseen}" ) return track debug( f"{self.whoami()}: (level 2) reached end of list, stopping this playback cycle" ) return None def get_track_id(self, track): return track['storeId'] def download_track(self, track): file = tempfile.NamedTemporaryFile(mode='w+b', dir=temp_dir, prefix='track', suffix='.mp3') stream_url = self.api.get_stream_url(self.get_track_id(track), quality='low') with requests.get(stream_url, stream=True) as r: r.raise_for_status() for chunk in r.iter_content(chunk_size=8192): # If you have chunk encoded response uncomment if # and set chunk_size parameter to None. # if chunk: file.write(chunk) file.flush() return file def get_track_id(self, track): return track['storeId'] def calc_bpm_histogram(self, track): file = self.download_track(track) debug( f"{self.whoami()}: got track {self.get_track_id(track)} to {file.name}" ) histogram = calc_file_bpm_histogram(file.name) file.close() return histogram
class PlaylistSync: def __init__(self, root, playlist_name): self.root = root self.playlist_name = playlist_name def _login_mc(self): APP_NAME = 'gmusic-sync-playlist' CONFIG_FILE = 'auth.cfg' config = SafeConfigParser({ 'username': '', 'device_id': '' }) config.read(CONFIG_FILE) if not config.has_section('auth'): config.add_section('auth') username = config.get('auth','username') password = None if username != '': password = keyring.get_password(APP_NAME, username) if password == None or not self.mc.login(username, password): while 1: username = raw_input("Username: "******"Password: "******"Sign-on failed." config.set('auth', 'username', username) with open(CONFIG_FILE, 'wb') as f: config.write(f) keyring.set_password(APP_NAME, username, password) device_id = config.get('auth', 'device_id') if device_id == '': wc = Webclient() if not wc.login(username, password): raise Exception('could not log in via Webclient') devices = wc.get_registered_devices() mobile_devices = [d for d in devices if d[u'type'] in (u'PHONE', u'IOS')] if len(mobile_devices) < 1: raise Exception('could not find any registered mobile devices') device_id = mobile_devices[0][u'id'] if device_id.startswith(u'0x'): device_id = device_id[2:] config.set('auth', 'device_id', device_id) with open(CONFIG_FILE, 'wb') as f: config.write(f) print('Device ID: {}'.format(device_id)) self.mc.device_id = device_id def login(self): self.mc = Mobileclient() self._login_mc() self.mm = Musicmanager() #self.mm.perform_oauth() self.mm.login() def track_file_name(self, track): if 'albumArtist' in track: albumartist = track['albumArtist'] else: albumartist = 'Various' if not albumartist: albumartist = 'Various' file_name = escape_path(u'{trackNumber:02d} {title}.mp3'.format(**track)) if track.get('totalDiscCount', 1) > 1: file_name = u'{discNumber}-'.format(**track) + file_name return unicodedata.normalize('NFD', os.path.join(self.root, escape_path(albumartist), escape_path(track['album']), file_name)) def get_local_tracks(self): # return (metadata, file_name) of all files in root tracks = [] for root, dirs, files in os.walk(self.root): for f in files: if os.path.splitext(f)[1].lower() == '.mp3': file_name = os.path.join(root, f) #id3 = EasyID3(file_name) track = {} #track = { # 'name': id3['title'], # 'album': id3['album'], # 'track': id3['tracknumber'], # 'disc': id3['discnumber'] #} yield unicodedata.normalize('NFD', file_name.decode('utf-8')), track def get_playlist_tracks(self): # return (metadata, local_file_name) for each track in playlist all_playlists = self.mc.get_all_playlists() try: playlist = next(p for p in all_playlists if p['name'] == self.playlist_name) except StopIteration: raise Exception('playlist "{0}" not found'.format(self.playlist_name)) contents = self.mc.get_shared_playlist_contents(playlist['shareToken']) for t in contents: track = t[u'track'] #pprint(track) #raw_input() yield (self.track_file_name(track), track) #for p in all_playlists: # shared = self.mc.get_shared_playlist_contents(p['shareToken']) # pprint(shared) #for p in self.mc.get_all_user_playlist_contents(): # del p['tracks'] # pprint(p) # raw_input() return all_songs = self.mc.get_all_songs() pprint(all_songs[0]) for p in self.mc.get_all_user_playlist_contents(): if p['name'] == self.playlist_name: for track in p['tracks']: song = next(s for s in all_songs if s['id'] == track['trackId']) print(u'{album} - {title}'.format(**song)) #pprint(song) yield self.track_file_name(song), song def add_track(self, track, file_name): # download track from gmusic, write to file_name if not os.path.exists(os.path.dirname(file_name)): os.makedirs(os.path.dirname(file_name)) if track[u'kind'] != u'sj#track' or u'id' not in track: if u'id' not in track: track[u'id'] = track[u'storeId'] url = self.mc.get_stream_url(track[u'id'], self.mc.device_id) r = requests.get(url) data = r.content #data = self.wc.get_stream_audio(track['id']) with open(file_name, 'wb') as f: f.write(data) _copy_track_metadata(file_name, track) else: fn, audio = self.mm.download_song(track['id']) with open(file_name, 'wb') as f: f.write(audio) def remove_track(self, file_name): """Removes the track and walks up the tree deleting empty folders """ os.remove(file_name) rel = os.path.relpath(file_name, self.root) dirs = os.path.split(rel)[0:-1] for i in xrange(1, len(dirs) + 1): dir_path = os.path.join(self.root, *dirs[0:i]) if not os.listdir(dir_path): os.unlink(dir_path) def sync(self, confirm=True, remove=False): print 'Searching for local tracks ...' local = dict(self.get_local_tracks()) print 'Getting playlist ...' playlist = OrderedDict(self.get_playlist_tracks()) to_add = [] to_remove = [] to_rename = [] for file_name, track in playlist.iteritems(): if file_name not in local and file_name.encode('ascii', 'replace').replace('?','_') not in local: to_add.append((track, file_name)) elif file_name not in local and file_name.encode('ascii', 'replace').replace('?','_') in local: to_rename.append((file_name.encode('ascii', 'replace').replace('?','_'), file_name)) if remove: for file_name, track in sorted(local.iteritems()): if file_name not in playlist: to_remove.append((track, file_name)) if to_remove: print 'Deleting tracks:' for track, file_name in to_remove: print ' ' + file_name print '' if to_add: to_add = list(reversed(to_add)) print 'Adding tracks:' for track, file_name in to_add: print ' ' + file_name print '' if to_rename: print 'Renaming tracks:' for src, dst in to_rename: print ' {0} to {1}'.format(src, dst) print '' if not (to_add or to_remove): print 'Nothing to do.' print '' if confirm: raw_input('Press enter to proceed') for src, dst in to_rename: if not os.path.exists(os.path.dirname(dst)): os.makedirs(os.path.dirname(dst)) shutil.move(src, dst) for track, file_name in to_remove: print 'removing track ' + file_name self.remove_track(file_name) for track, file_name in to_add: print u'adding track: {album} / \n {title}'.format(**track).encode('utf-8', 'replace') self.add_track(track, file_name)