From 0896957d1c4e8c8ab5e7b9969fb96ffeaa732425 Mon Sep 17 00:00:00 2001 From: Mike Milkin Date: Sun, 30 Mar 2014 12:31:29 -0400 Subject: [PATCH 1/5] Adding GoogleMusic Storage and GoogleMusic Song Moving open command to the Song since GoogleMusicSong is opened differently then FileSongs --- jukebox/song.py | 41 ++++++++++++++++++++++++++++ jukebox/storage.py | 46 ++++++++++++++++++++++++++++++- test/test_storage.py | 64 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/jukebox/song.py b/jukebox/song.py index df1aae2..b080f7c 100644 --- a/jukebox/song.py +++ b/jukebox/song.py @@ -1,3 +1,5 @@ +import urllib + class Song(object): def __init__(self, title, album, artist, uri): self.pk = None @@ -9,6 +11,45 @@ def __init__(self, title, album, artist, uri): def __repr__(self): return ''.format(self.pk, self.title) + def open(self): + return open(self.path) + + def stream(self, filesrc): + filesrc.set_property('location', self.path) + + +class GoogleSong(Song): + def __init__(self, google_song, web_api): + self.instance = google_song + self.web_api = web_api + super(GoogleSong, self).__init__( + google_song['title'], + google_song['album'], + google_song['artist'], + None + ) + self.path = None + self.pk = google_song['id'] + + def open(self): + return urllib.urlopen(self.get_uri()) + + def get_uri(self): + urls = self.web_api.get_stream_urls(self.pk) + # 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] + return None + + def stream(self, filesrc): + """ + Get the a stream to the first url for google song + """ + urls = self.web_api.get_stream_urls(self.pk) + if len(urls) == 0: + filesrc.set_property('uri', self.path) + class Playlist(object): def __init__(self): diff --git a/jukebox/storage.py b/jukebox/storage.py index 00847b8..c792f3e 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 @@ -67,3 +79,35 @@ def del_song(self, song): return d d.callback(None) return d + + +class GooglePlayStorage(MemoryStorage): + zope.interface.implements(IStorage) + + def __init__(self, username, password): + self._seq = itertools.count(1) + self.mobile_client = Mobileclient() + self.web_client = Webclient() + self.email = username + self.password = password + self._login() + self._store = {song.pk: song for song in self._load_songs()} + + def _login(self): + self.mobile_client.login(self.email, self.password) + self.web_client.login(self.email, self.password) + if not self.mobile_client.is_authenticated() or not self.web_client.is_authenticated(): + print 'raise the exception' + raise GoogleAuthenticationError + + def _load_songs(self): + if not self.mobile_client.is_authenticated(): + raise GoogleAuthenticationError + library = [GoogleSong(song, self.web_client) for song in self.mobile_client.get_all_songs()] + + return library + + def get_all_songs(self): + d = defer.Deferred() + d.callback(self._store.copy().itervalues()) + return d diff --git a/test/test_storage.py b/test/test_storage.py index daad364..2c0444a 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): @@ -92,3 +94,65 @@ def test_remove_song(self): class TestMemoryStorage(IStorage, TestCase): def make_storage(self): return jukebox.storage.MemoryStorage() + + +class TestGoogleMultiStorage(IStorage, TestCase): + + def setUp(self): + super(TestGoogleMultiStorage, self).setUp() + patch_mobile_client = patch('jukebox.storage.Mobileclient').start() + patch_web_client = patch('jukebox.storage.Webclient').start() + + self.mock_web_client = Mock(name='web_client') + self.mock_mobile_client = Mock(name='mobile_client') + self.mock_mobile_client.get_all_songs.return_value = [] + + patch_web_client.return_value = self.mock_web_client + patch_mobile_client.return_value = self.mock_mobile_client + + def tearDown(self): + super(TestGoogleMultiStorage, self).setUp() + patch.stopall() + + def make_storage(self): + return jukebox.storage.GooglePlayStorage('some', 'password') + + def test_called_login(self): + jukebox.storage.GooglePlayStorage('some', 'password') + self.mock_mobile_client.login.assert_called_with('some', 'password') + self.mock_web_client.login.assert_called_with('some', 'password') + self.assertTrue(self.mock_mobile_client.is_authenticated.called) + self.assertTrue(self.mock_web_client.is_authenticated.called) + + def test_web_auth_failed(self): + storage = jukebox.storage.GooglePlayStorage('some', 'password') + self.mock_web_client.is_authenticated.return_value = False + self.assertRaises(GoogleAuthenticationError, storage._login) + + def test_mobile_auth_failed(self): + storage = jukebox.storage.GooglePlayStorage('some', 'password') + self.mock_mobile_client.is_authenticated.return_value = False + self.assertRaises(GoogleAuthenticationError, storage._login) + + def test_load_all_songs_no_auth(self): + storage = jukebox.storage.GooglePlayStorage('some', 'password') + self.mock_mobile_client.is_authenticated.return_value = False + self.assertRaises(GoogleAuthenticationError, storage._load_songs) + + def test_load_all_songs(self): + self.mock_mobile_client.get_all_songs.return_value = [ + {'title': 'song 1', 'album': 'album', 'artist': 'some dude', 'id': '12345'} + ] + + storage = jukebox.storage.GooglePlayStorage('some', 'password') + + self.assertTrue(self.mock_mobile_client.get_all_songs.called) + self.assertEqual(len(storage._store), 1) + self.assertTrue('12345' in storage._store) + song = storage._store['12345'] + + self.assertEqual(song.title, 'song 1') + self.assertEqual(song.album, 'album') + self.assertEqual(song.artist, 'some dude') + self.assertEqual(song.pk, '12345') + self.assertEqual(song.web_api, storage.web_client) From 3bb23ba85c951292307727c82b57844e07a996e2 Mon Sep 17 00:00:00 2001 From: Mike Milkin Date: Sun, 30 Mar 2014 12:33:42 -0400 Subject: [PATCH 2/5] Adding google API to the encoder --- jukebox/api.py | 2 +- jukebox/encoders.py | 3 --- test/test_api.py | 28 +++++++++++++++++----------- 3 files changed, 18 insertions(+), 15 deletions(-) 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/encoders.py b/jukebox/encoders.py index 2e3fa76..b4cdadc 100644 --- a/jukebox/encoders.py +++ b/jukebox/encoders.py @@ -58,7 +58,6 @@ 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 @@ -73,7 +72,6 @@ def __init__(self, song, data_callback, done_callback): lame = gst.element_factory_make('lamemp3enc', 'lame') lame.set_property('quality', 1) - sink = gst.element_factory_make('appsink', 'appsink') sink.set_property('emit-signals', True) sink.set_property('blocksize', 1024 * 32) @@ -83,7 +81,6 @@ def __init__(self, song, data_callback, done_callback): gst.element_link_many(audioconvert, lame, sink) self.encoder.set_state(gst.STATE_PAUSED) - bus = self.encoder.get_bus() bus.add_signal_watch() bus.connect('message', self.on_message) 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(): From 75b2e3328d8fc1200b024cc02222abc1055c181b Mon Sep 17 00:00:00 2001 From: Mike Milkin Date: Sun, 30 Mar 2014 12:35:53 -0400 Subject: [PATCH 3/5] Adding gmusicapi to the settings as well as README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 2a0f317..d53c9e9 100644 --- a/README.md +++ b/README.md @@ -19,5 +19,14 @@ 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 = GooglePlayStorage('username', 'password') + Both py.test and grunt will stay running and retun the tests when the files change. Grunt will also redeploy the JS and CSS. From f47b4f42a0fc04ebf1a8250598cadcdf10b96a94 Mon Sep 17 00:00:00 2001 From: Mike Milkin Date: Mon, 10 Nov 2014 23:54:20 -0500 Subject: [PATCH 4/5] moving the API to scanner TODO: deffered URI --- README.md | 4 ++- jukebox.yaml.dist | 7 ++++- jukebox/config.py | 6 ++--- jukebox/encoders.py | 4 +++ jukebox/scanner.py | 19 ++++++++++++++ jukebox/song.py | 40 +++++++--------------------- jukebox/storage.py | 32 ----------------------- setup.py | 2 +- test/test_storage.py | 62 -------------------------------------------- 9 files changed, 45 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index d53c9e9..b2a7c86 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ The user name and password must be valid google music accounts Example ------ -storage = GooglePlayStorage('username', 'password') +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/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 b4cdadc..bae24e5 100644 --- a/jukebox/encoders.py +++ b/jukebox/encoders.py @@ -34,6 +34,7 @@ def __init__(self, song, data_callback, done_callback): self.song = song self.data_callback = data_callback self.done_callback = done_callback + import ipdb; ipdb.set_trace() self.file = urllib2.urlopen(self.song.uri) self.lc = LoopingCall(self.process_file) @@ -58,6 +59,7 @@ 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 @@ -72,6 +74,7 @@ def __init__(self, song, data_callback, done_callback): lame = gst.element_factory_make('lamemp3enc', 'lame') lame.set_property('quality', 1) + sink = gst.element_factory_make('appsink', 'appsink') sink.set_property('emit-signals', True) sink.set_property('blocksize', 1024 * 32) @@ -81,6 +84,7 @@ def __init__(self, song, data_callback, done_callback): gst.element_link_many(audioconvert, lame, sink) self.encoder.set_state(gst.STATE_PAUSED) + bus = self.encoder.get_bus() bus.add_signal_watch() bus.connect('message', self.on_message) 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 b080f7c..b564099 100644 --- a/jukebox/song.py +++ b/jukebox/song.py @@ -1,5 +1,3 @@ -import urllib - class Song(object): def __init__(self, title, album, artist, uri): self.pk = None @@ -11,44 +9,24 @@ def __init__(self, title, album, artist, uri): def __repr__(self): return ''.format(self.pk, self.title) - def open(self): - return open(self.path) - - def stream(self, filesrc): - filesrc.set_property('location', self.path) - class GoogleSong(Song): def __init__(self, google_song, web_api): self.instance = google_song self.web_api = web_api - super(GoogleSong, self).__init__( - google_song['title'], - google_song['album'], - google_song['artist'], - None - ) - self.path = None - self.pk = google_song['id'] - - def open(self): - return urllib.urlopen(self.get_uri()) - - def get_uri(self): - urls = self.web_api.get_stream_urls(self.pk) + self.title = google_song['title'] + self.album = google_song['album'] + self.artist = google_song['artist'] + self.pk = self.id = google_song['id'] + + @property + 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] - return None - - def stream(self, filesrc): - """ - Get the a stream to the first url for google song - """ - urls = self.web_api.get_stream_urls(self.pk) - if len(urls) == 0: - filesrc.set_property('uri', self.path) + raise Exception(u'Bad Url') class Playlist(object): diff --git a/jukebox/storage.py b/jukebox/storage.py index c792f3e..ad965e3 100644 --- a/jukebox/storage.py +++ b/jukebox/storage.py @@ -79,35 +79,3 @@ def del_song(self, song): return d d.callback(None) return d - - -class GooglePlayStorage(MemoryStorage): - zope.interface.implements(IStorage) - - def __init__(self, username, password): - self._seq = itertools.count(1) - self.mobile_client = Mobileclient() - self.web_client = Webclient() - self.email = username - self.password = password - self._login() - self._store = {song.pk: song for song in self._load_songs()} - - def _login(self): - self.mobile_client.login(self.email, self.password) - self.web_client.login(self.email, self.password) - if not self.mobile_client.is_authenticated() or not self.web_client.is_authenticated(): - print 'raise the exception' - raise GoogleAuthenticationError - - def _load_songs(self): - if not self.mobile_client.is_authenticated(): - raise GoogleAuthenticationError - library = [GoogleSong(song, self.web_client) for song in self.mobile_client.get_all_songs()] - - return library - - def get_all_songs(self): - d = defer.Deferred() - d.callback(self._store.copy().itervalues()) - 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_storage.py b/test/test_storage.py index 2c0444a..af329e1 100644 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -94,65 +94,3 @@ def test_remove_song(self): class TestMemoryStorage(IStorage, TestCase): def make_storage(self): return jukebox.storage.MemoryStorage() - - -class TestGoogleMultiStorage(IStorage, TestCase): - - def setUp(self): - super(TestGoogleMultiStorage, self).setUp() - patch_mobile_client = patch('jukebox.storage.Mobileclient').start() - patch_web_client = patch('jukebox.storage.Webclient').start() - - self.mock_web_client = Mock(name='web_client') - self.mock_mobile_client = Mock(name='mobile_client') - self.mock_mobile_client.get_all_songs.return_value = [] - - patch_web_client.return_value = self.mock_web_client - patch_mobile_client.return_value = self.mock_mobile_client - - def tearDown(self): - super(TestGoogleMultiStorage, self).setUp() - patch.stopall() - - def make_storage(self): - return jukebox.storage.GooglePlayStorage('some', 'password') - - def test_called_login(self): - jukebox.storage.GooglePlayStorage('some', 'password') - self.mock_mobile_client.login.assert_called_with('some', 'password') - self.mock_web_client.login.assert_called_with('some', 'password') - self.assertTrue(self.mock_mobile_client.is_authenticated.called) - self.assertTrue(self.mock_web_client.is_authenticated.called) - - def test_web_auth_failed(self): - storage = jukebox.storage.GooglePlayStorage('some', 'password') - self.mock_web_client.is_authenticated.return_value = False - self.assertRaises(GoogleAuthenticationError, storage._login) - - def test_mobile_auth_failed(self): - storage = jukebox.storage.GooglePlayStorage('some', 'password') - self.mock_mobile_client.is_authenticated.return_value = False - self.assertRaises(GoogleAuthenticationError, storage._login) - - def test_load_all_songs_no_auth(self): - storage = jukebox.storage.GooglePlayStorage('some', 'password') - self.mock_mobile_client.is_authenticated.return_value = False - self.assertRaises(GoogleAuthenticationError, storage._load_songs) - - def test_load_all_songs(self): - self.mock_mobile_client.get_all_songs.return_value = [ - {'title': 'song 1', 'album': 'album', 'artist': 'some dude', 'id': '12345'} - ] - - storage = jukebox.storage.GooglePlayStorage('some', 'password') - - self.assertTrue(self.mock_mobile_client.get_all_songs.called) - self.assertEqual(len(storage._store), 1) - self.assertTrue('12345' in storage._store) - song = storage._store['12345'] - - self.assertEqual(song.title, 'song 1') - self.assertEqual(song.album, 'album') - self.assertEqual(song.artist, 'some dude') - self.assertEqual(song.pk, '12345') - self.assertEqual(song.web_api, storage.web_client) From 2e477ae1b0cf78247b4ee3e118b63e0f0edfb0f3 Mon Sep 17 00:00:00 2001 From: Mike Milkin Date: Tue, 11 Nov 2014 18:29:35 -0500 Subject: [PATCH 5/5] adding deffered uri --- jukebox/encoders.py | 14 ++++++++------ jukebox/song.py | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/jukebox/encoders.py b/jukebox/encoders.py index bae24e5..4520974 100644 --- a/jukebox/encoders.py +++ b/jukebox/encoders.py @@ -34,7 +34,6 @@ def __init__(self, song, data_callback, done_callback): self.song = song self.data_callback = data_callback self.done_callback = done_callback - import ipdb; ipdb.set_trace() self.file = urllib2.urlopen(self.song.uri) self.lc = LoopingCall(self.process_file) @@ -56,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/song.py b/jukebox/song.py index b564099..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,12 @@ 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): @@ -19,7 +29,6 @@ def __init__(self, google_song, web_api): self.artist = google_song['artist'] self.pk = self.id = google_song['id'] - @property def uri(self): urls = self.web_api.get_stream_urls(self.id) # Normal tracks return a single url, All Access tracks return multiple urls, @@ -28,6 +37,11 @@ def uri(self): 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):