From e31099ef332474aefd226a265345fce7742f9492 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Tue, 8 May 2018 21:22:52 +0200 Subject: [PATCH] See #75 more subsonic api endpoints (star, unstar, search...) --- api/funkwhale_api/subsonic/filters.py | 23 +++ api/funkwhale_api/subsonic/serializers.py | 130 ++++++++++++----- api/funkwhale_api/subsonic/views.py | 169 +++++++++++++++++++++- api/tests/subsonic/test_serializers.py | 32 +++- api/tests/subsonic/test_views.py | 122 ++++++++++++++++ 5 files changed, 433 insertions(+), 43 deletions(-) create mode 100644 api/funkwhale_api/subsonic/filters.py diff --git a/api/funkwhale_api/subsonic/filters.py b/api/funkwhale_api/subsonic/filters.py new file mode 100644 index 00000000..b7b639fa --- /dev/null +++ b/api/funkwhale_api/subsonic/filters.py @@ -0,0 +1,23 @@ +from django_filters import rest_framework as filters + +from funkwhale_api.music import models as music_models + + +class AlbumList2FilterSet(filters.FilterSet): + type = filters.CharFilter(name='_', method='filter_type') + + class Meta: + model = music_models.Album + fields = ['type'] + + def filter_type(self, queryset, name, value): + ORDERING = { + 'random': '?', + 'newest': '-creation_date', + 'alphabeticalByArtist': 'artist__name', + 'alphabeticalByName': 'title', + } + if value not in ORDERING: + return queryset + + return queryset.order_by(ORDERING[value]) diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 034343be..59fdb930 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -4,6 +4,16 @@ from django.db.models import functions, Count from rest_framework import serializers +from funkwhale_api.music import models as music_models + + +def get_artist_data(artist_values): + return { + 'id': artist_values['id'], + 'name': artist_values['name'], + 'albumCount': artist_values['_albums_count'] + } + class GetArtistsSerializer(serializers.Serializer): def to_representation(self, queryset): @@ -11,7 +21,7 @@ class GetArtistsSerializer(serializers.Serializer): 'ignoredArticles': '', 'index': [] } - queryset = queryset.annotate(_albums_count=Count('albums')) + queryset = queryset.with_albums_count() queryset = queryset.order_by(functions.Lower('name')) values = queryset.values('id', '_albums_count', 'name') @@ -23,11 +33,7 @@ class GetArtistsSerializer(serializers.Serializer): letter_data = { 'name': letter, 'artist': [ - { - 'id': v['id'], - 'name': v['name'], - 'albumCount': v['_albums_count'] - } + get_artist_data(v) for v in artists ] } @@ -59,42 +65,88 @@ class GetArtistSerializer(serializers.Serializer): return payload +def get_track_data(album, track, tf): + data = { + 'id': track.pk, + 'isDir': 'false', + 'title': track.title, + 'album': album.title, + 'artist': album.artist.name, + 'track': track.position, + 'contentType': tf.mimetype, + 'suffix': tf.extension or '', + 'duration': tf.duration or 0, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': album.artist.pk, + 'type': 'music', + } + if album.release_date: + data['year'] = album.release_date.year + return data + + +def get_album2_data(album): + payload = { + 'id': album.id, + 'artistId': album.artist.id, + 'name': album.title, + 'artist': album.artist.name, + 'created': album.creation_date, + } + try: + payload['songCount'] = album._tracks_count + except AttributeError: + payload['songCount'] = len(album.tracks.prefetch_related('files')) + return payload + + +def get_song_list_data(tracks): + songs = [] + for track in tracks: + try: + tf = [tf for tf in track.files.all()][0] + except IndexError: + continue + track_data = get_track_data(track.album, track, tf) + songs.append(track_data) + return songs + + class GetAlbumSerializer(serializers.Serializer): def to_representation(self, album): - tracks = album.tracks.prefetch_related('files') - payload = { - 'id': album.id, - 'artistId': album.artist.id, - 'name': album.title, - 'artist': album.artist.name, - 'created': album.creation_date, - 'songCount': len(tracks), - 'song': [], - } + tracks = album.tracks.prefetch_related('files').select_related('album') + payload = get_album2_data(album) if album.release_date: payload['year'] = album.release_date.year - for track in tracks: - try: - tf = [tf for tf in track.files.all()][0] - except IndexError: - continue - track_data = { - 'id': track.pk, - 'isDir': False, - 'title': track.title, - 'album': album.title, - 'artist': album.artist.name, - 'track': track.position, - 'contentType': tf.mimetype, - 'suffix': tf.extension, - 'duration': tf.duration, - 'created': track.creation_date, - 'albumId': album.pk, - 'artistId': album.artist.pk, - 'type': 'music', - } - if album.release_date: - track_data['year'] = album.release_date.year - payload['song'].append(track_data) + payload['song'] = get_song_list_data(tracks) return payload + + +def get_starred_tracks_data(favorites): + by_track_id = { + f.track_id: f + for f in favorites + } + tracks = music_models.Track.objects.filter( + pk__in=by_track_id.keys() + ).select_related('album__artist').prefetch_related('files') + tracks = tracks.order_by('-creation_date') + data = [] + for t in tracks: + try: + tf = [tf for tf in t.files.all()][0] + except IndexError: + continue + td = get_track_data(t.album, t, tf) + td['starred'] = by_track_id[t.pk].creation_date + data.append(td) + return data + + +def get_album_list2_data(albums): + return [ + get_album2_data(a) + for a in albums + ] diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 98fea759..7bb96179 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -1,3 +1,7 @@ +import datetime + +from django.utils import timezone + from rest_framework import exceptions from rest_framework import permissions as rest_permissions from rest_framework import response @@ -5,10 +9,13 @@ from rest_framework import viewsets from rest_framework.decorators import list_route from rest_framework.serializers import ValidationError +from funkwhale_api.favorites.models import TrackFavorite 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 . import authentication +from . import filters from . import negotiation from . import serializers @@ -83,6 +90,24 @@ class SubsonicViewSet(viewsets.GenericViewSet): } return response.Response(data, status=200) + @list_route( + methods=['get', 'post'], + url_name='get_license', + permissions_classes=[], + url_path='getLicense') + def get_license(self, request, *args, **kwargs): + now = timezone.now() + data = { + 'status': 'ok', + 'version': '1.16.0', + 'license': { + 'valid': 'true', + 'email': 'valid@valid.license', + 'licenseExpires': now + datetime.timedelta(days=365) + } + } + return response.Response(data, status=200) + @list_route( methods=['get', 'post'], url_name='get_artists', @@ -110,6 +135,19 @@ class SubsonicViewSet(viewsets.GenericViewSet): return response.Response(payload, status=200) + @list_route( + methods=['get', 'post'], + url_name='get_artist_info2', + url_path='getArtistInfo2') + @find_object(music_models.Artist.objects.all()) + def get_artist_info2(self, request, *args, **kwargs): + artist = kwargs.pop('obj') + payload = { + 'artist-info2': {} + } + + return response.Response(payload, status=200) + @list_route( methods=['get', 'post'], url_name='get_album', @@ -139,5 +177,134 @@ class SubsonicViewSet(viewsets.GenericViewSet): ) track_file = queryset.first() if not track_file: - return Response(status=404) + return response.Response(status=404) return music_views.handle_serve(track_file) + + @list_route( + methods=['get', 'post'], + url_name='star', + url_path='star') + @find_object( + music_models.Track.objects.all()) + def star(self, request, *args, **kwargs): + track = kwargs.pop('obj') + TrackFavorite.add(user=request.user, track=track) + return response.Response({'status': 'ok'}) + + @list_route( + methods=['get', 'post'], + url_name='unstar', + url_path='unstar') + @find_object( + music_models.Track.objects.all()) + def unstar(self, request, *args, **kwargs): + track = kwargs.pop('obj') + request.user.track_favorites.filter(track=track).delete() + return response.Response({'status': 'ok'}) + + @list_route( + methods=['get', 'post'], + url_name='get_starred2', + url_path='getStarred2') + def get_starred2(self, request, *args, **kwargs): + favorites = request.user.track_favorites.all() + data = { + 'song': serializers.get_starred_tracks_data(favorites) + } + return response.Response(data) + + @list_route( + methods=['get', 'post'], + url_name='get_album_list2', + url_path='getAlbumList2') + def get_album_list2(self, request, *args, **kwargs): + queryset = music_models.Album.objects.with_tracks_count() + data = request.GET or request.POST + filterset = filters.AlbumList2FilterSet(data, queryset=queryset) + queryset = filterset.qs + try: + offset = int(data['offset']) + except (TypeError, KeyError, ValueError): + offset = 0 + + try: + size = int(data['size']) + except (TypeError, KeyError, ValueError): + size = 50 + + size = min(size, 500) + queryset = queryset[offset:size] + data = { + 'albumList2': { + 'album': serializers.get_album_list2_data(queryset) + } + } + return response.Response(data) + + + @list_route( + methods=['get', 'post'], + url_name='search3', + url_path='search3') + def search3(self, request, *args, **kwargs): + data = request.GET or request.POST + query = str(data.get('query', '')).replace('*', '') + conf = [ + { + 'subsonic': 'artist', + 'search_fields': ['name'], + 'queryset': ( + music_models.Artist.objects + .with_albums_count() + .values('id', '_albums_count', 'name') + ), + 'serializer': lambda qs: [ + serializers.get_artist_data(a) for a in qs + ] + }, + { + 'subsonic': 'album', + 'search_fields': ['title'], + 'queryset': ( + music_models.Album.objects + .with_tracks_count() + .select_related('artist') + ), + 'serializer': serializers.get_album_list2_data, + }, + { + 'subsonic': 'song', + 'search_fields': ['title'], + 'queryset': ( + music_models.Track.objects + .prefetch_related('files') + .select_related('album__artist') + ), + 'serializer': serializers.get_song_list_data, + }, + ] + payload = { + 'searchResult3': {} + } + for c in conf: + offsetKey = '{}Offset'.format(c['subsonic']) + countKey = '{}Count'.format(c['subsonic']) + try: + offset = int(data[offsetKey]) + except (TypeError, KeyError, ValueError): + offset = 0 + + try: + size = int(data[countKey]) + except (TypeError, KeyError, ValueError): + size = 20 + + size = min(size, 100) + queryset = c['queryset'] + if query: + queryset = c['queryset'].filter( + utils.get_query(query, c['search_fields']) + ) + queryset = queryset[offset:size] + payload['searchResult3'][c['subsonic']] = c['serializer'](queryset) + return response.Response(payload) diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 3a9de7c9..64ecc3b3 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -1,3 +1,4 @@ +from funkwhale_api.music import models as music_models from funkwhale_api.subsonic import serializers @@ -89,15 +90,15 @@ def test_get_album_serializer(factories): 'song': [ { 'id': track.pk, - 'isDir': False, + 'isDir': 'false', 'title': track.title, 'album': album.title, 'artist': artist.name, 'track': track.position, 'year': track.album.release_date.year, 'contentType': tf.mimetype, - 'suffix': tf.extension, - 'duration': tf.duration, + 'suffix': tf.extension or '', + 'duration': tf.duration or 0, 'created': track.creation_date, 'albumId': album.pk, 'artistId': artist.pk, @@ -107,3 +108,28 @@ def test_get_album_serializer(factories): } assert serializers.GetAlbumSerializer(album).data == expected + + +def test_starred_tracks2_serializer(factories): + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + track = factories['music.Track'](album=album) + tf = factories['music.TrackFile'](track=track) + favorite = factories['favorites.TrackFavorite'](track=track) + expected = [serializers.get_track_data(album, track, tf)] + expected[0]['starred'] = favorite.creation_date + data = serializers.get_starred_tracks_data([favorite]) + assert data == expected + + +def test_get_album_list2_serializer(factories): + album1 = factories['music.Album']() + album2 = factories['music.Album']() + + qs = music_models.Album.objects.with_tracks_count().order_by('pk') + expected = [ + serializers.get_album2_data(album1), + serializers.get_album2_data(album2), + ] + data = serializers.get_album_list2_data(qs) + assert data == expected diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index daf4548b..9fd9bfe3 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -1,7 +1,10 @@ +import datetime import json import pytest +from django.utils import timezone from django.urls import reverse + from rest_framework.response import Response from funkwhale_api.music import models as music_models @@ -42,6 +45,26 @@ def test_exception_wrong_credentials(f, db, api_client): assert response.data == expected +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_license(f, db, logged_in_api_client, mocker): + url = reverse('api:subsonic-get-license') + assert url.endswith('getLicense') is True + now = timezone.now() + mocker.patch('django.utils.timezone.now', return_value=now) + response = logged_in_api_client.get(url, {'f': f}) + expected = { + 'status': 'ok', + 'version': '1.16.0', + 'license': { + 'valid': 'true', + 'email': 'valid@valid.license', + 'licenseExpires': now + datetime.timedelta(days=365) + } + } + assert response.status_code == 200 + assert response.data == expected + + @pytest.mark.parametrize('f', ['xml', 'json']) def test_ping(f, db, api_client): url = reverse('api:subsonic-ping') @@ -86,6 +109,21 @@ def test_get_artist(f, db, logged_in_api_client, factories): assert response.data == expected +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_artist_info2(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-artist-info2') + assert url.endswith('getArtistInfo2') is True + artist = factories['music.Artist']() + + expected = { + 'artist-info2': {} + } + response = logged_in_api_client.get(url, {'id': artist.pk}) + + assert response.status_code == 200 + assert response.data == expected + + @pytest.mark.parametrize('f', ['xml', 'json']) def test_get_album(f, db, logged_in_api_client, factories): url = reverse('api:subsonic-get-album') @@ -118,3 +156,87 @@ def test_stream(f, db, logged_in_api_client, factories, mocker): track_file=tf ) assert response.status_code == 200 + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_star(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-star') + assert url.endswith('star') is True + track = factories['music.Track']() + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + assert response.status_code == 200 + assert response.data == {'status': 'ok'} + + favorite = logged_in_api_client.user.track_favorites.latest('id') + assert favorite.track == track + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_unstar(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-unstar') + assert url.endswith('unstar') is True + track = factories['music.Track']() + favorite = factories['favorites.TrackFavorite']( + track=track, user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + assert response.status_code == 200 + assert response.data == {'status': 'ok'} + assert logged_in_api_client.user.track_favorites.count() == 0 + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_starred2(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-starred2') + assert url.endswith('getStarred2') is True + track = factories['music.Track']() + favorite = factories['favorites.TrackFavorite']( + track=track, user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + assert response.status_code == 200 + assert response.data == { + 'song': serializers.get_starred_tracks_data([favorite]) + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_album_list2(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-album-list2') + assert url.endswith('getAlbumList2') is True + album1 = factories['music.Album']() + album2 = factories['music.Album']() + response = logged_in_api_client.get(url, {'f': f, 'type': 'newest'}) + + assert response.status_code == 200 + assert response.data == { + 'albumList2': { + 'album': serializers.get_album_list2_data([album2, album1]) + } + } + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_search3(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-search3') + assert url.endswith('search3') is True + artist = factories['music.Artist'](name='testvalue') + factories['music.Artist'](name='nope') + album = factories['music.Album'](title='testvalue') + factories['music.Album'](title='nope') + track = factories['music.Track'](title='testvalue') + factories['music.Track'](title='nope') + + response = logged_in_api_client.get(url, {'f': f, 'query': 'testval'}) + + artist_qs = music_models.Artist.objects.with_albums_count().filter( + pk=artist.pk).values('_albums_count', 'id', 'name') + assert response.status_code == 200 + assert response.data == { + 'searchResult3': { + 'artist': [serializers.get_artist_data(a) for a in artist_qs], + 'album': serializers.get_album_list2_data([album]), + 'song': serializers.get_song_list_data([track]), + } + } -- GitLab