diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py index e4b6470511378bbff21d824896a4210dd84c6c43..527b3fa1e2e9274c8041cbbd6c74e8741a55d261 100644 --- a/api/funkwhale_api/subsonic/renderers.py +++ b/api/funkwhale_api/subsonic/renderers.py @@ -53,5 +53,8 @@ def dict_to_xml_tree(root_tag, d, parent=None): for obj in value: root.append(dict_to_xml_tree(key, obj, parent=root)) else: - root.set(key, str(value)) + if key == "value": + root.text = str(value) + else: + root.set(key, str(value)) return root diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index a53ad464038316f5efbd5810bd8a995e33a744ec..994ad682deb4d73251ef72659b4ffd2c0f9df22f 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -263,3 +263,11 @@ class ScrobbleSerializer(serializers.Serializer): return history_models.Listening.objects.create( user=self.context["user"], track=data["id"] ) + + +def get_genre_data(tag): + return { + "songCount": getattr(tag, "_tracks_count", 0), + "albumCount": getattr(tag, "_albums_count", 0), + "value": tag.name, + } diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 909f7a6e4853c07e318ec7c9ddd15e821147a52a..e206555abdd74eee9b92605fc61f5d207b5cc1f3 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -2,6 +2,8 @@ import datetime import functools from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db.models import Count, Q from django.utils import timezone from rest_framework import exceptions from rest_framework import permissions as rest_permissions @@ -18,6 +20,7 @@ from funkwhale_api.music import models as music_models from funkwhale_api.music import utils from funkwhale_api.music import views as music_views from funkwhale_api.playlists import models as playlists_models +from funkwhale_api.tags import models as tags_models from funkwhale_api.users import models as users_models from . import authentication, filters, negotiation, serializers @@ -362,6 +365,26 @@ class SubsonicViewSet(viewsets.GenericViewSet): queryset = filterset.qs actor = utils.get_actor_from_request(request) queryset = queryset.playable_by(actor) + type = data.get("type", "alphabeticalByArtist") + + if type == "alphabeticalByArtist": + queryset = queryset.order_by("artist__name") + elif type == "random": + queryset = queryset.order_by("?") + elif type == "alphabeticalByName" or not type: + queryset = queryset.order_by("artist__title") + elif type == "recent" or not type: + queryset = queryset.exclude(release_date__in=["", None]).order_by( + "-release_date" + ) + elif type == "newest" or not type: + queryset = queryset.order_by("-creation_date") + elif type == "byGenre" and data.get("genre"): + genre = data.get("genre") + queryset = queryset.filter( + Q(tagged_items__tag__name=genre) + | Q(artist__tagged_items__tag__name=genre) + ) try: offset = int(data["offset"]) @@ -669,3 +692,29 @@ class SubsonicViewSet(viewsets.GenericViewSet): listening = serializer.save() record.send(listening) return response.Response({}) + + @action( + detail=False, + methods=["get", "post"], + url_name="get_genres", + url_path="getGenres", + ) + def get_genres(self, request, *args, **kwargs): + album_ct = ContentType.objects.get_for_model(music_models.Album) + track_ct = ContentType.objects.get_for_model(music_models.Track) + queryset = ( + tags_models.Tag.objects.annotate( + _albums_count=Count( + "tagged_items", filter=Q(tagged_items__content_type=album_ct) + ), + _tracks_count=Count( + "tagged_items", filter=Q(tagged_items__content_type=track_ct) + ), + ) + .exclude(_tracks_count=0, _albums_count=0) + .order_by("name") + ) + data = { + "genres": {"genre": [serializers.get_genre_data(tag) for tag in queryset]} + } + return response.Response(data) diff --git a/api/funkwhale_api/tags/models.py b/api/funkwhale_api/tags/models.py index a5f3a37359888232c72124b5e0ce3ea55981cb75..1416a62bc02d256a56fd9b16291b470e89242850 100644 --- a/api/funkwhale_api/tags/models.py +++ b/api/funkwhale_api/tags/models.py @@ -33,14 +33,12 @@ class TaggedItemQuerySet(models.QuerySet): class TaggedItem(models.Model): creation_date = models.DateTimeField(default=timezone.now) - tag = models.ForeignKey( - Tag, related_name="%(app_label)s_%(class)s_items", on_delete=models.CASCADE - ) + tag = models.ForeignKey(Tag, related_name="tagged_items", on_delete=models.CASCADE) content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, verbose_name=_("Content type"), - related_name="%(app_label)s_%(class)s_tagged_items", + related_name="tagged_items", ) object_id = models.IntegerField(verbose_name=_("Object id"), db_index=True) content_object = GenericForeignKey() diff --git a/api/tests/subsonic/test_renderers.py b/api/tests/subsonic/test_renderers.py index acd5500e665b6d4f9d32c0ea6cd9689a9bb37421..501ae48ce559277baf5b378b41c042d7241ea9a3 100644 --- a/api/tests/subsonic/test_renderers.py +++ b/api/tests/subsonic/test_renderers.py @@ -67,9 +67,12 @@ def test_json_renderer(): def test_xml_renderer_dict_to_xml(): - payload = {"hello": "world", "item": [{"this": 1}, {"some": "node"}]} + payload = { + "hello": "world", + "item": [{"this": 1, "value": "text"}, {"some": "node"}], + } expected = """<?xml version="1.0" encoding="UTF-8"?> -<key hello="world"><item this="1" /><item some="node" /></key>""" +<key hello="world"><item this="1">text</item><item some="node" /></key>""" result = renderers.dict_to_xml_tree("key", payload) exp = ET.fromstring(expected) assert ET.tostring(result) == ET.tostring(exp) diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index e227f4d1d1abbb9db924cc20b6065ea37f58c9dd..361a46a73bec60a91e1b042b6b12ff98cb076c28 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -375,6 +375,28 @@ def test_get_random_songs(f, db, logged_in_api_client, factories, mocker): order_by.assert_called_once_with("?") +@pytest.mark.parametrize("f", ["json"]) +def test_get_genres(f, db, logged_in_api_client, factories, mocker): + url = reverse("api:subsonic-get_genres") + assert url.endswith("getGenres") is True + tag1 = factories["tags.Tag"](name="Pop") + tag2 = factories["tags.Tag"](name="Rock") + + factories["music.Album"](set_tags=[tag1.name, tag2.name]) + factories["music.Track"](set_tags=[tag1.name]) + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + "genres": { + "genre": [ + {"songCount": 1, "albumCount": 1, "value": tag1.name}, + {"songCount": 0, "albumCount": 1, "value": tag2.name}, + ] + } + } + + @pytest.mark.parametrize("f", ["json"]) def test_get_starred(f, db, logged_in_api_client, factories): url = reverse("api:subsonic-get_starred") @@ -426,6 +448,27 @@ def test_get_album_list2_pagination(f, db, logged_in_api_client, factories): } +@pytest.mark.parametrize("f", ["json"]) +def test_get_album_list2_by_genre(f, db, logged_in_api_client, factories): + url = reverse("api:subsonic-get_album_list2") + assert url.endswith("getAlbumList2") is True + album1 = factories["music.Album"]( + artist__name="Artist1", playable=True, set_tags=["Rock"] + ) + album2 = factories["music.Album"]( + artist__name="Artist2", playable=True, artist__set_tags=["Rock"] + ) + factories["music.Album"](playable=True, set_tags=["Pop"]) + response = logged_in_api_client.get( + url, {"f": f, "type": "byGenre", "size": 5, "offset": 0, "genre": "rock"} + ) + + assert response.status_code == 200 + assert response.data == { + "albumList2": {"album": serializers.get_album_list2_data([album1, album2])} + } + + @pytest.mark.parametrize("f", ["json"]) def test_search3(f, db, logged_in_api_client, factories): url = reverse("api:subsonic-search3")