From 6a3f0949cd076fe763dad8bb93aceb31d9726606 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 5 Oct 2018 00:06:09 +0200 Subject: [PATCH] Can now browse favorites and artists --- README.rst | 8 +- mopidy_funkwhale/__init__.py | 1 + mopidy_funkwhale/client.py | 36 ++++++++- mopidy_funkwhale/ext.conf | 3 + mopidy_funkwhale/library.py | 151 ++++++++++++++++++++++++++++++----- tests/conftest.py | 7 +- tests/test_client.py | 30 +++++++ tests/test_extension.py | 1 + tests/test_library.py | 121 +++++++++++++++++++++++++--- 9 files changed, 325 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index 63141f8..f1c001a 100644 --- a/README.rst +++ b/README.rst @@ -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/ diff --git a/mopidy_funkwhale/__init__.py b/mopidy_funkwhale/__init__.py index 7a553cf..3cdc9f9 100644 --- a/mopidy_funkwhale/__init__.py +++ b/mopidy_funkwhale/__init__.py @@ -30,6 +30,7 @@ class Extension(mopidy.ext.Extension): 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): diff --git a/mopidy_funkwhale/client.py b/mopidy_funkwhale/client.py index 8c2cd44..200fff8 100644 --- a/mopidy_funkwhale/client.py +++ b/mopidy_funkwhale/client.py @@ -1,11 +1,14 @@ 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,7 +20,10 @@ 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) @@ -86,3 +92,31 @@ class APIClient(object): 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() + + 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 diff --git a/mopidy_funkwhale/ext.conf b/mopidy_funkwhale/ext.conf index 6ed219a..1b53865 100644 --- a/mopidy_funkwhale/ext.conf +++ b/mopidy_funkwhale/ext.conf @@ -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 diff --git a/mopidy_funkwhale/library.py b/mopidy_funkwhale/library.py index 1e7b123..1cd982a 100644 --- a/mopidy_funkwhale/library.py +++ b/mopidy_funkwhale/library.py @@ -41,10 +41,14 @@ class Cache(collections.OrderedDict): 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 @@ -64,42 +68,146 @@ class FunkwhaleLibraryProvider(backend.LibraryProvider): 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("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() + 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 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 uri.startswith("funkwhale:directory:"): uri = uri.replace("funkwhale:directory:", "", 1) parts = uri.split(":") remaining = parts[1:] if len(parts) > 1 else [] - print("PARTS", parts, remaining) 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 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"), - ] + 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": 100} - )["results"] - return [ - convert_to_track(row, ref=True, cache=self.cache) for row in payload + {"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 [] + 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 @@ -119,7 +227,6 @@ class FunkwhaleLibraryProvider(backend.LibraryProvider): ) def lookup(self, uri): - print("CACHE", self.cache, uri) from_cache = self.cache.get(uri) if from_cache: try: @@ -153,8 +260,10 @@ def parse_uri(uri): def cast_to_ref(f): - def inner(payload, ref=False, cache=None): - result = f(payload) + def inner(payload, *args, **kwargs): + ref = kwargs.pop("ref", False) + cache = kwargs.pop("cache", None) + result = f(payload, *args, **kwargs) if cache is not None: cache.set(result.uri, result) if ref: @@ -165,9 +274,9 @@ def cast_to_ref(f): @cast_to_ref -def convert_to_artist(payload): +def convert_to_artist(payload, uri_prefix="funkwhale:artists"): return models.Artist( - uri="funkwhale:artists:%s" % (payload["id"],), + uri=uri_prefix + ":%s" % payload["id"], name=payload["name"], sortname=payload["name"], musicbrainz_id=payload["mbid"], @@ -175,12 +284,12 @@ def convert_to_artist(payload): @cast_to_ref -def convert_to_album(payload): +def convert_to_album(payload, uri_prefix="funkwhale:albums"): artist = convert_to_artist(payload["artist"]) image = payload["cover"]["original"] if payload["cover"] else None return models.Album( - uri="funkwhale:albums:%s" % (payload["id"],), + uri=uri_prefix + ":%s" % payload["id"], name=payload["title"], musicbrainz_id=payload["mbid"], images=[image] if image else [], @@ -191,11 +300,11 @@ def convert_to_album(payload): @cast_to_ref -def convert_to_track(payload): +def convert_to_track(payload, uri_prefix="funkwhale:tracks"): artist = convert_to_artist(payload["artist"]) album = convert_to_album(payload["album"]) return models.Track( - uri="funkwhale:tracks:%s" % (payload["id"],), + uri=uri_prefix + ":%s" % payload["id"], name=payload["title"], musicbrainz_id=payload["mbid"], artists=[artist], diff --git a/tests/conftest.py b/tests/conftest.py index 03bd95a..7f40532 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,12 @@ FUNKWHALE_URL = "https://test.funkwhale" @pytest.fixture() def config(): return { - "funkwhale": {"url": FUNKWHALE_URL, "username": "user", "password": "passw0rd"}, + "funkwhale": { + "url": FUNKWHALE_URL, + "username": "user", + "password": "passw0rd", + "cache_duration": 600, + }, "proxy": {}, } diff --git a/tests/test_client.py b/tests/test_client.py index 0765d36..75bd248 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,3 +24,33 @@ def test_client_list_tracks(client, requests_mock): result = client.list_tracks({"artist": 12}) 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"] + ) diff --git a/tests/test_extension.py b/tests/test_extension.py index ab5e37f..9b79528 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -22,3 +22,4 @@ def test_get_config_schema(): assert "url" in schema assert "username" in schema assert "password" in schema + assert "cache_duration" in schema diff --git a/tests/test_library.py b/tests/test_library.py index 781d1b5..7bbe0a1 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -113,19 +113,26 @@ def test_parse_uri(type): ], ) 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 handler.called_once_with(remaining) def test_browse_favorites_root(library): - expected = [ - models.Ref.directory(uri="funkwhale:directory:favorites:recent", name="Recent"), - models.Ref.directory( - uri="funkwhale:directory:favorites:by-artist", name="By artist" - ), - ] + expected = ( + [ + models.Ref.directory( + 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 @@ -133,16 +140,96 @@ def test_browse_favorites_recent(library, client, requests_mock): track = factories.TrackJSONFactory() url = ( 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]}) - 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"]) 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(): cache = mopidy_funkwhale.library.Cache() cache.set("hello:world", "value") @@ -162,3 +249,19 @@ def test_cache_key_too_old(): cache["hello:world"] = (t, "value") assert cache.get("hello:world") is None 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