diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index cf5b03744..e75781d14 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 bc0c74a2d..1df949904 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 655d38755..e6253eb92 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 76fc8bc3e..98274e741 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 000000000..e69de29bb
diff --git a/api/funkwhale_api/subsonic/authentication.py b/api/funkwhale_api/subsonic/authentication.py
new file mode 100644
index 000000000..fe9b08dc8
--- /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 000000000..3335fda45
--- /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 000000000..74cf13d88
--- /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'\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 000000000..034343be0
--- /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 000000000..98fea7598
--- /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 51a1bc4c7..dda537801 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 000000000..724513523
--- /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 000000000..8e2ea3f85
--- /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 = """
+ """
+ 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'\n'
+
+ 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 000000000..3a9de7c9f
--- /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 000000000..daf4548b0
--- /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