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