Working PoC with basic search and playback
parent
977647684b
commit
3f0ad7742f
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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'])
|
|
@ -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'],
|
||||
)
|
|
@ -31,6 +31,8 @@ mopidy.ext =
|
|||
test =
|
||||
pytest
|
||||
pytest-cov
|
||||
requests-mock
|
||||
pytest-mock
|
||||
|
||||
dev =
|
||||
pygobject
|
||||
|
|
|
@ -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'}
|
|
@ -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
|
Loading…
Reference in New Issue