Commit 2fe6b42b authored by Agate's avatar Agate 💬
Browse files

Merge branch 'browse' into 'master'

Browse artists and favorites

See merge request !3
parents 8d51087d 6a3f0949
Pipeline #2193 passed with stage
in 56 seconds
......@@ -15,6 +15,8 @@ Features
--------
* Searching for tracks, albums and artists available in your Funkwhale instance
* Browse all artists and albums
* Browse your favorites
* Simple configuration
Installation
......@@ -47,15 +49,19 @@ To enable the extension, add the following to your ``mopidy.conf`` file::
username = demo
# Password to use when authenticating (leave empty fo anonymous access)
password = demo
# duration of cache entries before they are removed, in seconds
# 0 to cache forever, empty to disable cache
cache_duration = 600
Of course, replace the demo values with your actual info (but you can
try using the demo server).
After that, reload your mopidy daemon, and you should be good!
Todo
----
- Browse Funkwhale library and playlists
- Browse use library and playlists
.. _Mopidy: https://www.mopidy.com/
......
......@@ -9,39 +9,41 @@ import os
__author__ = """Eliot Berriot"""
__email__ = 'contact+funkwhale@eliotberriot.com'
__version__ = '0.1.0'
__email__ = "contact+funkwhale@eliotberriot.com"
__version__ = "0.1.0"
logger = logging.getLogger(__name__)
class Extension(mopidy.ext.Extension):
dist_name = 'Mopidy-Funkwhale'
ext_name = 'funkwhale'
dist_name = "Mopidy-Funkwhale"
ext_name = "funkwhale"
version = __version__
def get_default_config(self):
conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf')
conf_file = os.path.join(os.path.dirname(__file__), "ext.conf")
return mopidy.config.read(conf_file)
def get_config_schema(self):
schema = super(Extension, self).get_config_schema()
schema['url'] = mopidy.config.String()
schema['username'] = mopidy.config.String(optional=True)
schema['password'] = mopidy.config.Secret(optional=True)
schema["url"] = mopidy.config.String()
schema["username"] = mopidy.config.String(optional=True)
schema["password"] = mopidy.config.Secret(optional=True)
schema["cache_duration"] = mopidy.config.Integer(optional=True)
return schema
def validate_config(self, config):
if not config.getboolean('funkwhale', 'enabled'):
if not config.getboolean("funkwhale", "enabled"):
return
username = config.getstring('funkwhale', 'username')
password = config.getstring('funkwhale', 'password')
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'
"You need to provide username and password to authenticate with the funkwhale backend"
)
def setup(self, registry):
from . import actor
registry.add('backend', actor.FunkwhaleBackend)
registry.add("backend", actor.FunkwhaleBackend)
......@@ -14,34 +14,37 @@ 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.client = client.APIClient(config)
self.library = library.FunkwhaleLibraryProvider(backend=self)
self.playback = FunkwhalePlaybackProvider(audio=audio, backend=self)
self.uri_schemes = ['funkwhale', 'fw']
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'])
if self.client.username is not None:
self.client.login()
logger.info(
'Logged in to Funkwhale as "%s" on "%s"',
self.client.username,
self.config["funkwhale"]["url"],
)
else:
logger.info('Using "%s" anonymously', self.config['funkwhale']['url'])
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)
track = self.backend.client.get_track(id)
if track is None:
return None
url = track['listen_url']
if url.startswith('/'):
url = self.backend.config['funkwhale']['url'] + url
if self.backend.remote.token:
url += '?jwt=' + self.backend.remote.token
url = track["listen_url"]
if url.startswith("/"):
url = self.backend.config["funkwhale"]["url"] + url
if self.backend.client.token:
url += "?jwt=" + self.backend.client.token
return url
from __future__ import unicode_literals
import logging
import requests
from mopidy import httpclient, exceptions
from . import Extension, __version__
logger = logging.getLogger(__name__)
class SessionWithUrlBase(requests.Session):
# In Python 3 you could place `url_base` after `*args`, but not in Python 2.
......@@ -17,74 +20,103 @@ class SessionWithUrlBase(requests.Session):
# 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
if url.startswith("http://") or url.startswith("https://"):
modified_url = url
else:
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/'
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})
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})
if not username:
return
response = session.post("token/", {"username": username, "password": password})
try:
response.raise_for_status()
except requests.exceptions.HTTPError:
raise exceptions.BackendError('Authentication failed for user %s' % (username,))
token = response.json()['token']
session.headers.update({'Authorization': 'JWT %s' % (token,)})
raise exceptions.BackendError("Authentication failed for user %s" % (username,))
token = response.json()["token"]
session.headers.update({"Authorization": "JWT %s" % (token,)})
return token
class APIClient(object):
def __init__(self, session):
self.session = session
def __init__(self, config):
self.config = config
self.session = get_requests_session(
config["funkwhale"]["url"],
proxy_config=config["proxy"],
user_agent="%s/%s" % (Extension.dist_name, __version__),
)
self.username = self.config["funkwhale"]["username"]
self.token = None
def login(self):
self.username = self.config["funkwhale"]["username"]
if self.username:
self.token = login(
self.session,
self.config["funkwhale"]["username"],
self.config["funkwhale"]["password"],
)
else:
self.token = None
def search(self, query):
response = self.session.get('search', params={'query': 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 = 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 = self.session.get("tracks/", params=filters)
response.raise_for_status()
return response.json()
def list_artists(self, filters):
response = self.session.get("artists/", params=filters)
response.raise_for_status()
return response.json()
class FunkwhaleClient(object):
def list_albums(self, filters):
response = self.session.get("albums/", params=filters)
response.raise_for_status()
return response.json()
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']
self.token = None
if config['funkwhale']['username']:
self.token = login(
self.http_client.session,
config['funkwhale']['username'],
config['funkwhale']['password'])
def load_all(self, first_page, max=0):
for i in first_page["results"]:
yield i
next_page = first_page.get("next")
counter = 0
while next_page:
logger.info("Fetching next page of result at url: %s", next_page)
response = self.session.get(next_page)
response.raise_for_status()
payload = response.json()
for i in payload["results"]:
yield i
counter += 1
next_page = payload.get("next")
if max and counter >= max:
next_page = None
......@@ -6,3 +6,6 @@ url = https://demo.funkwhale.audio
username = demo
# Password to use when authenticating (leave empty fo anonymous access)
password = demo
# duration of cache entries before they are removed, in seconds
# 0 to cache forever, empty to disable cache
cache_duration = 600
......@@ -3,12 +3,21 @@ from __future__ import unicode_literals
import collections
import logging
import re
import time
import urllib
from mopidy import backend, models
logger = logging.getLogger(__name__)
def generate_uri(path):
return "funkwhale:directory:%s" % path
def new_folder(name, path):
return models.Ref.directory(uri=generate_uri(path), name=name)
def simplify_search_query(query):
......@@ -19,165 +28,294 @@ def simplify_search_query(query):
r.extend(v)
else:
r.append(v)
return ' '.join(r)
return " ".join(r)
if isinstance(query, list):
return ' '.join(query)
return " ".join(query)
else:
return query
class Cache(collections.OrderedDict):
def __init__(self, max_age=0):
self.max_age = max_age
super(Cache, self).__init__()
def set(self, key, value):
if self.max_age is None:
return
now = time.time()
self[key] = (now, value)
def get(self, key):
if self.max_age is None:
return
value = super(Cache, self).get(key)
if value is None:
return
now = time.time()
t, v = value
if self.max_age and t + self.max_age < now:
# entry is too old, we delete it
del self[key]
return None
return v
class FunkwhaleLibraryProvider(backend.LibraryProvider):
root_directory = models.Ref.directory(
uri='funkwhale:directory',
name='Funkwhale'
)
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.vfs = {"funkwhale:directory": collections.OrderedDict()}
self.add_to_vfs(new_folder("Favorites", "favorites"))
self.add_to_vfs(new_folder("Artists", "artists"))
# self.add_to_vfs(new_folder('Following', ['following']))
# self.add_to_vfs(new_folder('Sets', ['sets']))
# self.add_to_vfs(new_folder('Stream', ['stream']))
self.cache = Cache(max_age=self.backend.config["funkwhale"]["cache_duration"])
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()
self.vfs["funkwhale:directory"][_model.uri] = _model
def browse(self, uri):
if not self.vfs.get(uri):
(req_type, res_id) = re.match(r'.*:(\w*)(?:/(\d*))?', uri).groups()
cache_key = uri
from_cache = self.cache.get(cache_key)
if from_cache:
try:
len(from_cache)
return from_cache
except TypeError:
return [from_cache]
if 'favorites' == req_type:
return self.list_favorites()
if not self.vfs.get(uri):
if uri.startswith("funkwhale:directory:"):
uri = uri.replace("funkwhale:directory:", "", 1)
parts = uri.split(":")
remaining = parts[1:] if len(parts) > 1 else []
handler = getattr(self, "browse_%s" % parts[0])
result, cache = handler(remaining)
if cache:
self.cache.set(cache_key, result)
return result
# root directory
return self.vfs.get(uri, {}).values()
def browse_favorites(self, remaining):
if remaining == []:
return (
[
new_folder("Recent", "favorites:recent"),
# new_folder("By artist", "favorites:by-artist"),
],
False,
)
if remaining == ["recent"]:
payload = self.backend.client.list_tracks(
{"favorites": "true", "ordering": "-creation_date", "page_size": 50}
)
tracks = [
convert_to_track(row, ref=True, cache=self.cache)
for row in self.backend.client.load_all(payload, max=10)
]
return tracks, True
return [], False
def browse_albums(self, uri_prefix, remaining):
if len(remaining) == 2:
album = remaining[1]
payload = self.backend.client.list_tracks(
{
"ordering": "position",
"page_size": 50,
"playable": "true",
"album": album,
}
)
tracks = [
convert_to_track(row, ref=True, cache=self.cache)
for row in self.backend.client.load_all(payload)
]
return tracks
else:
artist, album = remaining[0], None
payload = self.backend.client.list_albums(
{
"ordering": "title",
"page_size": 50,
"playable": "true",
"artist": artist,
}
)
albums = [
convert_to_album(row, uri_prefix=uri_prefix, ref=True)
for row in self.backend.client.load_all(payload)
]
return albums
def browse_artists(self, remaining):
logger.debug("Handling artist route: %s", remaining)
if remaining == []:
return (
[
new_folder("Recent", "artists:recent"),
new_folder("By name", "artists:by-name"),
],
False,
)
root = remaining[0]
end = remaining[1:]
albums_uri_prefix = "funkwhale:directory:artists:" + ":".join(
[str(i) for i in remaining]
)
if root == "recent":
if end:
# list albums
return (
self.browse_albums(uri_prefix=albums_uri_prefix, remaining=end),
True,
)
# list recent artists
payload = self.backend.client.list_artists(
{"ordering": "-creation_date", "page_size": 50, "playable": "true"}
)
uri_prefix = "funkwhale:directory:artists:recent"
artists = [
convert_to_artist(row, uri_prefix=uri_prefix, ref=True)
for row in self.backend.client.load_all(payload, max=1)
]
return artists, True
if root == "by-name":
if end:
# list albums
return (
self.browse_albums(uri_prefix=albums_uri_prefix, remaining=end),
True,
)
# list recent artists
payload = self.backend.client.list_artists(
{"ordering": "name", "page_size": 50, "playable": "true"}
)
uri_prefix = "funkwhale:directory:artists:by-name"
artists = [
convert_to_artist(row, uri_prefix=uri_prefix, ref=True)
for row in self.backend.client.load_all(payload)
]
return artists, True
return [], False
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']]
logger.info("Searching Funkwhale for: %s", search_query)
raw_results = self.backend.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,
uri="funkwhale:search", tracks=tracks, albums=albums, artists=artists
)
def lookup(self, uri):
if 'fw:' in uri:
uri = uri.replace('fw:', '')
from_cache = self.cache.get(uri)
if from_cache:
try:
len(from_cache)
return from_cache
except TypeError:
return [from_cache]
if "fw:" in uri: