diff --git a/README.md b/README.md index 2a0f317..b2a7c86 100644 --- a/README.md +++ b/README.md @@ -19,5 +19,16 @@ by passing -r glib2 to twistd. - py.test -f - grunt watch-test +runs on port 8080 + +To use GoogleAPI you must add your email and password to the config.py +The user name and password must be valid google music accounts + +Example +------ +storage = GoogleMusicScanner('username', 'password') +If you have two factor auth this password needs to be added as an application password +The password needs to be added to your google account + Both py.test and grunt will stay running and retun the tests when the files change. Grunt will also redeploy the JS and CSS. diff --git a/jukebox.yaml.dist b/jukebox.yaml.dist index 80088fb..2414fa4 100644 --- a/jukebox.yaml.dist +++ b/jukebox.yaml.dist @@ -6,11 +6,16 @@ storage: &storage !!python/object/apply:jukebox.storage.MemoryStorage args: -scanner: +scanners: !!python/object/apply:jukebox.scanner.DirScanner args: - *storage - '/home/armooo/Music/' + !!python/object/apply:jukebox.scanner.GoogleMusicScanner + args: + - *storage + - 'username/email' + - 'password' encoder: !!python/name:jukebox.encoders.GSTEncoder diff --git a/jukebox/api.py b/jukebox/api.py index fb1fb2a..2b4d3a7 100644 --- a/jukebox/api.py +++ b/jukebox/api.py @@ -48,7 +48,7 @@ def get_playlist(self, request): @app.route('/playlist/add', methods=['POST']) @defer.inlineCallbacks def add_to_playlist(self, request): - pk = int(json.loads(request.content.getvalue())['pk']) + pk = json.loads(request.content.getvalue())['pk'] song = yield self.storage.get_song(pk) self.playlist.add_song(song) defer.returnValue('') diff --git a/jukebox/config.py b/jukebox/config.py index a8f9cf2..39efcb6 100644 --- a/jukebox/config.py +++ b/jukebox/config.py @@ -10,11 +10,11 @@ def make_root_resource(): storage = config['storage'] - scanner = config['scanner'] + scanners = config['scanners'] playlist = config['playlist'] encoder = config['encoder'] - - reactor.callInThread(scanner.scan) + for scanner in scanners: + reactor.callInThread(scanner.scan) api_server = API(storage, playlist) source = Source(playlist, encoder) httpd = HTTPd(api_server.app.resource(), Stream(source)) diff --git a/jukebox/encoders.py b/jukebox/encoders.py index 2e3fa76..4520974 100644 --- a/jukebox/encoders.py +++ b/jukebox/encoders.py @@ -55,18 +55,21 @@ class GSTEncoder(object): zope.interface.classProvides(IEncoder) def __init__(self, song, data_callback, done_callback): - import pygst - pygst.require('0.10') - import gst - self.song = song self.data_callback = data_callback self.done_callback = done_callback + self.url_callback = self.song.get_song_uri() + self.url_callback.addCallback(self.encode_callback) + def encode_callback(self, url): + import pygst + pygst.require('0.10') + import gst self.encoder = gst.Pipeline('encoder') decodebin = gst.element_factory_make('uridecodebin', 'uridecodebin') - decodebin.set_property('uri', self.song.uri) + + decodebin.set_property('uri', url) decodebin.connect('pad-added', self.on_new_pad) audioconvert = gst.element_factory_make('audioconvert', 'audioconvert') diff --git a/jukebox/scanner.py b/jukebox/scanner.py index 54ff814..6650e00 100644 --- a/jukebox/scanner.py +++ b/jukebox/scanner.py @@ -2,6 +2,8 @@ import zope.interface import mutagen +from gmusicapi import Mobileclient, Webclient + import jukebox.song @@ -23,6 +25,23 @@ def stop(): """ +class GoogleMusicScanner(object): + zope.interface.implements(IScanner) + + def __init__(self, storage, email, password): + self.storage = storage + self.email = email + self.password = password + + def scan(self): + wclient = Webclient() + wclient.login(self.email, self.password) + mobile_client = Mobileclient() + mobile_client.login(self.email, self.password) + for song in mobile_client.get_all_songs(): + self.storage.add_song(jukebox.song.GoogleSong(song, wclient)) + + class DirScanner(object): zope.interface.implements(IScanner) diff --git a/jukebox/song.py b/jukebox/song.py index df1aae2..ad2bc92 100644 --- a/jukebox/song.py +++ b/jukebox/song.py @@ -1,3 +1,7 @@ +from twisted.internet import threads +from twisted.internet.defer import Deferred, inlineCallbacks, returnValue + + class Song(object): def __init__(self, title, album, artist, uri): self.pk = None @@ -9,6 +13,35 @@ def __init__(self, title, album, artist, uri): def __repr__(self): return ''.format(self.pk, self.title) + @inlineCallbacks + def get_song_uri(self): + d = Deferred() + d.callback(self.uri) + uriYield = yield d + returnValue(uriYield) + +class GoogleSong(Song): + def __init__(self, google_song, web_api): + self.instance = google_song + self.web_api = web_api + self.title = google_song['title'] + self.album = google_song['album'] + self.artist = google_song['artist'] + self.pk = self.id = google_song['id'] + + def uri(self): + urls = self.web_api.get_stream_urls(self.id) + # Normal tracks return a single url, All Access tracks return multiple urls, + # which must be combined and are not supported + if len(urls) == 1: + return urls[0] + raise Exception(u'Bad Url') + + @inlineCallbacks + def get_song_uri(self): + url = yield threads.deferToThread(self.uri) + returnValue(url) + class Playlist(object): def __init__(self): diff --git a/jukebox/storage.py b/jukebox/storage.py index 00847b8..ad965e3 100644 --- a/jukebox/storage.py +++ b/jukebox/storage.py @@ -1,7 +1,19 @@ import itertools +from gmusicapi import Mobileclient, Webclient import zope.interface import twisted.internet.defer as defer +from twisted.internet.defer import ( + Deferred, + DeferredList, + inlineCallbacks, + returnValue +) +from jukebox.song import Song, GoogleSong + + +class GoogleAuthenticationError(Exception): + pass class IStorage(zope.interface.Interface): @@ -53,7 +65,7 @@ def get_song(self, pk): def add_song(self, song): d = defer.Deferred() - song.pk = next(self._seq) + song.pk = str(next(self._seq)) self._store[song.pk] = song d.callback(song.pk) return d diff --git a/setup.py b/setup.py index 98e64bb..12b5698 100644 --- a/setup.py +++ b/setup.py @@ -7,5 +7,5 @@ author='Jason Michalski', author_email='armooo@armooo.net', packages=find_packages(exclude=['tests']), - install_requires=['klein', 'mutagen', 'pyyaml',], + install_requires=['klein', 'mutagen', 'pyyaml', 'gmusicapi'], ) diff --git a/test/test_api.py b/test/test_api.py index 2b95354..0215b47 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -54,7 +54,7 @@ def test_few_songs(self): request = mock.Mock(name='request') storage = jukebox.storage.MemoryStorage() pks = [] - for i in range(3): + for i in range(1, 4): song = jukebox.song.Song( title='song %s' % i, album='album %s' % i, @@ -63,34 +63,40 @@ def test_few_songs(self): ) pk = yield storage.add_song(song) pks.append(pk) + api = jukebox.api.API(storage=storage) - result = yield api.all_songs(request) + feed_result = yield api.all_songs(request) request.setHeader.assert_called_with( 'Content-Type', 'application/json' ) - assert result == json.dumps({'songs': [ + result = json.loads(feed_result) + + self.assertTrue('songs' in result) + sorted_result = sorted(result['songs'], key=lambda song: song['pk']) + + assert sorted_result == [ { 'pk': pks[0], - 'title': 'song 0', - 'album': 'album 0', - 'artist': 'artist 0', - }, - { - 'pk': pks[1], 'title': 'song 1', 'album': 'album 1', 'artist': 'artist 1', }, { - 'pk': pks[2], + 'pk': pks[1], 'title': 'song 2', 'album': 'album 2', 'artist': 'artist 2', }, - ]}) + { + 'pk': pks[2], + 'title': 'song 3', + 'album': 'album 3', + 'artist': 'artist 3', + }, + ] def test_get_playlist_queue(): diff --git a/test/test_storage.py b/test/test_storage.py index daad364..af329e1 100644 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -5,9 +5,11 @@ import twisted.internet.defer as defer import jukebox.storage +from jukebox.storage import GoogleAuthenticationError import jukebox.song import util +from mock import patch, Mock class IStorage(object):