Can now browse favorites and artists

main
Eliot Berriot 2018-10-05 00:06:09 +02:00
parent 8696450267
commit 6a3f0949cd
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
9 changed files with 325 additions and 33 deletions

View File

@ -15,6 +15,8 @@ Features
-------- --------
* Searching for tracks, albums and artists available in your Funkwhale instance * Searching for tracks, albums and artists available in your Funkwhale instance
* Browse all artists and albums
* Browse your favorites
* Simple configuration * Simple configuration
Installation Installation
@ -47,15 +49,19 @@ To enable the extension, add the following to your ``mopidy.conf`` file::
username = demo username = demo
# Password to use when authenticating (leave empty fo anonymous access) # Password to use when authenticating (leave empty fo anonymous access)
password = demo 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 Of course, replace the demo values with your actual info (but you can
try using the demo server). try using the demo server).
After that, reload your mopidy daemon, and you should be good! After that, reload your mopidy daemon, and you should be good!
Todo Todo
---- ----
- Browse Funkwhale library and playlists - Browse use library and playlists
.. _Mopidy: https://www.mopidy.com/ .. _Mopidy: https://www.mopidy.com/

View File

@ -30,6 +30,7 @@ class Extension(mopidy.ext.Extension):
schema["url"] = mopidy.config.String() schema["url"] = mopidy.config.String()
schema["username"] = mopidy.config.String(optional=True) schema["username"] = mopidy.config.String(optional=True)
schema["password"] = mopidy.config.Secret(optional=True) schema["password"] = mopidy.config.Secret(optional=True)
schema["cache_duration"] = mopidy.config.Integer(optional=True)
return schema return schema
def validate_config(self, config): def validate_config(self, config):

View File

@ -1,11 +1,14 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import logging
import requests import requests
from mopidy import httpclient, exceptions from mopidy import httpclient, exceptions
from . import Extension, __version__ from . import Extension, __version__
logger = logging.getLogger(__name__)
class SessionWithUrlBase(requests.Session): class SessionWithUrlBase(requests.Session):
# In Python 3 you could place `url_base` after `*args`, but not in Python 2. # In Python 3 you could place `url_base` after `*args`, but not in Python 2.
@ -17,7 +20,10 @@ class SessionWithUrlBase(requests.Session):
# Next line of code is here for example purposes only. # Next line of code is here for example purposes only.
# You really shouldn't just use string concatenation here, # You really shouldn't just use string concatenation here,
# take a look at urllib.parse.urljoin instead. # 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) return super(SessionWithUrlBase, self).request(method, modified_url, **kwargs)
@ -86,3 +92,31 @@ class APIClient(object):
response = self.session.get("tracks/", params=filters) response = self.session.get("tracks/", params=filters)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def list_artists(self, filters):
response = self.session.get("artists/", params=filters)
response.raise_for_status()
return response.json()
def list_albums(self, filters):
response = self.session.get("albums/", params=filters)
response.raise_for_status()
return response.json()
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

View File

@ -6,3 +6,6 @@ url = https://demo.funkwhale.audio
username = demo username = demo
# Password to use when authenticating (leave empty fo anonymous access) # Password to use when authenticating (leave empty fo anonymous access)
password = demo password = demo
# duration of cache entries before they are removed, in seconds
# 0 to cache forever, empty to disable cache
cache_duration = 600

View File

