diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index af0899d30..fa4a5a0fe 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -226,6 +226,28 @@ class GetSongSerializer(serializers.Serializer): return get_track_data(track.album, track, uploads[0]) +class GetTopSongsSerializer(serializers.Serializer): + def to_representation(self, artist): + top_tracks = ( + history_models.Listening.objects.filter(track__artist_credit__artist=artist) + .values("track") + .annotate(listen_count=Count("id")) + .order_by("-listen_count")[: self.context["count"]] + ) + if not len(top_tracks): + return {} + + top_tracks_instances = [] + for track in top_tracks: + track = music_models.Track.objects.get(id=track["track"]) + top_tracks_instances.append(track) + + return [ + get_track_data(track.album, track, track.uploads.all()[0]) + for track in top_tracks_instances + ] + + def get_starred_tracks_data(favorites): by_track_id = {f.track_id: f for f in favorites} tracks = ( @@ -335,15 +357,21 @@ def get_channel_data(channel, uploads): "id": str(channel.uuid), "url": channel.get_rss_url(), "title": channel.artist.name, - "description": channel.artist.description.as_plain_text - if channel.artist.description - else "", - "coverArt": f"at-{channel.artist.attachment_cover.uuid}" - if channel.artist.attachment_cover - else "", - "originalImageUrl": channel.artist.attachment_cover.url - if channel.artist.attachment_cover - else "", + "description": ( + channel.artist.description.as_plain_text + if channel.artist.description + else "" + ), + "coverArt": ( + f"at-{channel.artist.attachment_cover.uuid}" + if channel.artist.attachment_cover + else "" + ), + "originalImageUrl": ( + channel.artist.attachment_cover.url + if channel.artist.attachment_cover + else "" + ), "status": "completed", } if uploads: @@ -360,12 +388,14 @@ def get_channel_episode_data(upload, channel_id): "channelId": str(channel_id), "streamId": upload.track.id, "title": upload.track.title, - "description": upload.track.description.as_plain_text - if upload.track.description - else "", - "coverArt": f"at-{upload.track.attachment_cover.uuid}" - if upload.track.attachment_cover - else "", + "description": ( + upload.track.description.as_plain_text if upload.track.description else "" + ), + "coverArt": ( + f"at-{upload.track.attachment_cover.uuid}" + if upload.track.attachment_cover + else "" + ), "isDir": "false", "year": upload.track.creation_date.year, "publishDate": upload.track.creation_date.isoformat(), diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 7b5712de9..494a3516a 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -1,6 +1,7 @@ """ Documentation of Subsonic API can be found at http://www.subsonic.org/pages/api.jsp """ + import datetime import functools @@ -90,6 +91,8 @@ def find_object( } } ) + except qs.model.MultipleObjectsReturned: + obj = qs.filter(**{model_field: value})[0] kwargs["obj"] = obj return func(self, request, *args, **kwargs) @@ -260,6 +263,43 @@ class SubsonicViewSet(viewsets.GenericViewSet): return response.Response(payload, status=200) + # This should return last.fm data but we choose to return the pod top song + @action( + detail=False, + methods=["get", "post"], + url_name="get_top_songs", + url_path="getTopSongs", + ) + @find_object( + music_models.Artist.objects.all(), + model_field="artist_credit__artist__name", + field="artist", + filter_playable=True, + cast=str, + ) + def get_top_songs(self, request, *args, **kwargs): + artist = kwargs.pop("obj") + data = request.GET or request.POST + try: + count = int(data["count"]) + except KeyError: + return response.Response( + { + "error": { + "code": 10, + "message": "required parameter 'count' not present", + } + } + ) + + # passing with many=true to make the serializer accept the returned list + data = serializers.GetTopSongsSerializer( + [artist], context={"count": count}, many=True + ).data + payload = {"topSongs": data[0]} + + return response.Response(payload, status=200) + @action( detail=False, methods=["get", "post"], @@ -289,6 +329,44 @@ class SubsonicViewSet(viewsets.GenericViewSet): payload = {"album": data} return response.Response(payload, status=200) + # A clone of get_album (this should return last.fm data but we prefer to send our own metadata) + @action( + detail=False, + methods=["get", "post"], + url_name="get_album_info_2", + url_path="getAlbumInfo2", + ) + @find_object( + music_models.Album.objects.with_duration().prefetch_related( + "artist_credit__artist" + ), + filter_playable=True, + ) + def get_album_info_2(self, request, *args, **kwargs): + album = kwargs.pop("obj") + data = serializers.GetAlbumSerializer(album).data + payload = {"albumInfo": data} + return response.Response(payload, status=200) + + # A clone of get_album (this should return last.fm data but we prefer to send our own metadata) + @action( + detail=False, + methods=["get", "post"], + url_name="get_album_info", + url_path="getAlbumInfo", + ) + @find_object( + music_models.Album.objects.with_duration().prefetch_related( + "artist_credit__artist" + ), + filter_playable=True, + ) + def get_album_info(self, request, *args, **kwargs): + album = kwargs.pop("obj") + data = serializers.GetAlbumSerializer(album).data + payload = {"albumInfo": data} + return response.Response(payload, status=200) + @action(detail=False, methods=["get", "post"], url_name="stream", url_path="stream") @find_object(music_models.Track.objects.all(), filter_playable=True) def stream(self, request, *args, **kwargs): diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 3ffe10078..9e86b6962 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -227,6 +227,62 @@ def test_get_album( ) +@pytest.mark.parametrize("f", ["json"]) +def test_get_album_info_2( + f, db, logged_in_api_client, factories, mocker, queryset_equal_queries +): + url = reverse("api:subsonic:subsonic-get_album_info_2") + assert url.endswith("getAlbumInfo2") is True + artist_credit = factories["music.ArtistCredit"]() + album = ( + factories["music.Album"](artist_credit=artist_credit) + .__class__.objects.with_duration() + .first() + ) + factories["music.Track"].create_batch(size=3, album=album, playable=True) + playable_by = mocker.spy(music_models.AlbumQuerySet, "playable_by") + expected = {"albumInfo": serializers.GetAlbumSerializer(album).data} + response = logged_in_api_client.get(url, {"f": f, "id": album.pk}) + + assert response.status_code == 200 + assert response.data == expected + + playable_by.assert_called_once_with( + music_models.Album.objects.with_duration().prefetch_related( + "artist_credit__artist" + ), + None, + ) + + +@pytest.mark.parametrize("f", ["json"]) +def test_get_album_info( + f, db, logged_in_api_client, factories, mocker, queryset_equal_queries +): + url = reverse("api:subsonic:subsonic-get_album_info") + assert url.endswith("getAlbumInfo") is True + artist_credit = factories["music.ArtistCredit"]() + album = ( + factories["music.Album"](artist_credit=artist_credit) + .__class__.objects.with_duration() + .first() + ) + factories["music.Track"].create_batch(size=3, album=album, playable=True) + playable_by = mocker.spy(music_models.AlbumQuerySet, "playable_by") + expected = {"albumInfo": serializers.GetAlbumSerializer(album).data} + response = logged_in_api_client.get(url, {"f": f, "id": album.pk}) + + assert response.status_code == 200 + assert response.data == expected + + playable_by.assert_called_once_with( + music_models.Album.objects.with_duration().prefetch_related( + "artist_credit__artist" + ), + None, + ) + + @pytest.mark.parametrize("f", ["json"]) def test_get_song( f, db, logged_in_api_client, factories, mocker, queryset_equal_queries @@ -247,6 +303,32 @@ def test_get_song( playable_by.assert_called_once_with(music_models.Track.objects.all(), None) +@pytest.mark.parametrize("f", ["json"]) +def test_get_top_songs( + f, db, logged_in_api_client, factories, mocker, queryset_equal_queries +): + url = reverse("api:subsonic:subsonic-get_top_songs") + assert url.endswith("getTopSongs") is True + artist_credit = factories["music.ArtistCredit"]() + album = factories["music.Album"](artist_credit=artist_credit) + track = factories["music.Track"](album=album, playable=True) + tracks = factories["music.Track"].create_batch(20, album=album, playable=True) + factories["music.Upload"](track=track) + factories["history.Listening"].create_batch(20, track=track) + factories["history.Listening"].create_batch(2, track=tracks[2]) + + playable_by = mocker.spy(music_models.TrackQuerySet, "playable_by") + response = logged_in_api_client.get( + url, {"f": f, "artist": artist_credit.artist.name, "count": 2} + ) + + assert response.status_code == 200 + assert response.data["topSongs"][0] == serializers.get_track_data( + track.album, track, track.uploads.all()[0] + ) + playable_by.assert_called_once_with(music_models.Track.objects.all(), None) + + @pytest.mark.parametrize("f", ["json"]) def test_stream( f, db, logged_in_api_client, factories, mocker, queryset_equal_queries, settings diff --git a/changes/changelog.d/2392.enhancement b/changes/changelog.d/2392.enhancement new file mode 100644 index 000000000..2958abed0 --- /dev/null +++ b/changes/changelog.d/2392.enhancement @@ -0,0 +1 @@ +Subsonic getAlbumInfo, getAlbumInfo2 and getTopSongs endpoints (#2392)