From bbd273404aad600c9078a651c4498bcd9bbfce32 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Tue, 8 May 2018 16:32:07 +0200 Subject: [PATCH] See #75: initial subsonic implementation that works with http://p.subfireplayer.net --- api/config/api_urls.py | 8 +- api/funkwhale_api/music/factories.py | 2 +- api/funkwhale_api/music/models.py | 8 +- api/funkwhale_api/music/views.py | 93 ++++++------ api/funkwhale_api/subsonic/__init__.py | 0 api/funkwhale_api/subsonic/authentication.py | 69 +++++++++ api/funkwhale_api/subsonic/negotiation.py | 21 +++ api/funkwhale_api/subsonic/renderers.py | 48 +++++++ api/funkwhale_api/subsonic/serializers.py | 100 +++++++++++++ api/funkwhale_api/subsonic/views.py | 143 +++++++++++++++++++ api/tests/conftest.py | 1 + api/tests/subsonic/test_authentication.py | 56 ++++++++ api/tests/subsonic/test_renderers.py | 44 ++++++ api/tests/subsonic/test_serializers.py | 109 ++++++++++++++ api/tests/subsonic/test_views.py | 120 ++++++++++++++++ 15 files changed, 774 insertions(+), 48 deletions(-) create mode 100644 api/funkwhale_api/subsonic/__init__.py create mode 100644 api/funkwhale_api/subsonic/authentication.py create mode 100644 api/funkwhale_api/subsonic/negotiation.py create mode 100644 api/funkwhale_api/subsonic/renderers.py create mode 100644 api/funkwhale_api/subsonic/serializers.py create mode 100644 api/funkwhale_api/subsonic/views.py create mode 100644 api/tests/subsonic/test_authentication.py create mode 100644 api/tests/subsonic/test_renderers.py create mode 100644 api/tests/subsonic/test_serializers.py create mode 100644 api/tests/subsonic/test_views.py diff --git a/api/config/api_urls.py b/api/config/api_urls.py index cf5b0374..e75781d1 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -1,9 +1,11 @@ from rest_framework import routers +from rest_framework.urlpatterns import format_suffix_patterns from django.conf.urls import include, url from funkwhale_api.activity import views as activity_views from funkwhale_api.instance import views as instance_views from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views +from funkwhale_api.subsonic.views import SubsonicViewSet from rest_framework_jwt import views as jwt_views from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet @@ -27,6 +29,10 @@ router.register( 'playlist-tracks') v1_patterns = router.urls +subsonic_router = routers.SimpleRouter(trailing_slash=False) +subsonic_router.register(r'subsonic/rest', SubsonicViewSet, base_name='subsonic') + + v1_patterns += [ url(r'^instance/', include( @@ -68,4 +74,4 @@ v1_patterns += [ urlpatterns = [ url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1')) -] +] + format_suffix_patterns(subsonic_router.urls, allowed=['view']) diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index bc0c74a2..1df94990 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -26,7 +26,7 @@ class ArtistFactory(factory.django.DjangoModelFactory): class AlbumFactory(factory.django.DjangoModelFactory): title = factory.Faker('sentence', nb_words=3) mbid = factory.Faker('uuid4') - release_date = factory.Faker('date') + release_date = factory.Faker('date_object') cover = factory.django.ImageField() artist = factory.SubFactory(ArtistFactory) release_group_id = factory.Faker('uuid4') diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 655d3875..e6253eb9 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -457,7 +457,13 @@ class TrackFile(models.Model): def filename(self): return '{}{}'.format( self.track.full_name, - os.path.splitext(self.audio_file.name)[-1]) + self.extension) + + @property + def extension(self): + if not self.audio_file: + return + return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1) def save(self, **kwargs): if not self.mimetype and self.audio_file: diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 76fc8bc3..98274e74 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -245,6 +245,53 @@ def get_file_path(audio_file): return path +def handle_serve(track_file): + f = track_file + # we update the accessed_date + f.accessed_date = timezone.now() + f.save(update_fields=['accessed_date']) + + mt = f.mimetype + audio_file = f.audio_file + try: + library_track = f.library_track + except ObjectDoesNotExist: + library_track = None + if library_track and not audio_file: + if not library_track.audio_file: + # we need to populate from cache + with transaction.atomic(): + # why the transaction/select_for_update? + # this is because browsers may send multiple requests + # in a short time range, for partial content, + # thus resulting in multiple downloads from the remote + qs = LibraryTrack.objects.select_for_update() + library_track = qs.get(pk=library_track.pk) + library_track.download_audio() + audio_file = library_track.audio_file + file_path = get_file_path(audio_file) + mt = library_track.audio_mimetype + elif audio_file: + file_path = get_file_path(audio_file) + elif f.source and f.source.startswith('file://'): + file_path = get_file_path(f.source.replace('file://', '', 1)) + response = Response() + filename = f.filename + mapping = { + 'nginx': 'X-Accel-Redirect', + 'apache2': 'X-Sendfile', + } + file_header = mapping[settings.REVERSE_PROXY_TYPE] + response[file_header] = file_path + filename = "filename*=UTF-8''{}".format( + urllib.parse.quote(filename)) + response["Content-Disposition"] = "attachment; {}".format(filename) + if mt: + response["Content-Type"] = mt + + return response + + class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): queryset = (models.TrackFile.objects.all().order_by('-id')) serializer_class = serializers.TrackFileSerializer @@ -261,54 +308,10 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): 'track__artist', ) try: - f = queryset.get(pk=kwargs['pk']) + return handle_serve(queryset.get(pk=kwargs['pk'])) except models.TrackFile.DoesNotExist: return Response(status=404) - # we update the accessed_date - f.accessed_date = timezone.now() - f.save(update_fields=['accessed_date']) - - mt = f.mimetype - audio_file = f.audio_file - try: - library_track = f.library_track - except ObjectDoesNotExist: - library_track = None - if library_track and not audio_file: - if not library_track.audio_file: - # we need to populate from cache - with transaction.atomic(): - # why the transaction/select_for_update? - # this is because browsers may send multiple requests - # in a short time range, for partial content, - # thus resulting in multiple downloads from the remote - qs = LibraryTrack.objects.select_for_update() - library_track = qs.get(pk=library_track.pk) - library_track.download_audio() - audio_file = library_track.audio_file - file_path = get_file_path(audio_file) - mt = library_track.audio_mimetype - elif audio_file: - file_path = get_file_path(audio_file) - elif f.source and f.source.startswith('file://'): - file_path = get_file_path(f.source.replace('file://', '', 1)) - response = Response() - filename = f.filename - mapping = { - 'nginx': 'X-Accel-Redirect', - 'apache2': 'X-Sendfile', - } - file_header = mapping[settings.REVERSE_PROXY_TYPE] - response[file_header] = file_path - filename = "filename*=UTF-8''{}".format( - urllib.parse.quote(filename)) - response["Content-Disposition"] = "attachment; {}".format(filename) - if mt: - response["Content-Type"] = mt - - return response - @list_route(methods=['get']) def viewable(self, request, *args, **kwargs): return Response({}, status=200) diff --git a/api/funkwhale_api/subsonic/__init__.py b/api/funkwhale_api/subsonic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/funkwhale_api/subsonic/authentication.py b/api/funkwhale_api/subsonic/authentication.py new file mode 100644 index 00000000..fe9b08dc --- /dev/null +++ b/api/funkwhale_api/subsonic/authentication.py @@ -0,0 +1,69 @@ +import binascii +import hashlib + +from rest_framework import authentication +from rest_framework import exceptions + +from funkwhale_api.users.models import User + + +def get_token(salt, password): + to_hash = password + salt + h = hashlib.md5() + h.update(to_hash.encode('utf-8')) + return h.hexdigest() + + +def authenticate(username, password): + try: + if password.startswith('enc:'): + password = password.replace('enc:', '', 1) + password = binascii.unhexlify(password).decode('utf-8') + user = User.objects.get( + username=username, + is_active=True, + subsonic_api_token=password) + except (User.DoesNotExist, binascii.Error): + raise exceptions.AuthenticationFailed( + 'Wrong username or password.' + ) + + return (user, None) + + +def authenticate_salt(username, salt, token): + try: + user = User.objects.get( + username=username, + is_active=True, + subsonic_api_token__isnull=False) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed( + 'Wrong username or password.' + ) + expected = get_token(salt, user.subsonic_api_token) + if expected != token: + raise exceptions.AuthenticationFailed( + 'Wrong username or password.' + ) + + return (user, None) + + +class SubsonicAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + data = request.GET or request.POST + username = data.get('u') + if not username: + return None + + p = data.get('p') + s = data.get('s') + t = data.get('t') + if not p and (not s or not t): + raise exceptions.AuthenticationFailed('Missing credentials') + + if p: + return authenticate(username, p) + + return authenticate_salt(username, s, t) diff --git a/api/funkwhale_api/subsonic/negotiation.py b/api/funkwhale_api/subsonic/negotiation.py new file mode 100644 index 00000000..3335fda4 --- /dev/null +++ b/api/funkwhale_api/subsonic/negotiation.py @@ -0,0 +1,21 @@ +from rest_framework import exceptions +from rest_framework import negotiation + +from . import renderers + + +MAPPING = { + 'json': (renderers.SubsonicJSONRenderer(), 'application/json'), + 'xml': (renderers.SubsonicXMLRenderer(), 'text/xml'), +} + + +class SubsonicContentNegociation(negotiation.DefaultContentNegotiation): + def select_renderer(self, request, renderers, format_suffix=None): + path = request.path + data = request.GET or request.POST + requested_format = data.get('f', 'xml') + try: + return MAPPING[requested_format] + except KeyError: + raise exceptions.NotAcceptable(available_renderers=renderers) diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py new file mode 100644 index 00000000..74cf13d8 --- /dev/null +++ b/api/funkwhale_api/subsonic/renderers.py @@ -0,0 +1,48 @@ +import xml.etree.ElementTree as ET + +from rest_framework import renderers + + +class SubsonicJSONRenderer(renderers.JSONRenderer): + def render(self, data, accepted_media_type=None, renderer_context=None): + if not data: + # when stream view is called, we don't have any data + return super().render(data, accepted_media_type, renderer_context) + final = { + 'subsonic-response': { + 'status': 'ok', + 'version': '1.16.0', + } + } + final['subsonic-response'].update(data) + return super().render(final, accepted_media_type, renderer_context) + + +class SubsonicXMLRenderer(renderers.JSONRenderer): + media_type = 'text/xml' + + def render(self, data, accepted_media_type=None, renderer_context=None): + if not data: + # when stream view is called, we don't have any data + return super().render(data, accepted_media_type, renderer_context) + final = { + 'xmlns': 'http://subsonic.org/restapi', + 'status': 'ok', + 'version': '1.16.0', + } + final.update(data) + tree = dict_to_xml_tree('subsonic-response', final) + return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(tree, encoding='utf-8') + + +def dict_to_xml_tree(root_tag, d, parent=None): + root = ET.Element(root_tag) + for key, value in d.items(): + if isinstance(value, dict): + root.append(dict_to_xml_tree(key, value, parent=root)) + elif isinstance(value, list): + for obj in value: + root.append(dict_to_xml_tree(key, obj, parent=root)) + else: + root.set(key, str(value)) + return root diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py new file mode 100644 index 00000000..034343be --- /dev/null +++ b/api/funkwhale_api/subsonic/serializers.py @@ -0,0 +1,100 @@ +import collections + +from django.db.models import functions, Count + +from rest_framework import serializers + + +class GetArtistsSerializer(serializers.Serializer): + def to_representation(self, queryset): + payload = { + 'ignoredArticles': '', + 'index': [] + } + queryset = queryset.annotate(_albums_count=Count('albums')) + queryset = queryset.order_by(functions.Lower('name')) + values = queryset.values('id', '_albums_count', 'name') + + first_letter_mapping = collections.defaultdict(list) + for artist in values: + first_letter_mapping[artist['name'][0].upper()].append(artist) + + for letter, artists in sorted(first_letter_mapping.items()): + letter_data = { + 'name': letter, + 'artist': [ + { + 'id': v['id'], + 'name': v['name'], + 'albumCount': v['_albums_count'] + } + for v in artists + ] + } + payload['index'].append(letter_data) + return payload + + +class GetArtistSerializer(serializers.Serializer): + def to_representation(self, artist): + albums = artist.albums.prefetch_related('tracks__files') + payload = { + 'id': artist.pk, + 'name': artist.name, + 'albumCount': len(albums), + 'album': [], + } + for album in albums: + album_data = { + 'id': album.id, + 'artistId': artist.id, + 'name': album.title, + 'artist': artist.name, + 'created': album.creation_date, + 'songCount': len(album.tracks.all()) + } + if album.release_date: + album_data['year'] = album.release_date.year + payload['album'].append(album_data) + return payload + + +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': [], + } + 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) + return payload diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py new file mode 100644 index 00000000..98fea759 --- /dev/null +++ b/api/funkwhale_api/subsonic/views.py @@ -0,0 +1,143 @@ +from rest_framework import exceptions +from rest_framework import permissions as rest_permissions +from rest_framework import response +from rest_framework import viewsets +from rest_framework.decorators import list_route +from rest_framework.serializers import ValidationError + +from funkwhale_api.music import models as music_models +from funkwhale_api.music import views as music_views + +from . import authentication +from . import negotiation +from . import serializers + + +def find_object(queryset, model_field='pk', field='id', cast=int): + def decorator(func): + def inner(self, request, *args, **kwargs): + data = request.GET or request.POST + try: + raw_value = data[field] + except KeyError: + return response.Response({ + 'code': 10, + 'message': "required parameter '{}' not present".format(field) + }) + try: + value = cast(raw_value) + except (TypeError, ValidationError): + return response.Response({ + 'code': 0, + 'message': 'For input string "{}"'.format(raw_value) + }) + try: + obj = queryset.get(**{model_field: value}) + except queryset.model.DoesNotExist: + return response.Response({ + 'code': 70, + 'message': '{} not found'.format( + queryset.model.__class__.__name__) + }) + kwargs['obj'] = obj + return func(self, request, *args, **kwargs) + return inner + return decorator + + +class SubsonicViewSet(viewsets.GenericViewSet): + content_negotiation_class = negotiation.SubsonicContentNegociation + authentication_classes = [authentication.SubsonicAuthentication] + permissions_classes = [rest_permissions.IsAuthenticated] + + def handle_exception(self, exc): + # subsonic API sends 200 status code with custom error + # codes in the payload + mapping = { + exceptions.AuthenticationFailed: ( + 40, 'Wrong username or password.' + ) + } + payload = { + 'status': 'failed' + } + try: + code, message = mapping[exc.__class__] + except KeyError: + return super().handle_exception(exc) + else: + payload['error'] = { + 'code': code, + 'message': message + } + + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + permission_classes=[]) + def ping(self, request, *args, **kwargs): + data = { + 'status': 'ok', + 'version': '1.16.0' + } + return response.Response(data, status=200) + + @list_route( + methods=['get', 'post'], + url_name='get_artists', + url_path='getArtists') + def get_artists(self, request, *args, **kwargs): + artists = music_models.Artist.objects.all() + data = serializers.GetArtistsSerializer(artists).data + payload = { + 'artists': data + } + + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + url_name='get_artist', + url_path='getArtist') + @find_object(music_models.Artist.objects.all()) + def get_artist(self, request, *args, **kwargs): + artist = kwargs.pop('obj') + data = serializers.GetArtistSerializer(artist).data + payload = { + 'artist': data + } + + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + url_name='get_album', + url_path='getAlbum') + @find_object( + music_models.Album.objects.select_related('artist')) + def get_album(self, request, *args, **kwargs): + album = kwargs.pop('obj') + data = serializers.GetAlbumSerializer(album).data + payload = { + 'album': data + } + return response.Response(payload, status=200) + + @list_route( + methods=['get', 'post'], + url_name='stream', + url_path='stream') + @find_object( + music_models.Track.objects.all()) + def stream(self, request, *args, **kwargs): + track = kwargs.pop('obj') + queryset = track.files.select_related( + 'library_track', + 'track__album__artist', + 'track__artist', + ) + track_file = queryset.first() + if not track_file: + return Response(status=404) + return music_views.handle_serve(track_file) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 51a1bc4c..dda53780 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -130,6 +130,7 @@ def logged_in_api_client(db, factories, api_client): """ user = factories['users.User']() assert api_client.login(username=user.username, password='test') + api_client.force_authenticate(user=user) setattr(api_client, 'user', user) yield api_client delattr(api_client, 'user') diff --git a/api/tests/subsonic/test_authentication.py b/api/tests/subsonic/test_authentication.py new file mode 100644 index 00000000..72451352 --- /dev/null +++ b/api/tests/subsonic/test_authentication.py @@ -0,0 +1,56 @@ +import binascii + +from funkwhale_api.subsonic import authentication + + +def test_auth_with_salt(api_request, factories): + salt = 'salt' + user = factories['users.User']() + user.subsonic_api_token = 'password' + user.save() + token = authentication.get_token(salt, 'password') + request = api_request.get('/', { + 't': token, + 's': salt, + 'u': user.username + }) + + authenticator = authentication.SubsonicAuthentication() + u, _ = authenticator.authenticate(request) + + assert user == u + + +def test_auth_with_password_hex(api_request, factories): + salt = 'salt' + user = factories['users.User']() + user.subsonic_api_token = 'password' + user.save() + token = authentication.get_token(salt, 'password') + request = api_request.get('/', { + 'u': user.username, + 'p': 'enc:{}'.format(binascii.hexlify( + user.subsonic_api_token.encode('utf-8')).decode('utf-8')) + }) + + authenticator = authentication.SubsonicAuthentication() + u, _ = authenticator.authenticate(request) + + assert user == u + + +def test_auth_with_password_cleartext(api_request, factories): + salt = 'salt' + user = factories['users.User']() + user.subsonic_api_token = 'password' + user.save() + token = authentication.get_token(salt, 'password') + request = api_request.get('/', { + 'u': user.username, + 'p': 'password', + }) + + authenticator = authentication.SubsonicAuthentication() + u, _ = authenticator.authenticate(request) + + assert user == u diff --git a/api/tests/subsonic/test_renderers.py b/api/tests/subsonic/test_renderers.py new file mode 100644 index 00000000..8e2ea3f8 --- /dev/null +++ b/api/tests/subsonic/test_renderers.py @@ -0,0 +1,44 @@ +import json +import xml.etree.ElementTree as ET + +from funkwhale_api.subsonic import renderers + + +def test_json_renderer(): + data = {'hello': 'world'} + expected = { + 'subsonic-response': { + 'status': 'ok', + 'version': '1.16.0', + 'hello': 'world' + } + } + renderer = renderers.SubsonicJSONRenderer() + assert json.loads(renderer.render(data)) == expected + + +def test_xml_renderer_dict_to_xml(): + payload = { + 'hello': 'world', + 'item': [ + {'this': 1}, + {'some': 'node'}, + ] + } + expected = """<?xml version="1.0" encoding="UTF-8"?> +<key hello="world"><item this="1" /><item some="node" /></key>""" + result = renderers.dict_to_xml_tree('key', payload) + exp = ET.fromstring(expected) + assert ET.tostring(result) == ET.tostring(exp) + + +def test_xml_renderer(): + payload = { + 'hello': 'world', + } + expected = b'<?xml version="1.0" encoding="UTF-8"?>\n<subsonic-response hello="world" status="ok" version="1.16.0" xmlns="http://subsonic.org/restapi" />' + + renderer = renderers.SubsonicXMLRenderer() + rendered = renderer.render(payload) + + assert rendered == expected diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py new file mode 100644 index 00000000..3a9de7c9 --- /dev/null +++ b/api/tests/subsonic/test_serializers.py @@ -0,0 +1,109 @@ +from funkwhale_api.subsonic import serializers + + +def test_get_artists_serializer(factories): + artist1 = factories['music.Artist'](name='eliot') + artist2 = factories['music.Artist'](name='Ellena') + artist3 = factories['music.Artist'](name='Rilay') + + factories['music.Album'].create_batch(size=3, artist=artist1) + factories['music.Album'].create_batch(size=2, artist=artist2) + + expected = { + 'ignoredArticles': '', + 'index': [ + { + 'name': 'E', + 'artist': [ + { + 'id': artist1.pk, + 'name': artist1.name, + 'albumCount': 3, + }, + { + 'id': artist2.pk, + 'name': artist2.name, + 'albumCount': 2, + }, + ] + }, + { + 'name': 'R', + 'artist': [ + { + 'id': artist3.pk, + 'name': artist3.name, + 'albumCount': 0, + }, + ] + }, + ] + } + + queryset = artist1.__class__.objects.filter(pk__in=[ + artist1.pk, artist2.pk, artist3.pk + ]) + + assert serializers.GetArtistsSerializer(queryset).data == expected + + +def test_get_artist_serializer(factories): + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + tracks = factories['music.Track'].create_batch(size=3, album=album) + + expected = { + 'id': artist.pk, + 'name': artist.name, + 'albumCount': 1, + 'album': [ + { + 'id': album.pk, + 'artistId': artist.pk, + 'name': album.title, + 'artist': artist.name, + 'songCount': len(tracks), + 'created': album.creation_date, + 'year': album.release_date.year, + } + ] + } + + assert serializers.GetArtistSerializer(artist).data == expected + + +def test_get_album_serializer(factories): + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + track = factories['music.Track'](album=album) + tf = factories['music.TrackFile'](track=track) + + expected = { + 'id': album.pk, + 'artistId': artist.pk, + 'name': album.title, + 'artist': artist.name, + 'songCount': 1, + 'created': album.creation_date, + 'year': album.release_date.year, + 'song': [ + { + 'id': track.pk, + '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, + 'created': track.creation_date, + 'albumId': album.pk, + 'artistId': artist.pk, + 'type': 'music', + } + ] + } + + assert serializers.GetAlbumSerializer(album).data == expected diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py new file mode 100644 index 00000000..daf4548b --- /dev/null +++ b/api/tests/subsonic/test_views.py @@ -0,0 +1,120 @@ +import json +import pytest + +from django.urls import reverse +from rest_framework.response import Response + +from funkwhale_api.music import models as music_models +from funkwhale_api.music import views as music_views +from funkwhale_api.subsonic import renderers +from funkwhale_api.subsonic import serializers + + +def render_json(data): + return json.loads(renderers.SubsonicJSONRenderer().render(data)) + + +def test_render_content_json(db, api_client): + url = reverse('api:subsonic-ping') + response = api_client.get(url, {'f': 'json'}) + + expected = { + 'status': 'ok', + 'version': '1.16.0' + } + assert response.status_code == 200 + assert json.loads(response.content) == render_json(expected) + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_exception_wrong_credentials(f, db, api_client): + url = reverse('api:subsonic-ping') + response = api_client.get(url, {'f': f, 'u': 'yolo'}) + + expected = { + 'status': 'failed', + 'error': { + 'code': 40, + 'message': 'Wrong username or password.' + } + } + 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') + response = api_client.get(url, {'f': f}) + + expected = { + 'status': 'ok', + 'version': '1.16.0', + } + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_artists(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-artists') + assert url.endswith('getArtists') is True + artists = factories['music.Artist'].create_batch(size=10) + expected = { + 'artists': serializers.GetArtistsSerializer( + music_models.Artist.objects.all() + ).data + } + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_get_artist(f, db, logged_in_api_client, factories): + url = reverse('api:subsonic-get-artist') + assert url.endswith('getArtist') is True + artist = factories['music.Artist']() + albums = factories['music.Album'].create_batch(size=3, artist=artist) + expected = { + 'artist': serializers.GetArtistSerializer(artist).data + } + 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') + assert url.endswith('getAlbum') is True + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + tracks = factories['music.Track'].create_batch(size=3, album=album) + expected = { + 'album': 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 + + +@pytest.mark.parametrize('f', ['xml', 'json']) +def test_stream(f, db, logged_in_api_client, factories, mocker): + url = reverse('api:subsonic-stream') + mocked_serve = mocker.spy( + music_views, 'handle_serve') + assert url.endswith('stream') is True + artist = factories['music.Artist']() + album = factories['music.Album'](artist=artist) + track = factories['music.Track'](album=album) + tf = factories['music.TrackFile'](track=track) + response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + + mocked_serve.assert_called_once_with( + track_file=tf + ) + assert response.status_code == 200 -- GitLab