@ -41,10 +41,14 @@ class Cache(collections.OrderedDict):
super(Cache, self).__init__() super(Cache, self).__init__()
def set(self, key, value): def set(self, key, value):
if self.max_age is None:
return
now = time.time() now = time.time()
self[key] = (now, value) self[key] = (now, value)
def get(self, key): def get(self, key):
if self.max_age is None:
return
value = super(Cache, self).get(key) value = super(Cache, self).get(key)
if value is None: if value is None:
return return
@ -64,42 +68,146 @@ class FunkwhaleLibraryProvider(backend.LibraryProvider):
super(FunkwhaleLibraryProvider, self).__init__(*args, **kwargs) super(FunkwhaleLibraryProvider, self).__init__(*args, **kwargs)
self.vfs = {"funkwhale:directory": collections.OrderedDict()} self.vfs = {"funkwhale:directory": collections.OrderedDict()}
self.add_to_vfs(new_folder("Favorites", "favorites")) 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('Following', ['following']))
# self.add_to_vfs(new_folder('Sets', ['sets'])) # self.add_to_vfs(new_folder('Sets', ['sets']))
# self.add_to_vfs(new_folder('Stream', ['stream'])) # self.add_to_vfs(new_folder('Stream', ['stream']))
self.cache = Cache() self.cache = Cache(max_age=self.backend.config["funkwhale"]["cache_duration"])
def add_to_vfs(self, _model): def add_to_vfs(self, _model):
self.vfs["funkwhale:directory"][_model.uri] = _model self.vfs["funkwhale:directory"][_model.uri] = _model
def browse(self, uri): def browse(self, uri):
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 not self.vfs.get(uri): if not self.vfs.get(uri):
if uri.startswith("funkwhale:directory:"): if uri.startswith("funkwhale:directory:"):
uri = uri.replace("funkwhale:directory:", "", 1) uri = uri.replace("funkwhale:directory:", "", 1)
parts = uri.split(":") parts = uri.split(":")
remaining = parts[1:] if len(parts) > 1 else [] remaining = parts[1:] if len(parts) > 1 else []
print("PARTS", parts, remaining)
handler = getattr(self, "browse_%s" % parts[0]) handler = getattr(self, "browse_%s" % parts[0])
return handler(remaining) result, cache = handler(remaining)
if cache:
self.cache.set(cache_key, result)
return result
# root directory # root directory
return self.vfs.get(uri, {}).values() return self.vfs.get(uri, {}).values()
def browse_favorites(self, remaining): def browse_favorites(self, remaining):
if remaining == []: if remaining == []:
return [ return (
new_folder("Recent", "favorites:recent"), [
new_folder("By artist", "favorites:by-artist"), new_folder("Recent", "favorites:recent"),
] # new_folder("By artist", "favorites:by-artist"),
],
False,
)
if remaining == ["recent"]: if remaining == ["recent"]:
payload = self.backend.client.list_tracks( payload = self.backend.client.list_tracks(
{"favorites": "true", "ordering": "-creation_date", "page_size": 100} {"favorites": "true", "ordering": "-creation_date", "page_size": 50}
)["results"] )
return [ tracks = [
convert_to_track(row, ref=True, cache=self.cache) for row in payload convert_to_track(row, ref=True, cache=self.cache)
for row in self.backend.client.load_all(payload, max=10)
] ]
return [] 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): def search(self, query=None, uris=None, exact=False):
# TODO Support exact search # TODO Support exact search
@ -119,7 +227,6 @@ class FunkwhaleLibraryProvider(backend.LibraryProvider):
) )
def lookup(self, uri): def lookup(self, uri):
print("CACHE", self.cache, uri)
from_cache = self.cache.get(uri) from_cache = self.cache.get(uri)
if from_cache: if from_cache:
try: try:
@ -153,8 +260,10 @@ def parse_uri(uri):
def cast_to_ref(f): def cast_to_ref(f):
def inner(payload, ref=False, cache=None): def inner(payload, *args, **kwargs):
result = f(payload) ref = kwargs.pop("ref", False)
cache = kwargs.pop("cache", None)
result = f(payload, *args, **kwargs)
if cache is not None: if cache is not None:
cache.set(result.uri, result) cache.set(result.uri, result)
if ref: if ref:
@ -165,9 +274,9 @@ def cast_to_ref(f):
@cast_to_ref @cast_to_ref
def convert_to_artist(payload): def convert_to_artist(payload, uri_prefix="funkwhale:artists"):
return models.Artist( return models.Artist(
uri="funkwhale:artists:%s" % (payload["id"],), uri=uri_prefix + ":%s" % payload["id"],
name=payload["name"], name=payload["name"],
sortname=payload["name"], sortname=payload["name"],
musicbrainz_id=payload["mbid"], musicbrainz_id=payload["mbid"],
@ -175,12 +284,12 @@ def convert_to_artist(payload):
@cast_to_ref @cast_to_ref
def convert_to_album(payload): def convert_to_album(payload, uri_prefix="funkwhale:albums"):
artist = convert_to_artist(payload["artist"]) artist = convert_to_artist(payload["artist"])
image = payload["cover"]["original"] if payload["cover"] else None image = payload["cover"]["original"] if payload["cover"] else None
return models.Album( return models.Album(
uri="funkwhale:albums:%s" % (payload["id"],), uri=uri_prefix + ":%s" % payload["id"],
name=payload["title"], name=payload["title"],
musicbrainz_id=payload["mbid"], musicbrainz_id=payload["mbid"],
images=[image] if image else [], images=[image] if image else [],
@ -191,11 +300,11 @@ def convert_to_album(payload):
@cast_to_ref @cast_to_ref
def convert_to_track(payload): def convert_to_track(payload, uri_prefix="funkwhale:tracks"):
artist = convert_to_artist(payload["artist"]) artist = convert_to_artist(payload["artist"])
album = convert_to_album(payload["album"]) album = convert_to_album(payload["album"])
return models.Track( return models.Track(
uri="funkwhale:tracks:%s" % (payload["id"],), uri=uri_prefix + ":%s" % payload["id"],
name=payload["title"], name=payload["title"],
musicbrainz_id=payload["mbid"], musicbrainz_id=payload["mbid"],
artists=[artist], artists=[artist],

View File

@ -10,7 +10,12 @@ FUNKWHALE_URL = "https://test.funkwhale"
@pytest.fixture() @pytest.fixture()
def config(): def config():
return { return {
"funkwhale": {"url": FUNKWHALE_URL, "username": "user", "password": "passw0rd"}, "funkwhale": {
"url": FUNKWHALE_URL,
"username": "user",
"password": "passw0rd",
"cache_duration": 600,
},
"proxy": {}, "proxy": {},
} }

View File

@ -24,3 +24,33 @@ def test_client_list_tracks(client, requests_mock):
result = client.list_tracks({"artist": 12}) result = client.list_tracks({"artist": 12})
assert result == {"hello": "world"} assert result == {"hello": "world"}
def test_client_list_artists(client, requests_mock):
requests_mock.get(
client.session.url_base + "artists/?playable=true", json={"hello": "world"}
)
result = client.list_artists({"playable": "true"})
assert result == {"hello": "world"}
def test_client_list_albums(client, requests_mock):
requests_mock.get(
client.session.url_base + "albums/?playable=true", json={"hello": "world"}
)
result = client.list_albums({"playable": "true"})
assert result == {"hello": "world"}
def test_load_all(client, requests_mock):
page1 = {"results": [1, 2, 3], "next": "https://first.page"}
page2 = {"results": [4, 5, 6], "next": "https://second.page"}
page3 = {"results": [7, 8, 9], "next": None}
requests_mock.get(page1["next"], json=page2)
requests_mock.get(page2["next"], json=page3)
assert (
list(client.load_all(page1))
== page1["results"] + page2["results"] + page3["results"]
)

View File

@ -22,3 +22,4 @@ def test_get_config_schema():
assert "url" in schema assert "url" in schema
assert "username" in schema assert "username" in schema
assert "password" in schema assert "password" in schema
assert "cache_duration" in schema

View File

@ -113,19 +113,26 @@ def test_parse_uri(type):
], ],
) )
def test_browse_routing(library, path, expected_handler, mocker, remaining): def test_browse_routing(library, path, expected_handler, mocker, remaining):
handler = mocker.patch.object(library, expected_handler, return_value="test") handler = mocker.patch.object(
library, expected_handler, return_value=("test", False)
)
assert library.browse(path) == "test" assert library.browse(path) == "test"
assert handler.called_once_with(remaining) assert handler.called_once_with(remaining)
def test_browse_favorites_root(library): def test_browse_favorites_root(library):
expected = [ expected = (
models.Ref.directory(uri="funkwhale:directory:favorites:recent", name="Recent"), [
models.Ref.directory( models.Ref.directory(
uri="funkwhale:directory:favorites:by-artist", name="By artist" uri="funkwhale:directory:favorites:recent", name="Recent"
), ),
] # models.Ref.directory(
# uri="funkwhale:directory:favorites:by-artist", name="By artist"
# ),
],
False,
)
assert library.browse_favorites([]) == expected assert library.browse_favorites([]) == expected
@ -133,16 +140,96 @@ def test_browse_favorites_recent(library, client, requests_mock):
track = factories.TrackJSONFactory() track = factories.TrackJSONFactory()
url = ( url = (
client.session.url_base client.session.url_base
+ "tracks/?favorites=true&page_size=100&&ordering=-creation_date" + "tracks/?favorites=true&page_size=50&&ordering=-creation_date"
) )
requests_mock.get(url, json={"results": [track]}) requests_mock.get(url, json={"results": [track]})
expected = [mopidy_funkwhale.library.convert_to_track(track, ref=True)] expected = [mopidy_funkwhale.library.convert_to_track(track, ref=True)], True
result = library.browse_favorites(["recent"]) result = library.browse_favorites(["recent"])
assert result == expected assert result == expected
def test_browse_artists_root(library):
expected = (
[
models.Ref.directory(
uri="funkwhale:directory:artists:recent", name="Recent"
),
models.Ref.directory(
uri="funkwhale:directory:artists:by-name", name="By name"
),
],
False,
)
assert library.browse_artists([]) == expected
def test_browse_artists_recent(client, library, requests_mock):
artist1 = factories.ArtistJSONFactory()
artist2 = factories.ArtistJSONFactory()
url = (
client.session.url_base
+ "artists/?page_size=50&ordering=-creation_date&playable=true"
)
requests_mock.get(url, json={"results": [artist1, artist2]})
uri_prefix = "funkwhale:directory:artists:recent"
expected = (
[
mopidy_funkwhale.library.convert_to_artist(
artist1, uri_prefix=uri_prefix, ref=True
),
mopidy_funkwhale.library.convert_to_artist(
artist2, uri_prefix=uri_prefix, ref=True
),
],
True,
)
assert library.browse_artists(["recent"]) == expected
def test_browse_artists_albums(client, library, requests_mock):
album1 = factories.AlbumJSONFactory()
album2 = factories.AlbumJSONFactory(artist=album1["artist"])
url = (
client.session.url_base
+ "albums/?page_size=50&ordering=title&playable=true&artist%s"
% album1["artist"]["id"]
)
requests_mock.get(url, json={"results": [album1, album2]})
uri_prefix = "funkwhale:directory:artists:by-name:%s" % album1["artist"]["id"]
expected = (
[
mopidy_funkwhale.library.convert_to_album(
album1, uri_prefix=uri_prefix, ref=True
),
mopidy_funkwhale.library.convert_to_album(
album2, uri_prefix=uri_prefix, ref=True
),
],
True,
)
assert library.browse_artists(["by-name", album1["artist"]["id"]]) == expected
def test_browse_artists_album_single(client, library, requests_mock):
track = factories.TrackJSONFactory()
url = (
client.session.url_base
+ "tracks/?page_size=50&ordering=position&playable=true&album="
+ str(track["album"]["id"])
)
requests_mock.get(url, json={"results": [track]})
expected = ([mopidy_funkwhale.library.convert_to_track(track, ref=True)], True)
assert (
library.browse_artists(
["by-name", track["album"]["artist"]["id"], track["album"]["id"]]
)
== expected
)
def test_cache_set(): def test_cache_set():
cache = mopidy_funkwhale.library.Cache() cache = mopidy_funkwhale.library.Cache()
cache.set("hello:world", "value") cache.set("hello:world", "value")
@ -162,3 +249,19 @@ def test_cache_key_too_old():
cache["hello:world"] = (t, "value") cache["hello:world"] = (t, "value")
assert cache.get("hello:world") is None assert cache.get("hello:world") is None
assert "hello:world" not in cache assert "hello:world" not in cache
def test_lookup_from_cache(library):
track = object()
library.cache.set("funkwhale:artists:42", track)
result = library.lookup("funkwhale:artists:42")
assert result == [track]
def test_lookup_from_cache_iterable(library):
track = [object()]
library.cache.set("funkwhale:artists:42", track)
result = library.lookup("funkwhale:artists:42")
assert result == track