From 3f0ad7742fd960c0f0818abea1283e2edee46318 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 3 Oct 2018 21:51:48 +0200 Subject: [PATCH] Working PoC with basic search and playback --- mopidy_funkwhale/__init__.py | 16 +- mopidy_funkwhale/actor.py | 45 +++++ mopidy_funkwhale/client.py | 89 +++++++++ mopidy_funkwhale/library.py | 183 ++++++++++++++++++ setup.cfg | 2 + tests/test_client.py | 42 ++++ ..._mopidy_funkwhale.py => test_extension.py} | 0 tests/test_library.py | 103 ++++++++++ 8 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 mopidy_funkwhale/actor.py create mode 100644 mopidy_funkwhale/client.py create mode 100644 mopidy_funkwhale/library.py create mode 100644 tests/test_client.py rename tests/{test_mopidy_funkwhale.py => test_extension.py} (100%) create mode 100644 tests/test_library.py diff --git a/mopidy_funkwhale/__init__.py b/mopidy_funkwhale/__init__.py index ad3c9ee..aba4d1b 100644 --- a/mopidy_funkwhale/__init__.py +++ b/mopidy_funkwhale/__init__.py @@ -28,10 +28,20 @@ class Extension(mopidy.ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['url'] = mopidy.config.String() - schema['username'] = mopidy.config.Secret(optional=True) + schema['username'] = mopidy.config.String(optional=True) schema['password'] = mopidy.config.Secret(optional=True) return schema + def validate_config(self, config): + if not config.getboolean('funkwhale', 'enabled'): + return + username = config.getstring('funkwhale', 'username') + password = config.getstring('funkwhale', 'password') + if any([username, password]) and not all([username, password]): + raise mopidy.ext.ExtensionError( + 'You need to provide username and password to authenticate with the funkwhale backend' + ) + def setup(self, registry): - from .backend import FoobarBackend - registry.add('backend', FoobarBackend) + from . import actor + registry.add('backend', actor.FunkwhaleBackend) diff --git a/mopidy_funkwhale/actor.py b/mopidy_funkwhale/actor.py new file mode 100644 index 0000000..000a3fb --- /dev/null +++ b/mopidy_funkwhale/actor.py @@ -0,0 +1,45 @@ +from __future__ import unicode_literals + +import logging + +from mopidy import backend + +import pykka + +from . import client +from . import library + + +logger = logging.getLogger(__name__) + + +class FunkwhaleBackend(pykka.ThreadingActor, backend.Backend): + + def __init__(self, config, audio): + super(FunkwhaleBackend, self).__init__() + self.config = config + self.remote = client.FunkwhaleClient(config) + self.library = library.FunkwhaleLibraryProvider(backend=self) + self.playback = FunkwhalePlaybackProvider(audio=audio, backend=self) + + self.uri_schemes = ['funkwhale', 'fw'] + + def on_start(self): + username = self.remote.username + if username is not None: + logger.info('Logged in to Funkwhale as "%s" on "%s"', username, self.config['funkwhale']['url']) + else: + logger.info('Using "%s" anonymously', self.config['funkwhale']['url']) + + +class FunkwhalePlaybackProvider(backend.PlaybackProvider): + def translate_uri(self, uri): + _, id = library.parse_uri(uri) + track = self.backend.remote.http_client.get_track(id) + + if track is None: + return None + url = track['listen_url'] + if url.startswith('/'): + url = self.backend.config['funkwhale']['url'] + url + return url diff --git a/mopidy_funkwhale/client.py b/mopidy_funkwhale/client.py new file mode 100644 index 0000000..226ad52 --- /dev/null +++ b/mopidy_funkwhale/client.py @@ -0,0 +1,89 @@ +from __future__ import unicode_literals + +import requests + +from mopidy import httpclient + +from . import Extension, __version__ + + +class SessionWithUrlBase(requests.Session): + # In Python 3 you could place `url_base` after `*args`, but not in Python 2. + def __init__(self, url_base=None, *args, **kwargs): + super(SessionWithUrlBase, self).__init__(*args, **kwargs) + self.url_base = url_base + + def request(self, method, url, **kwargs): + # Next line of code is here for example purposes only. + # You really shouldn't just use string concatenation here, + # take a look at urllib.parse.urljoin instead. + modified_url = self.url_base + url + + return super(SessionWithUrlBase, self).request(method, modified_url, **kwargs) + + +def get_requests_session(url, proxy_config, user_agent): + if not url.endswith('/'): + url += '/' + url += 'api/v1/' + + proxy = httpclient.format_proxy(proxy_config) + full_user_agent = httpclient.format_user_agent(user_agent) + + session = SessionWithUrlBase(url_base=url) + session.proxies.update({'http': proxy, 'https': proxy}) + session.headers.update({'user-agent': full_user_agent}) + + return session + + +def login(session, username, password): + response = session.post('token/', {'username': username, 'password': password}) + try: + response.raise_for_status() + except requests.exceptions.HTTPError: + raise BackendError('Authentication failed for user %s' % (username,)) + token = response.json()['token'] + session.headers.update({'Authorization': 'JWT %s' % (token,)}) + + +class APIClient(object): + def __init__(self, session): + self.session = session + + def search(self, query): + response = self.session.get('search', params={'query': query}) + response.raise_for_status() + return response.json() + + def get_track(self, id): + response = self.session.get('tracks/{}/'.format(id)) + response.raise_for_status() + return response.json() + + def list_tracks(self, filters): + response = self.session.get('tracks/', params=filters) + response.raise_for_status() + return response.json() + + +class FunkwhaleClient(object): + + def __init__(self, config): + super(FunkwhaleClient, self).__init__() + self.page_size = config['funkwhale'].get('page_size', 50) + self.http_client = APIClient( + session=get_requests_session( + config['funkwhale']['url'], + proxy_config=config['proxy'], + user_agent='%s/%s' % ( + Extension.dist_name, + __version__), + ) + ) + self.username = config['funkwhale']['username'] + if config['funkwhale']['username']: + login( + self.http_client.session, + config['funkwhale']['username'], + config['funkwhale']['password']) diff --git a/mopidy_funkwhale/library.py b/mopidy_funkwhale/library.py new file mode 100644 index 0000000..ad7d46e --- /dev/null +++ b/mopidy_funkwhale/library.py @@ -0,0 +1,183 @@ +from __future__ import unicode_literals + +import collections +import logging +import re + +from mopidy import backend, models + +logger = logging.getLogger(__name__) + + + +def simplify_search_query(query): + + if isinstance(query, dict): + r = [] + for v in query.values(): + if isinstance(v, list): + r.extend(v) + else: + r.append(v) + return ' '.join(r) + if isinstance(query, list): + return ' '.join(query) + else: + return query + + +class FunkwhaleLibraryProvider(backend.LibraryProvider): + root_directory = models.Ref.directory( + uri='funkwhale:directory', + name='Funkwhale' + ) + + def __init__(self, *args, **kwargs): + super(FunkwhaleLibraryProvider, self).__init__(*args, **kwargs) + self.vfs = {'funkwhale:directory': collections.OrderedDict()} + # self.add_to_vfs(new_folder('Favorites', ['favorites'])) + # self.add_to_vfs(new_folder('Following', ['following'])) + # self.add_to_vfs(new_folder('Sets', ['sets'])) + # self.add_to_vfs(new_folder('Stream', ['stream'])) + + def add_to_vfs(self, _model): + self.vfs['funkwhale:directory'][_model.uri] = _model + + def list_sets(self): + sets_vfs = collections.OrderedDict() + for (name, set_id, tracks) in self.backend.remote.get_sets(): + sets_list = new_folder(name, ['sets', set_id]) + logger.debug('Adding set %s to vfs' % sets_list.name) + sets_vfs[set_id] = sets_list + return sets_vfs.values() + + def list_favorites(self): + vfs_list = collections.OrderedDict() + for track in self.backend.remote.get_likes(): + logger.debug('Adding liked track %s to vfs' % track.name) + vfs_list[track.name] = models.Ref.track( + uri=track.uri, name=track.name) + return vfs_list.values() + + def list_user_follows(self): + sets_vfs = collections.OrderedDict() + for (name, user_id) in self.backend.remote.get_followings(): + sets_list = new_folder(name, ['following', user_id]) + logger.debug('Adding set %s to vfs' % sets_list.name) + sets_vfs[user_id] = sets_list + return sets_vfs.values() + + def tracklist_to_vfs(self, track_list): + vfs_list = collections.OrderedDict() + for temp_track in track_list: + if not isinstance(temp_track, Track): + temp_track = self.backend.remote.parse_track(temp_track) + if hasattr(temp_track, 'uri'): + vfs_list[temp_track.name] = models.Ref.track( + uri=temp_track.uri, + name=temp_track.name + ) + return vfs_list.values() + + def browse(self, uri): + if not self.vfs.get(uri): + (req_type, res_id) = re.match(r'.*:(\w*)(?:/(\d*))?', uri).groups() + + if 'favorites' == req_type: + return self.list_favorites() + + # root directory + return self.vfs.get(uri, {}).values() + + def search(self, query=None, uris=None, exact=False): + # TODO Support exact search + if not query: + return + + if 'uri' in query: + search_query = ''.join(query['uri']) + url = urlparse(search_query) + if 'soundcloud.com' in url.netloc: + logger.info('Resolving SoundCloud for: %s', search_query) + return SearchResult( + uri='soundcloud:search', + tracks=self.backend.remote.resolve_url(search_query) + ) + else: + search_query = simplify_search_query(query) + logger.info('Searching Funkwhale for: %s', search_query) + raw_results = self.backend.remote.http_client.search(search_query) + artists = [convert_to_artist(row) for row in raw_results['artists']] + albums = [convert_to_album(row) for row in raw_results['albums']] + tracks = [convert_to_track(row) for row in raw_results['tracks']] + + return models.SearchResult( + uri='funkwhale:search', + tracks=tracks, + albums=albums, + artists=artists, + ) + + def lookup(self, uri): + if 'fw:' in uri: + uri = uri.replace('fw:', '') + return self.backend.remote.resolve_url(uri) + + client = self.backend.remote.http_client + config = { + 'track': lambda id: [client.get_track(id)], + 'album': lambda id: client.list_tracks({'album': id})['results'], + 'artist': lambda id: client.list_tracks({'artist': id})['results'], + } + type, id = parse_uri(uri) + payload = config[type](id) + + return [convert_to_track(row) for row in payload] + + +def parse_uri(uri): + uri = uri.replace('funkwhale:', '') + parts = uri.split(':') + type = parts[0].rstrip('s') + id = int(parts[1]) + return type, id + + +def convert_to_artist(payload): + return models.Artist( + uri='funkwhale:artists:%s' % (payload['id'],), + name=payload['name'], + sortname=payload['name'], + musicbrainz_id=payload['mbid'], + ) + + +def convert_to_album(payload): + artist = convert_to_artist(payload['artist']) + image = payload['cover']['original'] if payload['cover'] else None + + return models.Album( + uri='funkwhale:albums:%s' % (payload['id'],), + name=payload['title'], + musicbrainz_id=payload['mbid'], + images=[image] if image else [], + artists=[artist], + date=payload['release_date'], + num_tracks=len(payload.get('tracks', [])), + ) + + +def convert_to_track(payload): + artist = convert_to_artist(payload['artist']) + album = convert_to_album(payload['album']) + return models.Track( + uri='funkwhale:tracks:%s' % (payload['id'],), + name=payload['title'], + musicbrainz_id=payload['mbid'], + artists=[artist], + album=album, + date=payload['album']['release_date'], + bitrate=(payload['bitrate'] or 0) / 1000, + length=(payload['duration'] or 0) * 1000, + track_no=payload['position'], + ) diff --git a/setup.cfg b/setup.cfg index fec88a2..9b84548 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,8 @@ mopidy.ext = test = pytest pytest-cov + requests-mock + pytest-mock dev = pygobject diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..1d04162 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,42 @@ +import pytest + +import mopidy_funkwhale.client + +FUNKWHALE_URL = 'https://test.funkwhale' +FUNKWHALE_API_URL = FUNKWHALE_URL + '/api/v1/' + + +@pytest.fixture() +def session(): + return mopidy_funkwhale.client.get_requests_session( + FUNKWHALE_URL, + {}, + 'test/something' + ) + + +@pytest.fixture() +def client(session): + return mopidy_funkwhale.client.APIClient(session) + + +def test_client_search(client, requests_mock): + requests_mock.get(FUNKWHALE_API_URL + 'search?query=myquery', json={'hello': 'world'}) + + result = client.search('myquery') + assert result == {'hello': 'world'} + + +def test_client_get_track(client, requests_mock): + requests_mock.get(FUNKWHALE_API_URL + 'tracks/12/', json={'hello': 'world'}) + + result = client.get_track(12) + assert result == {'hello': 'world'} + + + +def test_client_list_tracks(client, requests_mock): + requests_mock.get(FUNKWHALE_API_URL + 'tracks/?artist=12', json={'hello': 'world'}) + + result = client.list_tracks({'artist': 12}) + assert result == {'hello': 'world'} diff --git a/tests/test_mopidy_funkwhale.py b/tests/test_extension.py similarity index 100% rename from tests/test_mopidy_funkwhale.py rename to tests/test_extension.py diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..2ea4555 --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,103 @@ +import pytest +import uuid +from mopidy import models + +import mopidy_funkwhale.library + + +def test_convert_artist_to_model(): + payload = { + 'id': 42, + 'mbid': str(uuid.uuid4()), + 'name': "Test artist", + } + + result = mopidy_funkwhale.library.convert_to_artist(payload) + + assert type(result) == models.Artist + assert result.musicbrainz_id == payload['mbid'] + assert result.uri == 'funkwhale:artists:%s' % (payload['id'],) + assert result.name == payload['name'] + assert result.sortname == payload['name'] + + +def test_convert_album_to_model(): + payload = { + "id": 3, + "tracks": [1, 2, 3, 4], + "mbid": str(uuid.uuid4()), + "title": "Test album", + "artist": { + 'id': 42, + 'mbid': str(uuid.uuid4()), + 'name': "Test artist", + }, + "release_date": "2017-01-01", + "cover": { + "original": "/media/albums/covers/2018/10/03/b4e94b07e-da27-4df4-ae2a-d924a9448544.jpg" + }, + } + + result = mopidy_funkwhale.library.convert_to_album(payload) + + assert type(result) == models.Album + assert result.musicbrainz_id == payload['mbid'] + assert result.uri == 'funkwhale:albums:%s' % (payload['id'],) + assert result.name == payload['title'] + assert result.date == payload['release_date'] + assert result.num_tracks == len(payload['tracks']) + assert result.artists == frozenset([mopidy_funkwhale.library.convert_to_artist(payload['artist'])]) + assert result.images == frozenset([payload['cover']['original']]) + + +def test_convert_album_to_model(): + payload = { + "id": 2, + "title": 'Test track', + "mbid": str(uuid.uuid4()), + "creation_date": "2017-01-01", + "position": 12, + "bitrate": 128000, + "duration": 120, + "artist": { + 'id': 43, + 'mbid': str(uuid.uuid4()), + 'name': "Test artist 2", + }, + "album": { + "id": 3, + "tracks": [1, 2, 3, 4], + "mbid": str(uuid.uuid4()), + "title": "Test album", + "artist": { + 'id': 42, + 'mbid': str(uuid.uuid4()), + 'name': "Test artist", + }, + "release_date": "2017-01-01", + "cover": { + "original": "/media/albums/covers/2018/10/03/b4e94b07e-da27-4df4-ae2a-d924a9448544.jpg" + }, + } + } + + result = mopidy_funkwhale.library.convert_to_track(payload) + + assert type(result) == models.Track + assert result.musicbrainz_id == payload['mbid'] + assert result.uri == 'funkwhale:tracks:%s' % (payload['id'],) + assert result.name == payload['title'] + assert result.date == payload['album']['release_date'] + assert result.length == payload['duration'] * 1000 + assert result.bitrate == payload['bitrate'] / 1000 + + assert result.album == mopidy_funkwhale.library.convert_to_album(payload['album']) + assert result.artists == frozenset([mopidy_funkwhale.library.convert_to_artist(payload['artist'])]) + +@pytest.mark.parametrize('uri, expected', [ + ('funkwhale:albums:42', ('album', 42)), + ('funkwhale:tracks:42', ('track', 42)), + ('funkwhale:artists:42', ('artist', 42)), +]) +def test_parse_uri(uri, expected): + assert mopidy_funkwhale.library.parse_uri(uri) == expected