diff --git a/CHANGELOG b/CHANGELOG
index 82c867bf890d619bd9c10177a75f906a4445c56a..ba9b9f1ae0dbfac417fdb857eb46fbdb7718d744 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -10,7 +10,155 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
 
 .. towncrier
 
-0.11 (unreleased)
+0.12 (2018-05-09)
+-----------------
+
+Upgrade instructions are available at
+  https://docs.funkwhale.audio/upgrading.html
+
+Features:
+
+- Subsonic API implementation to offer compatibility with existing clients such
+  as DSub (#75)
+- Use nodeinfo standard for publishing instance information (#192)
+
+
+Enhancements:
+
+- Play button now play tracks immediately instead of appending them to the
+  queue (#99, #156)
+
+
+Bugfixes:
+
+- Fix broken federated import (#193)
+
+
+Documentation:
+
+- Up-to-date documentation for upgrading front-end files on docker setup (#132)
+
+
+Subsonic API
+^^^^^^^^^^^^
+
+This release implements some core parts of the Subsonic API, which is widely
+deployed in various projects and supported by numerous clients.
+
+By offering this API in Funkwhale, we make it possible to access the instance
+library and listen to the music without from existing Subsonic clients, and
+without developping our own alternative clients for each and every platform.
+
+Most advanced Subsonic clients support offline caching of music files,
+playlist management and search, which makes them well-suited for nomadic use.
+
+Please head over :doc:`users/apps` for more informations about supported clients
+and user instructions.
+
+At the instance-level, the Subsonic API is enabled by default, but require
+and additional endpoint to be added in you reverse-proxy configuration.
+
+On nginx, add the following block::
+
+    location /rest/ {
+        include /etc/nginx/funkwhale_proxy.conf;
+        proxy_pass   http://funkwhale-api/api/subsonic/rest/;
+    }
+
+On Apache, add the following block::
+
+    <Location "/rest">
+        ProxyPass ${funkwhale-api}/api/subsonic/rest
+        ProxyPassReverse ${funkwhale-api}/api/subsonic/rest
+    </Location>
+
+The Subsonic can be disabled at the instance level from the django admin.
+
+.. note::
+
+    Because of Subsonic's API design which assumes cleartext storing of
+    user passwords, we chose to have a dedicated, separate password
+    for that purpose. Users can generate this password from their
+    settings page in the web client.
+
+
+Nodeinfo standard for instance information and stats
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. warning::
+
+    The ``/api/v1/instance/stats/`` endpoint which was used to display
+    instance data in the about page is removed in favor of the new
+    ``/api/v1/instance/nodeinfo/2.0/`` endpoint.
+
+In earlier version, we where using a custom endpoint and format for
+our instance information and statistics. While this was working,
+this was not compatible with anything else on the fediverse.
+
+We now offer a nodeinfo 2.0 endpoint which provides, in a single place,
+all the instance information such as library and user activity statistics,
+public instance settings (description, registration and federation status, etc.).
+
+We offer two settings to manage nodeinfo in your Funkwhale instance:
+
+1. One setting to completely disable nodeinfo, but this is not recommended
+   as the exposed data may be needed to make some parts of the front-end
+   work (especially the about page).
+2. One setting to disable only usage and library statistics in the nodeinfo
+   endpoint. This is useful if you want the nodeinfo endpoint to work,
+   but don't feel comfortable sharing aggregated statistics about your library
+   and user activity.
+
+To make your instance fully compatible with the nodeinfo protocol, you need to
+to edit your nginx configuration file:
+
+.. code-block::
+
+    # before
+    ...
+    location /.well-known/webfinger {
+        include /etc/nginx/funkwhale_proxy.conf;
+        proxy_pass   http://funkwhale-api/.well-known/webfinger;
+    }
+    ...
+
+    # after
+    ...
+    location /.well-known/ {
+        include /etc/nginx/funkwhale_proxy.conf;
+        proxy_pass   http://funkwhale-api/.well-known/;
+    }
+    ...
+
+You can do the same if you use apache:
+
+.. code-block::
+
+    # before
+    ...
+    <Location "/.well-known/webfinger">
+      ProxyPass ${funkwhale-api}/.well-known/webfinger
+      ProxyPassReverse ${funkwhale-api}/.well-known/webfinger
+    </Location>
+    ...
+
+    # after
+    ...
+    <Location "/.well-known/">
+      ProxyPass ${funkwhale-api}/.well-known/
+      ProxyPassReverse ${funkwhale-api}/.well-known/
+    </Location>
+    ...
+
+This will ensure all well-known endpoints are proxied to funkwhale, and
+not just webfinger one.
+
+Links:
+
+- About nodeinfo: https://github.com/jhass/nodeinfo
+
+
+0.11 (2018-05-06)
 -----------------
 
 Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html
diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index cf5b03744d8b12d5a91fd43df4edbad941870389..e75781d14c3061892520290867d068f29991a016 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/config/settings/common.py b/api/config/settings/common.py
index f88aa5dd549fe09afd1c41999180e481dd2f151a..5fed9f25e86d065aff3b8f4ab662d38c65870aaf 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -133,6 +133,7 @@ LOCAL_APPS = (
     'funkwhale_api.providers.audiofile',
     'funkwhale_api.providers.youtube',
     'funkwhale_api.providers.acoustid',
+    'funkwhale_api.subsonic',
 )
 
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
diff --git a/api/demo/demo-user.py b/api/demo/demo-user.py
index 64f48f9aa06bb9cb406306929d975025ad440c9b..4f8648fb37288ad6ff18d59be88ea3bac207fdcc 100644
--- a/api/demo/demo-user.py
+++ b/api/demo/demo-user.py
@@ -3,4 +3,5 @@ from funkwhale_api.users.models import User
 
 u = User.objects.create(email='demo@demo.com', username='demo', is_staff=True)
 u.set_password('demo')
+u.subsonic_api_token = 'demo'
 u.save()
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index 4f62dd9b5b08542e8fa55eaae0920bda4edd6296..f8b8af4126f34f3e55477c22d0c3a985ad827763 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.11'
+__version__ = '0.12'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 9b51a534df506169be39b66d75becca94bb5d90c..ef581408c2e187bf32f3922c651b903f3583884e 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -85,13 +85,31 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
         return response.Response({}, status=200)
 
 
-class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
+class WellKnownViewSet(viewsets.GenericViewSet):
     authentication_classes = []
     permission_classes = []
     renderer_classes = [renderers.WebfingerRenderer]
 
+    @list_route(methods=['get'])
+    def nodeinfo(self, request, *args, **kwargs):
+        if not preferences.get('instance__nodeinfo_enabled'):
+            return HttpResponse(status=404)
+        data = {
+            'links': [
+                {
+                    'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
+                    'href': utils.full_url(
+                        reverse('api:v1:instance:nodeinfo-2.0')
+                    )
+                }
+            ]
+        }
+        return response.Response(data)
+
     @list_route(methods=['get'])
     def webfinger(self, request, *args, **kwargs):
+        if not preferences.get('federation__enabled'):
+            return HttpResponse(status=405)
         try:
             resource_type, resource = webfinger.clean_resource(
                 request.GET['resource'])
diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py
index 1d11a2988ca1d76da7b61d8ef8657ac6cdee74df..03555b0be57bde18f4fe13ba1b62d852ffd93ae4 100644
--- a/api/funkwhale_api/instance/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py
@@ -68,3 +68,31 @@ class RavenEnabled(types.BooleanPreference):
         'Wether error reporting to a Sentry instance using raven is enabled'
         ' for front-end errors'
     )
+
+
+@global_preferences_registry.register
+class InstanceNodeinfoEnabled(types.BooleanPreference):
+    show_in_api = False
+    section = instance
+    name = 'nodeinfo_enabled'
+    default = True
+    verbose_name = 'Enable nodeinfo endpoint'
+    help_text = (
+        'This endpoint is needed for your about page to work.'
+        'It\'s also helpful for the various monitoring '
+        'tools that map and analyzize the fediverse, '
+        'but you can disable it completely if needed.'
+    )
+
+
+@global_preferences_registry.register
+class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
+    show_in_api = False
+    section = instance
+    name = 'nodeinfo_stats_enabled'
+    default = True
+    verbose_name = 'Enable usage and library stats in nodeinfo endpoint'
+    help_text = (
+        'Disable this f you don\'t want to share usage and library statistics'
+        'in the nodeinfo endpoint but don\'t want to disable it completely.'
+    )
diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py
new file mode 100644
index 0000000000000000000000000000000000000000..e267f197d17ce7c317128c684f1db8f72a1a47ba
--- /dev/null
+++ b/api/funkwhale_api/instance/nodeinfo.py
@@ -0,0 +1,73 @@
+import memoize.djangocache
+
+import funkwhale_api
+from funkwhale_api.common import preferences
+
+from . import stats
+
+
+store = memoize.djangocache.Cache('default')
+memo = memoize.Memoizer(store, namespace='instance:stats')
+
+
+def get():
+    share_stats = preferences.get('instance__nodeinfo_stats_enabled')
+    data = {
+        'version': '2.0',
+        'software': {
+            'name': 'funkwhale',
+            'version': funkwhale_api.__version__
+        },
+        'protocols': ['activitypub'],
+        'services': {
+            'inbound': [],
+            'outbound': []
+        },
+        'openRegistrations': preferences.get('users__registration_enabled'),
+        'usage': {
+            'users': {
+                'total': 0,
+            }
+        },
+        'metadata': {
+            'shortDescription': preferences.get('instance__short_description'),
+            'longDescription': preferences.get('instance__long_description'),
+            'nodeName': preferences.get('instance__name'),
+            'library': {
+                'federationEnabled': preferences.get('federation__enabled'),
+                'federationNeedsApproval': preferences.get('federation__music_needs_approval'),
+                'anonymousCanListen': preferences.get('common__api_authentication_required'),
+            },
+        }
+    }
+    if share_stats:
+        getter = memo(
+            lambda: stats.get(),
+            max_age=600
+        )
+        statistics = getter()
+        data['usage']['users']['total'] = statistics['users']
+        data['metadata']['library']['tracks'] = {
+            'total': statistics['tracks'],
+        }
+        data['metadata']['library']['artists'] = {
+            'total': statistics['artists'],
+        }
+        data['metadata']['library']['albums'] = {
+            'total': statistics['albums'],
+        }
+        data['metadata']['library']['music'] = {
+            'hours': statistics['music_duration']
+        }
+
+        data['metadata']['usage'] = {
+            'favorites': {
+                'tracks': {
+                    'total': statistics['track_favorites'],
+                }
+            },
+            'listenings': {
+                'total': statistics['listenings']
+            }
+        }
+    return data
diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py
index af23e7e08433b97c6c10f07e1b929892e5dbe32c..f506488fc4db7819da6aae5d5c79fe33e8a9af5c 100644
--- a/api/funkwhale_api/instance/urls.py
+++ b/api/funkwhale_api/instance/urls.py
@@ -1,11 +1,9 @@
 from django.conf.urls import url
-from django.views.decorators.cache import cache_page
 
 from . import views
 
 
 urlpatterns = [
+    url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'),
     url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
-    url(r'^stats/$',
-        cache_page(60 * 5)(views.InstanceStats.as_view()), name='stats'),
 ]
diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py
index 7f8f393c964e24bfc7a5ee29bf6be2e30f337188..5953ca555a3081d5e58a1d60da1d3dec58279e3b 100644
--- a/api/funkwhale_api/instance/views.py
+++ b/api/funkwhale_api/instance/views.py
@@ -4,9 +4,17 @@ from rest_framework.response import Response
 from dynamic_preferences.api import serializers
 from dynamic_preferences.registries import global_preferences_registry
 
+from funkwhale_api.common import preferences
+
+from . import nodeinfo
 from . import stats
 
 
+NODEINFO_2_CONTENT_TYPE = (
+    'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8'  # noqa
+)
+
+
 class InstanceSettings(views.APIView):
     permission_classes = []
     authentication_classes = []
@@ -27,10 +35,13 @@ class InstanceSettings(views.APIView):
         return Response(data, status=200)
 
 
-class InstanceStats(views.APIView):
+class NodeInfo(views.APIView):
     permission_classes = []
     authentication_classes = []
 
     def get(self, request, *args, **kwargs):
-        data = stats.get()
-        return Response(data, status=200)
+        if not preferences.get('instance__nodeinfo_enabled'):
+            return Response(status=404)
+        data = nodeinfo.get()
+        return Response(
+            data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py
index bc0c74a2d0c108330e274d71522bfed9cccc8554..1df949904db992e2cd295d02e4acbfed9d212627 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 655d38755e1deba8e4bd3e99942c0ea7b586af5d..5ee5d851dc122f2aa632952eed2b218d76b0dfd7 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -76,6 +76,11 @@ class APIModelMixin(models.Model):
                 self.musicbrainz_model, self.mbid)
 
 
+class ArtistQuerySet(models.QuerySet):
+    def with_albums_count(self):
+        return self.annotate(_albums_count=models.Count('albums'))
+
+
 class Artist(APIModelMixin):
     name = models.CharField(max_length=255)
 
@@ -89,6 +94,7 @@ class Artist(APIModelMixin):
         }
     }
     api = musicbrainz.api.artists
+    objects = ArtistQuerySet.as_manager()
 
     def __str__(self):
         return self.name
@@ -106,7 +112,7 @@ class Artist(APIModelMixin):
         kwargs.update({'name': name})
         return cls.objects.get_or_create(
             name__iexact=name,
-            defaults=kwargs)[0]
+            defaults=kwargs)
 
 
 def import_artist(v):
@@ -129,6 +135,11 @@ def import_tracks(instance, cleaned_data, raw_data):
         track = importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
 
 
+class AlbumQuerySet(models.QuerySet):
+    def with_tracks_count(self):
+        return self.annotate(_tracks_count=models.Count('tracks'))
+
+
 class Album(APIModelMixin):
     title = models.CharField(max_length=255)
     artist = models.ForeignKey(
@@ -173,6 +184,7 @@ class Album(APIModelMixin):
             'converter': import_artist,
         }
     }
+    objects = AlbumQuerySet.as_manager()
 
     def get_image(self):
         image_data =  musicbrainz.api.images.get_front(str(self.mbid))
@@ -196,7 +208,7 @@ class Album(APIModelMixin):
         kwargs.update({'title': title})
         return cls.objects.get_or_create(
             title__iexact=title,
-            defaults=kwargs)[0]
+            defaults=kwargs)
 
 
 def import_tags(instance, cleaned_data, raw_data):
@@ -403,7 +415,7 @@ class Track(APIModelMixin):
         kwargs.update({'title': title})
         return cls.objects.get_or_create(
             title__iexact=title,
-            defaults=kwargs)[0]
+            defaults=kwargs)
 
 
 class TrackFile(models.Model):
@@ -457,7 +469,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/tasks.py b/api/funkwhale_api/music/tasks.py
index 4509c9a57e6faf11b63d21c98af189a940c30677..bad0006aa98520e2afac00aad8d5466bde2b8934 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -39,7 +39,7 @@ def import_track_from_remote(library_track):
     except (KeyError, AssertionError):
         pass
     else:
-        return models.Track.get_or_create_from_api(mbid=track_mbid)
+        return models.Track.get_or_create_from_api(mbid=track_mbid)[0]
 
     try:
         album_mbid = metadata['release']['musicbrainz_id']
@@ -47,9 +47,9 @@ def import_track_from_remote(library_track):
     except (KeyError, AssertionError):
         pass
     else:
-        album = models.Album.get_or_create_from_api(mbid=album_mbid)
+        album, _ = models.Album.get_or_create_from_api(mbid=album_mbid)
         return models.Track.get_or_create_from_title(
-            library_track.title, artist=album.artist, album=album)
+            library_track.title, artist=album.artist, album=album)[0]
 
     try:
         artist_mbid = metadata['artist']['musicbrainz_id']
@@ -57,20 +57,20 @@ def import_track_from_remote(library_track):
     except (KeyError, AssertionError):
         pass
     else:
-        artist = models.Artist.get_or_create_from_api(mbid=artist_mbid)
-        album = models.Album.get_or_create_from_title(
+        artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid)
+        album, _ = models.Album.get_or_create_from_title(
             library_track.album_title, artist=artist)
         return models.Track.get_or_create_from_title(
-            library_track.title, artist=artist, album=album)
+            library_track.title, artist=artist, album=album)[0]
 
     # worst case scenario, we have absolutely no way to link to a
     # musicbrainz resource, we rely on the name/titles
-    artist = models.Artist.get_or_create_from_name(
+    artist, _ = models.Artist.get_or_create_from_name(
         library_track.artist_name)
-    album = models.Album.get_or_create_from_title(
+    album, _ = models.Album.get_or_create_from_title(
         library_track.album_title, artist=artist)
     return models.Track.get_or_create_from_title(
-        library_track.title, artist=artist, album=album)
+        library_track.title, artist=artist, album=album)[0]
 
 
 def _do_import(import_job, replace=False, use_acoustid=True):
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 76fc8bc3e7fb257962c3388cd904223ff0c5d9d3..98274e741293f6ef9b4b9cf9ce5f59e32eb8815f 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/playlists/models.py b/api/funkwhale_api/playlists/models.py
index a208a5fd05e82258ad887b8f7dac06facf6f1167..f5132e12dd9d9beeb03db32090310d46b9efc6b9 100644
--- a/api/funkwhale_api/playlists/models.py
+++ b/api/funkwhale_api/playlists/models.py
@@ -9,6 +9,12 @@ from funkwhale_api.common import fields
 from funkwhale_api.common import preferences
 
 
+class PlaylistQuerySet(models.QuerySet):
+    def with_tracks_count(self):
+        return self.annotate(
+            _tracks_count=models.Count('playlist_tracks'))
+
+
 class Playlist(models.Model):
     name = models.CharField(max_length=50)
     user = models.ForeignKey(
@@ -18,6 +24,8 @@ class Playlist(models.Model):
         auto_now=True)
     privacy_level = fields.get_privacy_field()
 
+    objects = PlaylistQuerySet.as_manager()
+
     def __str__(self):
         return self.name
 
diff --git a/api/funkwhale_api/subsonic/__init__.py b/api/funkwhale_api/subsonic/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/subsonic/authentication.py b/api/funkwhale_api/subsonic/authentication.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe9b08dc8afda3c0171dd671b79e9ba20ea7ca05
--- /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/dynamic_preferences_registry.py b/api/funkwhale_api/subsonic/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..93482702ff110e39885dc72395836598f4e7e6bc
--- /dev/null
+++ b/api/funkwhale_api/subsonic/dynamic_preferences_registry.py
@@ -0,0 +1,22 @@
+from dynamic_preferences import types
+from dynamic_preferences.registries import global_preferences_registry
+
+from funkwhale_api.common import preferences
+
+subsonic = types.Section('subsonic')
+
+
+@global_preferences_registry.register
+class APIAutenticationRequired(types.BooleanPreference):
+    section = subsonic
+    show_in_api = True
+    name = 'enabled'
+    default = True
+    verbose_name = 'Enabled Subsonic API'
+    help_text = (
+        'Funkwhale supports a subset of the Subsonic API, that makes '
+        'it compatible with existing clients such as DSub for Android '
+        'or Clementine for desktop. However, Subsonic protocol is less '
+        'than ideal in terms of security and you can disable this feature '
+        'completely using this flag.'
+    )
diff --git a/api/funkwhale_api/subsonic/filters.py b/api/funkwhale_api/subsonic/filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..b7b639fac319bc6f8da17ead83f23f52324c23c7
--- /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/negotiation.py b/api/funkwhale_api/subsonic/negotiation.py
new file mode 100644
index 0000000000000000000000000000000000000000..3335fda45b2d59564b6687bb686cc98b6cf93700
--- /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 0000000000000000000000000000000000000000..74cf13d887d9186a1bd50b4569fe59970daff762
--- /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 0000000000000000000000000000000000000000..5bc452886d7486bdc0ad7c1f5571ea69fc405895
--- /dev/null
+++ b/api/funkwhale_api/subsonic/serializers.py
@@ -0,0 +1,215 @@
+import collections
+
+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):
+        payload = {
+            'ignoredArticles': '',
+            'index': []
+        }
+        queryset = queryset.with_albums_count()
+        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': [
+                    get_artist_data(v)
+                    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
+
+
+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 or 1,
+        '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').select_related('album')
+        payload = get_album2_data(album)
+        if album.release_date:
+            payload['year'] = album.release_date.year
+
+        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
+    ]
+
+
+def get_playlist_data(playlist):
+    return {
+        'id': playlist.pk,
+        'name': playlist.name,
+        'owner': playlist.user.username,
+        'public': 'false',
+        'songCount': playlist._tracks_count,
+        'duration': 0,
+        'created': playlist.creation_date,
+    }
+
+
+def get_playlist_detail_data(playlist):
+    data = get_playlist_data(playlist)
+    qs = playlist.playlist_tracks.select_related(
+        'track__album__artist'
+    ).prefetch_related('track__files').order_by('index')
+    data['entry'] = []
+    for plt in qs:
+        try:
+            tf = [tf for tf in plt.track.files.all()][0]
+        except IndexError:
+            continue
+        td = get_track_data(plt.track.album, plt.track, tf)
+        data['entry'].append(td)
+    return data
+
+
+def get_music_directory_data(artist):
+    tracks = artist.tracks.select_related('album').prefetch_related('files')
+    data = {
+        'id': artist.pk,
+        'parent': 1,
+        'name': artist.name,
+        'child': []
+    }
+    for track in tracks:
+        try:
+            tf = [tf for tf in track.files.all()][0]
+        except IndexError:
+            continue
+        album = track.album
+        td = {
+            'id': track.pk,
+            'isDir': 'false',
+            'title': track.title,
+            'album': album.title,
+            'artist': artist.name,
+            'track': track.position or 1,
+            'year': track.album.release_date.year if track.album.release_date else 0,
+            'contentType': tf.mimetype,
+            'suffix': tf.extension or '',
+            'duration': tf.duration or 0,
+            'created': track.creation_date,
+            'albumId': album.pk,
+            'artistId': artist.pk,
+            'parent': artist.id,
+            'type': 'music',
+        }
+        data['child'].append(td)
+    return data
diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..475e61aa73119ff8df9a71fa2a27cf3188dc0cff
--- /dev/null
+++ b/api/funkwhale_api/subsonic/views.py
@@ -0,0 +1,498 @@
+import datetime
+
+from django.utils import timezone
+
+from rest_framework import exceptions
+from rest_framework import permissions as rest_permissions
+from rest_framework import renderers
+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.common import preferences
+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 funkwhale_api.playlists import models as playlists_models
+
+from . import authentication
+from . import filters
+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)
+                })
+            qs = queryset
+            if hasattr(qs, '__call__'):
+                qs = qs(request)
+            try:
+                obj = qs.get(**{model_field: value})
+            except qs.model.DoesNotExist:
+                return response.Response({
+                    'code': 70,
+                    'message': '{} not found'.format(
+                        qs.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 dispatch(self, request, *args, **kwargs):
+        if not preferences.get('subsonic__enabled'):
+            r = response.Response({}, status=405)
+            r.accepted_renderer = renderers.JSONRenderer()
+            r.accepted_media_type = 'application/json'
+            r.renderer_context = {}
+            return r
+        return super().dispatch(request, *args, **kwargs)
+
+    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_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',
+        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_indexes',
+        url_path='getIndexes')
+    def get_indexes(self, request, *args, **kwargs):
+        artists = music_models.Artist.objects.all()
+        data = serializers.GetArtistsSerializer(artists).data
+        payload = {
+            'indexes': 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_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',
+        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.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 = {
+            'starred2': {
+                'song': serializers.get_starred_tracks_data(favorites)
+            }
+        }
+        return response.Response(data)
+
+    @list_route(
+        methods=['get', 'post'],
+        url_name='get_starred',
+        url_path='getStarred')
+    def get_starred(self, request, *args, **kwargs):
+        favorites = request.user.track_favorites.all()
+        data = {
+            'starred': {
+                '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)
+
+    @list_route(
+        methods=['get', 'post'],
+        url_name='get_playlists',
+        url_path='getPlaylists')
+    def get_playlists(self, request, *args, **kwargs):
+        playlists = request.user.playlists.with_tracks_count().select_related(
+            'user'
+        )
+        data = {
+            'playlists': {
+                'playlist': [
+                    serializers.get_playlist_data(p) for p in playlists]
+            }
+        }
+        return response.Response(data)
+
+    @list_route(
+        methods=['get', 'post'],
+        url_name='get_playlist',
+        url_path='getPlaylist')
+    @find_object(
+        playlists_models.Playlist.objects.with_tracks_count())
+    def get_playlist(self, request, *args, **kwargs):
+        playlist = kwargs.pop('obj')
+        data = {
+            'playlist': serializers.get_playlist_detail_data(playlist)
+        }
+        return response.Response(data)
+
+    @list_route(
+        methods=['get', 'post'],
+        url_name='update_playlist',
+        url_path='updatePlaylist')
+    @find_object(
+        lambda request: request.user.playlists.all(),
+        field='playlistId')
+    def update_playlist(self, request, *args, **kwargs):
+        playlist = kwargs.pop('obj')
+        data = request.GET or request.POST
+        new_name = data.get('name', '')
+        if new_name:
+            playlist.name = new_name
+            playlist.save(update_fields=['name', 'modification_date'])
+        try:
+            to_remove = int(data['songIndexToRemove'])
+            plt = playlist.playlist_tracks.get(index=to_remove)
+        except (TypeError, ValueError, KeyError):
+            pass
+        except playlists_models.PlaylistTrack.DoesNotExist:
+            pass
+        else:
+            plt.delete(update_indexes=True)
+
+        ids = []
+        for i in data.getlist('songIdToAdd'):
+            try:
+                ids.append(int(i))
+            except (TypeError, ValueError):
+                pass
+        if ids:
+            tracks = music_models.Track.objects.filter(pk__in=ids)
+            by_id = {t.id: t for t in tracks}
+            sorted_tracks = []
+            for i in ids:
+                try:
+                    sorted_tracks.append(by_id[i])
+                except KeyError:
+                    pass
+            if sorted_tracks:
+                playlist.insert_many(sorted_tracks)
+
+        data = {
+            'status': 'ok'
+        }
+        return response.Response(data)
+
+    @list_route(
+        methods=['get', 'post'],
+        url_name='delete_playlist',
+        url_path='deletePlaylist')
+    @find_object(
+        lambda request: request.user.playlists.all())
+    def delete_playlist(self, request, *args, **kwargs):
+        playlist = kwargs.pop('obj')
+        playlist.delete()
+        data = {
+            'status': 'ok'
+        }
+        return response.Response(data)
+
+    @list_route(
+        methods=['get', 'post'],
+        url_name='create_playlist',
+        url_path='createPlaylist')
+    def create_playlist(self, request, *args, **kwargs):
+        data = request.GET or request.POST
+        name = data.get('name', '')
+        if not name:
+            return response.Response({
+                'code': 10,
+                'message': 'Playlist ID or name must be specified.'
+            }, data)
+
+        playlist = request.user.playlists.create(
+            name=name
+        )
+        ids = []
+        for i in data.getlist('songId'):
+            try:
+                ids.append(int(i))
+            except (TypeError, ValueError):
+                pass
+
+        if ids:
+            tracks = music_models.Track.objects.filter(pk__in=ids)
+            by_id = {t.id: t for t in tracks}
+            sorted_tracks = []
+            for i in ids:
+                try:
+                    sorted_tracks.append(by_id[i])
+                except KeyError:
+                    pass
+            if sorted_tracks:
+                playlist.insert_many(sorted_tracks)
+        playlist = request.user.playlists.with_tracks_count().get(
+            pk=playlist.pk)
+        data = {
+            'playlist': serializers.get_playlist_detail_data(playlist)
+        }
+        return response.Response(data)
+
+    @list_route(
+        methods=['get', 'post'],
+        url_name='get_music_folders',
+        url_path='getMusicFolders')
+    def get_music_folders(self, request, *args, **kwargs):
+        data = {
+            'musicFolders': {
+                'musicFolder': [{
+                    'id': 1,
+                    'name': 'Music'
+                }]
+            }
+        }
+        return response.Response(data)
diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py
index 0af155e77339177323d46f32f2ad9a7e6cf99f5c..12307f7fd109407f025b05dd364146204a59ae27 100644
--- a/api/funkwhale_api/users/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -9,6 +9,7 @@ class UserFactory(factory.django.DjangoModelFactory):
     username = factory.Sequence(lambda n: 'user-{0}'.format(n))
     email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
     password = factory.PostGenerationMethodCall('set_password', 'test')
+    subsonic_api_token = None
 
     class Meta:
         model = 'users.User'
diff --git a/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py b/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py
new file mode 100644
index 0000000000000000000000000000000000000000..689b3ef7791943bc92d505aa49b6289bc2f52b14
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.3 on 2018-05-08 09:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0004_user_privacy_level'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='subsonic_api_token',
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 572fa9ddca7c1457db1e6a00956c105874e19c62..773d60f38ebec50dd46cda63b05b37ac4659573c 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -2,6 +2,7 @@
 from __future__ import unicode_literals, absolute_import
 
 import uuid
+import secrets
 
 from django.conf import settings
 from django.contrib.auth.models import AbstractUser
@@ -38,6 +39,13 @@ class User(AbstractUser):
 
     privacy_level = fields.get_privacy_field()
 
+    # Unfortunately, Subsonic API assumes a MD5/password authentication
+    # scheme, which is weak in terms of security, and not achievable
+    # anyway since django use stronger schemes for storing passwords.
+    # Users that want to use the subsonic API from external client
+    # should set this token and use it as their password in such clients
+    subsonic_api_token = models.CharField(
+        blank=True, null=True, max_length=255)
 
     def __str__(self):
         return self.username
@@ -49,9 +57,15 @@ class User(AbstractUser):
         self.secret_key = uuid.uuid4()
         return self.secret_key
 
+    def update_subsonic_api_token(self):
+        self.subsonic_api_token = secrets.token_hex(32)
+        return self.subsonic_api_token
+
     def set_password(self, raw_password):
         super().set_password(raw_password)
         self.update_secret_key()
+        if self.subsonic_api_token:
+            self.update_subsonic_api_token()
 
     def get_activity_url(self):
         return settings.FUNKWHALE_URL + '/@{}'.format(self.username)
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index 7c58363a3ed7fcbdccdd86138d2081a17b564631..0cc317889196703e9435f8af36ed63fb001b887f 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -1,11 +1,13 @@
 from rest_framework.response import Response
 from rest_framework import mixins
 from rest_framework import viewsets
-from rest_framework.decorators import list_route
+from rest_framework.decorators import detail_route, list_route
 
 from rest_auth.registration.views import RegisterView as BaseRegisterView
 from allauth.account.adapter import get_adapter
 
+from funkwhale_api.common import preferences
+
 from . import models
 from . import serializers
 
@@ -37,6 +39,28 @@ class UserViewSet(
         serializer = serializers.UserReadSerializer(request.user)
         return Response(serializer.data)
 
+    @detail_route(
+        methods=['get', 'post', 'delete'], url_path='subsonic-token')
+    def subsonic_token(self, request, *args, **kwargs):
+        if not self.request.user.username == kwargs.get('username'):
+            return Response(status=403)
+        if not preferences.get('subsonic__enabled'):
+            return Response(status=405)
+        if request.method.lower() == 'get':
+            return Response({
+                'subsonic_api_token': self.request.user.subsonic_api_token
+            })
+        if request.method.lower() == 'delete':
+            self.request.user.subsonic_api_token = None
+            self.request.user.save(update_fields=['subsonic_api_token'])
+            return Response(status=204)
+        self.request.user.update_subsonic_api_token()
+        self.request.user.save(update_fields=['subsonic_api_token'])
+        data = {
+            'subsonic_api_token': self.request.user.subsonic_api_token
+        }
+        return Response(data)
+
     def update(self, request, *args, **kwargs):
         if not self.request.user.username == kwargs.get('username'):
             return Response(status=403)
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 51a1bc4c7455c0be929ba503b971b3ec2d8ad244..dda537801f3cf66efb7eec4e823816e5dcec8207 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/federation/test_views.py b/api/tests/federation/test_views.py
index 09ecfc8ff7f6d192808890b78eb9d3226ad8cc7e..cc81f0657754960d1389bfe36bb6c02787f4c5a3 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -70,6 +70,32 @@ def test_wellknown_webfinger_system(
     assert response.data == serializer.data
 
 
+def test_wellknown_nodeinfo(db, preferences, api_client, settings):
+    expected = {
+        'links': [
+            {
+                'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
+                'href': '{}{}'.format(
+                    settings.FUNKWHALE_URL,
+                    reverse('api:v1:instance:nodeinfo-2.0')
+                )
+            }
+        ]
+    }
+    url = reverse('federation:well-known-nodeinfo')
+    response = api_client.get(url)
+    assert response.status_code == 200
+    assert response['Content-Type'] == 'application/jrd+json'
+    assert response.data == expected
+
+
+def test_wellknown_nodeinfo_disabled(db, preferences, api_client):
+    preferences['instance__nodeinfo_enabled'] = False
+    url = reverse('federation:well-known-nodeinfo')
+    response = api_client.get(url)
+    assert response.status_code == 404
+
+
 def test_audio_file_list_requires_authenticated_actor(
         db, preferences, api_client):
     preferences['federation__music_needs_approval'] = True
diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ca1c43a5835f94a6db20018a76e456c5e1b7e28
--- /dev/null
+++ b/api/tests/instance/test_nodeinfo.py
@@ -0,0 +1,105 @@
+from django.urls import reverse
+
+import funkwhale_api
+
+from funkwhale_api.instance import nodeinfo
+
+
+def test_nodeinfo_dump(preferences, mocker):
+    preferences['instance__nodeinfo_stats_enabled'] = True
+    stats = {
+        'users': 1,
+        'tracks': 2,
+        'albums': 3,
+        'artists': 4,
+        'track_favorites': 5,
+        'music_duration': 6,
+        'listenings': 7,
+    }
+    mocker.patch('funkwhale_api.instance.stats.get', return_value=stats)
+
+    expected = {
+        'version': '2.0',
+        'software': {
+            'name': 'funkwhale',
+            'version': funkwhale_api.__version__
+        },
+        'protocols': ['activitypub'],
+        'services': {
+            'inbound': [],
+            'outbound': []
+        },
+        'openRegistrations': preferences['users__registration_enabled'],
+        'usage': {
+            'users': {
+                'total': stats['users'],
+            }
+        },
+        'metadata': {
+            'shortDescription': preferences['instance__short_description'],
+            'longDescription': preferences['instance__long_description'],
+            'nodeName': preferences['instance__name'],
+            'library': {
+                'federationEnabled': preferences['federation__enabled'],
+                'federationNeedsApproval': preferences['federation__music_needs_approval'],
+                'anonymousCanListen': preferences['common__api_authentication_required'],
+                'tracks': {
+                    'total': stats['tracks'],
+                },
+                'artists': {
+                    'total': stats['artists'],
+                },
+                'albums': {
+                    'total': stats['albums'],
+                },
+                'music': {
+                    'hours': stats['music_duration']
+                },
+            },
+            'usage': {
+                'favorites': {
+                    'tracks': {
+                        'total': stats['track_favorites'],
+                    }
+                },
+                'listenings': {
+                    'total': stats['listenings']
+                }
+            }
+        }
+    }
+    assert nodeinfo.get() == expected
+
+
+def test_nodeinfo_dump_stats_disabled(preferences, mocker):
+    preferences['instance__nodeinfo_stats_enabled'] = False
+
+    expected = {
+        'version': '2.0',
+        'software': {
+            'name': 'funkwhale',
+            'version': funkwhale_api.__version__
+        },
+        'protocols': ['activitypub'],
+        'services': {
+            'inbound': [],
+            'outbound': []
+        },
+        'openRegistrations': preferences['users__registration_enabled'],
+        'usage': {
+            'users': {
+                'total': 0,
+            }
+        },
+        'metadata': {
+            'shortDescription': preferences['instance__short_description'],
+            'longDescription': preferences['instance__long_description'],
+            'nodeName': preferences['instance__name'],
+            'library': {
+                'federationEnabled': preferences['federation__enabled'],
+                'federationNeedsApproval': preferences['federation__music_needs_approval'],
+                'anonymousCanListen': preferences['common__api_authentication_required'],
+            },
+        }
+    }
+    assert nodeinfo.get() == expected
diff --git a/api/tests/instance/test_stats.py b/api/tests/instance/test_stats.py
index 6eaad76f7f9d8292211965a462f473c8bb41745a..6063e9300512819a9cc3e6d6bebcc477af9a59d7 100644
--- a/api/tests/instance/test_stats.py
+++ b/api/tests/instance/test_stats.py
@@ -3,16 +3,6 @@ from django.urls import reverse
 from funkwhale_api.instance import stats
 
 
-def test_can_get_stats_via_api(db, api_client, mocker):
-    stats = {
-        'foo': 'bar'
-    }
-    mocker.patch('funkwhale_api.instance.stats.get', return_value=stats)
-    url = reverse('api:v1:instance:stats')
-    response = api_client.get(url)
-    assert response.data == stats
-
-
 def test_get_users(mocker):
     mocker.patch(
         'funkwhale_api.users.models.User.objects.count', return_value=42)
diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..468c0ddae9de440b3edce7fd65fdc57c6ead8fff
--- /dev/null
+++ b/api/tests/instance/test_views.py
@@ -0,0 +1,23 @@
+from django.urls import reverse
+
+
+def test_nodeinfo_endpoint(db, api_client, mocker):
+    payload = {
+        'test': 'test'
+    }
+    mocked_nodeinfo = mocker.patch(
+        'funkwhale_api.instance.nodeinfo.get', return_value=payload)
+    url = reverse('api:v1:instance:nodeinfo-2.0')
+    response = api_client.get(url)
+    ct = 'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8'  # noqa
+    assert response.status_code == 200
+    assert response['Content-Type'] == ct
+    assert response.data == payload
+
+
+def test_nodeinfo_endpoint_disabled(db, api_client, preferences):
+    preferences['instance__nodeinfo_enabled'] = False
+    url = reverse('api:v1:instance:nodeinfo-2.0')
+    response = api_client.get(url)
+
+    assert response.status_code == 404
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
index 000e6a8b6265eca2178c98ff52b0807956314830..c7b40fb16e9ebc50e33060b88c0c82947e808451 100644
--- a/api/tests/music/test_import.py
+++ b/api/tests/music/test_import.py
@@ -66,7 +66,7 @@ def test_import_job_from_federation_musicbrainz_recording(factories, mocker):
     t = factories['music.Track']()
     track_from_api = mocker.patch(
         'funkwhale_api.music.models.Track.get_or_create_from_api',
-        return_value=t)
+        return_value=(t, True))
     lt = factories['federation.LibraryTrack'](
         metadata__recording__musicbrainz=True,
         artist_name='Hello',
@@ -92,7 +92,7 @@ def test_import_job_from_federation_musicbrainz_release(factories, mocker):
     a = factories['music.Album']()
     album_from_api = mocker.patch(
         'funkwhale_api.music.models.Album.get_or_create_from_api',
-        return_value=a)
+        return_value=(a, True))
     lt = factories['federation.LibraryTrack'](
         metadata__release__musicbrainz=True,
         artist_name='Hello',
@@ -121,7 +121,7 @@ def test_import_job_from_federation_musicbrainz_artist(factories, mocker):
     a = factories['music.Artist']()
     artist_from_api = mocker.patch(
         'funkwhale_api.music.models.Artist.get_or_create_from_api',
-        return_value=a)
+        return_value=(a, True))
     lt = factories['federation.LibraryTrack'](
         metadata__artist__musicbrainz=True,
         album_title='World',
diff --git a/api/tests/subsonic/test_authentication.py b/api/tests/subsonic/test_authentication.py
new file mode 100644
index 0000000000000000000000000000000000000000..724513523bd93d4c4e1a686962b5b05f03f37b0d
--- /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 0000000000000000000000000000000000000000..8e2ea3f85ab80bc45661eb07e4ec69f542b8f963
--- /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 0000000000000000000000000000000000000000..6da9dd12e2e273643cf78ec0410535e77891fdad
--- /dev/null
+++ b/api/tests/subsonic/test_serializers.py
@@ -0,0 +1,207 @@
+from funkwhale_api.music import models as music_models
+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 or '',
+                'duration': tf.duration or 0,
+                'created': track.creation_date,
+                'albumId': album.pk,
+                'artistId': artist.pk,
+                'type': 'music',
+            }
+        ]
+    }
+
+    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
+
+
+def test_playlist_serializer(factories):
+    plt = factories['playlists.PlaylistTrack']()
+    playlist = plt.playlist
+    qs = music_models.Album.objects.with_tracks_count().order_by('pk')
+    expected = {
+        'id': playlist.pk,
+        'name': playlist.name,
+        'owner': playlist.user.username,
+        'public': 'false',
+        'songCount': 1,
+        'duration': 0,
+        'created': playlist.creation_date,
+    }
+    qs = playlist.__class__.objects.with_tracks_count()
+    data = serializers.get_playlist_data(qs.first())
+    assert data == expected
+
+
+def test_playlist_detail_serializer(factories):
+    plt = factories['playlists.PlaylistTrack']()
+    tf = factories['music.TrackFile'](track=plt.track)
+    playlist = plt.playlist
+    qs = music_models.Album.objects.with_tracks_count().order_by('pk')
+    expected = {
+        'id': playlist.pk,
+        'name': playlist.name,
+        'owner': playlist.user.username,
+        'public': 'false',
+        'songCount': 1,
+        'duration': 0,
+        'created': playlist.creation_date,
+        'entry': [
+            serializers.get_track_data(plt.track.album, plt.track, tf)
+        ]
+    }
+    qs = playlist.__class__.objects.with_tracks_count()
+    data = serializers.get_playlist_detail_data(qs.first())
+    assert data == expected
+
+
+def test_directory_serializer_artist(factories):
+    track = factories['music.Track']()
+    tf = factories['music.TrackFile'](track=track)
+    album = track.album
+    artist = track.artist
+
+    expected = {
+        'id': artist.pk,
+        'parent': 1,
+        'name': artist.name,
+        'child': [{
+            '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 or '',
+            'duration': tf.duration or 0,
+            'created': track.creation_date,
+            'albumId': album.pk,
+            'artistId': artist.pk,
+            'parent': artist.pk,
+            'type': 'music',
+        }]
+    }
+    data = serializers.get_music_directory_data(artist)
+    assert data == expected
diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd445e070727c529e1295154c79302d0f37c51b1
--- /dev/null
+++ b/api/tests/subsonic/test_views.py
@@ -0,0 +1,393 @@
+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
+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
+
+
+def test_disabled_subsonic(preferences, api_client):
+    preferences['subsonic__enabled'] = False
+    url = reverse('api:subsonic-ping')
+    response = api_client.get(url)
+    assert response.status_code == 405
+
+
+@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')
+    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, {'f': f})
+
+    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_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')
+    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
+
+
+@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 == {
+        'starred2': {
+            'song': serializers.get_starred_tracks_data([favorite])
+        }
+    }
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_get_starred(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-get-starred')
+    assert url.endswith('getStarred') 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 == {
+        'starred': {
+            '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]),
+        }
+    }
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_get_playlists(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-get-playlists')
+    assert url.endswith('getPlaylists') is True
+    playlist = factories['playlists.Playlist'](
+        user=logged_in_api_client.user
+    )
+    response = logged_in_api_client.get(url, {'f': f})
+
+    qs = playlist.__class__.objects.with_tracks_count()
+    assert response.status_code == 200
+    assert response.data == {
+        'playlists': {
+            'playlist': [serializers.get_playlist_data(qs.first())],
+        }
+    }
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_get_playlist(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-get-playlist')
+    assert url.endswith('getPlaylist') is True
+    playlist = factories['playlists.Playlist'](
+        user=logged_in_api_client.user
+    )
+    response = logged_in_api_client.get(url, {'f': f, 'id': playlist.pk})
+
+    qs = playlist.__class__.objects.with_tracks_count()
+    assert response.status_code == 200
+    assert response.data == {
+        'playlist': serializers.get_playlist_detail_data(qs.first())
+    }
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_update_playlist(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-update-playlist')
+    assert url.endswith('updatePlaylist') is True
+    playlist = factories['playlists.Playlist'](
+        user=logged_in_api_client.user
+    )
+    plt = factories['playlists.PlaylistTrack'](
+        index=0, playlist=playlist)
+    new_track = factories['music.Track']()
+    response = logged_in_api_client.get(
+        url, {
+            'f': f,
+            'name': 'new_name',
+            'playlistId': playlist.pk,
+            'songIdToAdd': new_track.pk,
+            'songIndexToRemove': 0})
+    playlist.refresh_from_db()
+    assert response.status_code == 200
+    assert playlist.name == 'new_name'
+    assert playlist.playlist_tracks.count() == 1
+    assert playlist.playlist_tracks.first().track_id == new_track.pk
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_delete_playlist(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-delete-playlist')
+    assert url.endswith('deletePlaylist') is True
+    playlist = factories['playlists.Playlist'](
+        user=logged_in_api_client.user
+    )
+    response = logged_in_api_client.get(
+        url, {'f': f, 'id': playlist.pk})
+    assert response.status_code == 200
+    with pytest.raises(playlist.__class__.DoesNotExist):
+        playlist.refresh_from_db()
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_create_playlist(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-create-playlist')
+    assert url.endswith('createPlaylist') is True
+    track1 = factories['music.Track']()
+    track2 = factories['music.Track']()
+    response = logged_in_api_client.get(
+        url, {'f': f, 'name': 'hello', 'songId': [track1.pk, track2.pk]})
+    assert response.status_code == 200
+    playlist = logged_in_api_client.user.playlists.latest('id')
+    assert playlist.playlist_tracks.count() == 2
+    for i, t in enumerate([track1, track2]):
+        plt = playlist.playlist_tracks.get(track=t)
+        assert plt.index == i
+    assert playlist.name == 'hello'
+    qs = playlist.__class__.objects.with_tracks_count()
+    assert response.data == {
+        'playlist': serializers.get_playlist_detail_data(qs.first())
+    }
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_get_music_folders(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-get-music-folders')
+    assert url.endswith('getMusicFolders') is True
+    response = logged_in_api_client.get(url, {'f': f})
+    assert response.status_code == 200
+    assert response.data == {
+        'musicFolders': {
+            'musicFolder': [{
+                'id': 1,
+                'name': 'Music'
+            }]
+        }
+    }
+
+
+@pytest.mark.parametrize('f', ['xml', 'json'])
+def test_get_indexes(f, db, logged_in_api_client, factories):
+    url = reverse('api:subsonic-get-indexes')
+    assert url.endswith('getIndexes') is True
+    artists = factories['music.Artist'].create_batch(size=10)
+    expected = {
+        'indexes': serializers.GetArtistsSerializer(
+            music_models.Artist.objects.all()
+        ).data
+    }
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == expected
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index 57793f494bcc59a6994d2f014cf9ae7090495cd6..c7cd12e9e3ba19a457fa0d66d68e6b3679925c84 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -2,3 +2,17 @@
 def test__str__(factories):
     user = factories['users.User'](username='hello')
     assert user.__str__() == 'hello'
+
+
+def test_changing_password_updates_subsonic_api_token_no_token(factories):
+    user = factories['users.User'](subsonic_api_token=None)
+    user.set_password('new')
+    assert user.subsonic_api_token is None
+
+
+def test_changing_password_updates_subsonic_api_token(factories):
+    user = factories['users.User'](subsonic_api_token='test')
+    user.set_password('new')
+
+    assert user.subsonic_api_token is not None
+    assert user.subsonic_api_token != 'test'
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 985a78c8a65ed49853869ed18c2cb82a5b2a95db..fffc762fde7cb6f6533f3781bc73e828fa8e5d56 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client):
     assert user.privacy_level == 'me'
 
 
+def test_user_can_request_new_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.post(url)
+
+    assert response.status_code == 200
+    user.refresh_from_db()
+    assert user.subsonic_api_token != 'test'
+    assert user.subsonic_api_token is not None
+    assert response.data == {
+        'subsonic_api_token': user.subsonic_api_token
+    }
+
+
+def test_user_can_get_new_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == {
+        'subsonic_api_token': 'test'
+    }
+def test_user_can_request_new_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.post(url)
+
+    assert response.status_code == 200
+    user.refresh_from_db()
+    assert user.subsonic_api_token != 'test'
+    assert user.subsonic_api_token is not None
+    assert response.data == {
+        'subsonic_api_token': user.subsonic_api_token
+    }
+
+
+def test_user_can_delete_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+    user.refresh_from_db()
+    assert user.subsonic_api_token is None
+
+
 @pytest.mark.parametrize('method', ['put', 'patch'])
 def test_user_cannot_patch_another_user(
         method, logged_in_api_client, factories):
diff --git a/demo/setup.sh b/demo/setup.sh
index 1d63cc181857cdeca2b579282eff98c816c378f5..b96f517b3a4ba7b69c137fc425aa2c7e912f820a 100644
--- a/demo/setup.sh
+++ b/demo/setup.sh
@@ -5,7 +5,7 @@ demo_path="/srv/funkwhale-demo/demo"
 
 echo 'Cleaning everything...'
 cd $demo_path
-docker-compose down -v || echo 'Nothing to stop'
+/usr/local/bin/docker-compose down -v || echo 'Nothing to stop'
 rm -rf /srv/funkwhale-demo/demo/*
 mkdir -p $demo_path
 echo 'Downloading demo files...'
@@ -23,9 +23,10 @@ echo "DJANGO_SECRET_KEY=demo" >> .env
 echo "DJANGO_ALLOWED_HOSTS=demo.funkwhale.audio" >> .env
 echo "FUNKWHALE_VERSION=$version" >> .env
 echo "FUNKWHALE_API_PORT=5001" >> .env
-
-docker-compose pull
-docker-compose up -d postgres redis
+echo "FEDERATION_MUSIC_NEEDS_APPROVAL=False" >>.env
+echo "PROTECT_AUDIO_FILES=False" >> .env
+/usr/local/bin/docker-compose pull
+/usr/local/bin/docker-compose up -d postgres redis
 sleep 5
-docker-compose run --rm api demo/load-demo-data.sh
-docker-compose up -d
+/usr/local/bin/docker-compose run --rm api demo/load-demo-data.sh
+/usr/local/bin/docker-compose up -d
diff --git a/deploy/apache.conf b/deploy/apache.conf
index 8d5a5e1f7ee45c7a02f4f8654309744f841751c7..5bfcbce04587e96fe949f21e6c50b4e05c77360e 100644
--- a/deploy/apache.conf
+++ b/deploy/apache.conf
@@ -84,9 +84,9 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music
       ProxyPassReverse ${funkwhale-api}/federation
    </Location>
 
-   <Location "/.well-known/webfinger">
-      ProxyPass ${funkwhale-api}/.well-known/webfinger
-      ProxyPassReverse ${funkwhale-api}/.well-known/webfinger
+   <Location "/.well-known/">
+      ProxyPass ${funkwhale-api}/.well-known/
+      ProxyPassReverse ${funkwhale-api}/.well-known/
    </Location>
 
    Alias /media /srv/funkwhale/data/media
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index 4b27595af62ae2975b5af853ce5acca406bae58a..42659a0dabc33cf2da439b21f06f77163c544722 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -48,9 +48,9 @@ FUNKWHALE_URL=https://yourdomain.funwhale
 # EMAIL_CONFIG=consolemail://         # output emails to console (the default)
 # EMAIL_CONFIG=dummymail://          # disable email sending completely
 # On a production instance, you'll usually want to use an external SMTP server:
-# EMAIL_CONFIG=smtp://user@:password@youremail.host:25'
-# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465'
-# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587'
+# EMAIL_CONFIG=smtp://user@:password@youremail.host:25
+# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465
+# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587
 
 # The email address to use to send systme emails. By default, we will
 # DEFAULT_FROM_EMAIL=noreply@yourdomain
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index b3a4c6aaf762830b533774689cb26266ca6b65e8..7d344408b67ad8c46e2d7a161180ba928ebb31aa 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -67,9 +67,9 @@ server {
         proxy_pass   http://funkwhale-api/federation/;
     }
 
-    location /.well-known/webfinger {
+    location /.well-known/ {
         include /etc/nginx/funkwhale_proxy.conf;
-        proxy_pass   http://funkwhale-api/.well-known/webfinger;
+        proxy_pass   http://funkwhale-api/.well-known/;
     }
 
     location /media/ {
diff --git a/dev.yml b/dev.yml
index 534d8f5b5d8bbde692acde76ba1a2f1b7cb9dfbc..e85ce3b91f59345982e1443a6637ef2fbb726c86 100644
--- a/dev.yml
+++ b/dev.yml
@@ -20,7 +20,7 @@ services:
       - internal
     labels:
       traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
-      traefik.frontend.rule: "Host: ${COMPOSE_PROJECT_NAME-node1}.funkwhale.test"
+      traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}"
       traefik.enable: 'true'
       traefik.federation.protocol: 'http'
       traefik.federation.port: "${WEBPACK_DEVSERVER_PORT-8080}"
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 38c3de6c7e41369aa98bf6e3f21ad84a8d0c0a4d..ab6714e60e1b6fec7a606cc6e820e65127af7346 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -82,5 +82,9 @@ http {
             include /etc/nginx/funkwhale_proxy.conf;
             proxy_pass   http://api:12081/;
         }
+        location /rest/ {
+            include /etc/nginx/funkwhale_proxy.conf;
+            proxy_pass   http://api:12081/api/subsonic/rest/;
+        }
     }
 }
diff --git a/docker/traefik.toml b/docker/traefik.toml
index 85da2ea7288a9609506d925178f79fc9a6fa4be1..c87f4527d4ee898c44529e64b35c81de7ca36a0b 100644
--- a/docker/traefik.toml
+++ b/docker/traefik.toml
@@ -1,5 +1,5 @@
 defaultEntryPoints = ["http", "https"]
-
+[accessLog]
 ################################################################
 # Web configuration backend
 ################################################################
diff --git a/docs/index.rst b/docs/index.rst
index 481690b708fd49d98d79bdf4b4354d1963339935..01f76d3cc0212bee64ea8ec9956d78e880c88777 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -11,6 +11,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
 .. toctree::
    :maxdepth: 2
 
+   users/index
    features
    installation/index
    configuration
diff --git a/docs/installation/index.rst b/docs/installation/index.rst
index a3e11529b155d7148aa08840b71a4692ce035f00..ae5794b6cfd41ea2074be012d38748c2801bb06a 100644
--- a/docs/installation/index.rst
+++ b/docs/installation/index.rst
@@ -77,7 +77,8 @@ Frontend setup
 .. note::
 
     You do not need to do this if you are deploying using Docker, as frontend files
-    are already included in the funkwhale docker image.
+    are already included in the docker image.
+
 
 Files for the web frontend are purely static and can simply be downloaded, unzipped and served from any webserver:
 
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
index 674878ba7c4d8f4dd41f64f0dbda615ea515ba2d..bd3d5578f3904ecf4edd73d9360cbcaa46d768bf 100644
--- a/docs/upgrading.rst
+++ b/docs/upgrading.rst
@@ -17,29 +17,9 @@ Please take a few minutes to read the :doc:`changelog`: updates should work
 similarly from version to version, but some of them may require additional steps.
 Those steps would be described in the version release notes.
 
-Upgrade the static files
-------------------------
-
-Regardless of your deployment choice (docker/non-docker) the front-end app
-is updated separately from the API. This is as simple as downloading
-the zip with the static files and extracting it in the correct place.
-
-The following example assume your setup match :ref:`frontend-setup`.
-
-.. parsed-literal::
-
-    # this assumes you want to upgrade to version "|version|"
-    export FUNKWHALE_VERSION="|version|"
-    cd /srv/funkwhale
-    curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
-    unzip -o front.zip
-    rm front.zip
-
-Upgrading the API
------------------
 
 Docker setup
-^^^^^^^^^^^^
+------------
 
 If you've followed the setup instructions in :doc:`Docker`, upgrade path is
 easy:
@@ -57,10 +37,33 @@ easy:
     # Relaunch the containers
     docker-compose up -d
 
+
+
 Non-docker setup
-^^^^^^^^^^^^^^^^
+----------------
+
+Upgrade the static files
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+On non-docker setups, the front-end app
+is updated separately from the API. This is as simple as downloading
+the zip with the static files and extracting it in the correct place.
+
+The following example assume your setup match :ref:`frontend-setup`.
+
+.. parsed-literal::
+
+    # this assumes you want to upgrade to version "|version|"
+    export FUNKWHALE_VERSION="|version|"
+    cd /srv/funkwhale
+    curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
+    unzip -o front.zip
+    rm front.zip
+
+Upgrading the API
+^^^^^^^^^^^^^^^^^
 
-On non docker-setup, upgrade involves a few more commands. We assume your setup
+On non-docker, upgrade involves a few more commands. We assume your setup
 match what is described in :doc:`debian`:
 
 .. parsed-literal::
diff --git a/docs/users/apps.rst b/docs/users/apps.rst
new file mode 100644
index 0000000000000000000000000000000000000000..f01af9266c9bbcbb66e13edbf332957810e5c106
--- /dev/null
+++ b/docs/users/apps.rst
@@ -0,0 +1,92 @@
+Using Funkwhale from other apps
+===============================
+
+As of today, the only official client for using Funkwhale is the web client,
+the one you use in your browser.
+
+While the web client works okay, it's still not ready for some use cases, especially:
+
+- Usage on narrow/touche screens (smartphones, tablets)
+- Usage on the go, with an intermittent connexion
+
+This pages lists alternative clients you can use to connect to your Funkwhale
+instance and enjoy your music.
+
+
+Subsonic-compatible clients
+---------------------------
+
+Since version 0.12, Funkwhale implements a subset of the `Subsonic API <http://www.subsonic.org/pages/api.jsp>`_.
+This API is a de-facto standard for a lot of projects out there, and many clients
+are available that works with this API.
+
+Those Subsonic features are supported in Funkwhale:
+
+- Search (artists, albums, tracks)
+- Common library browsing using ID3 tags (list artists, albums, etc.)
+- Playlist management
+- Stars (which is mapped to Funkwhale's favorites)
+
+Those features as missing:
+
+- Transcoding/streaming with different bitrates
+- Album covers
+- Artist info (this data is not available in Funkwhale)
+- Library browsing that relies music directories
+- Bookmarks
+- Admin
+- Chat
+- Shares
+
+.. note::
+
+    If you know or use some recent, well-maintained, Subsonic clients,
+    please get in touch so we can add them to this list.
+
+    Especially we're still lacking an iOS client!
+
+
+Enabling Subsonic on your Funkwhale account
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+To log-in on your Funkwhale account from Subsonic clients, you will need to
+set a separate Subsonic API password by visiting your settings page.
+
+Then, when using a client, you'll have to input some information about your server:
+
+1. Your Funkwhale instance URL (e.g. https://demo.funkwhale.audio)
+2. Your Funkwhale username (e.g. demo)
+3. Your Subsonic API password (the one you set earlier in this section)
+
+In your client configuration, please double check the "ID3" or "Browse with tags"
+setting is enabled.
+
+DSub (Android)
+^^^^^^^^^^^^^^
+
+- Price: free (on F-Droid)
+- F-Droid: https://f-droid.org/en/packages/github.daneren2005.dsub/
+- Google Play: https://play.google.com/store/apps/details?id=github.daneren2005.dsub
+- Sources: https://github.com/daneren2005/Subsonic
+
+DSub is a full-featured Subsonic client that works great, and has a lot of features:
+
+- Playlists
+- Stars
+- Search
+- Offline cache (with configurable size, playlist download, queue prefetching, etc.)
+
+It's the recommended Android client to use with Funkwhale, as we are doing
+our Android tests on this one.
+
+Clementine (Desktop)
+^^^^^^^^^^^^^^^^^^^^
+
+- Price: free
+- Website: https://www.clementine-player.org/fr/
+
+This desktop client works on Windows, Mac OS X and Linux and is able to stream
+music from your Funkwhale instance. However, it does not implement advanced
+features such as playlist management, search or stars.
+
+This is the client we use for our desktop tests.
diff --git a/docs/users/index.rst b/docs/users/index.rst
new file mode 100644
index 0000000000000000000000000000000000000000..215fe959ec700290f4ba9551d5a1866dad34253b
--- /dev/null
+++ b/docs/users/index.rst
@@ -0,0 +1,16 @@
+.. funkwhale documentation master file, created by
+   sphinx-quickstart on Sun Jun 25 18:49:23 2017.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+Funkwhale's users documentation
+=====================================
+
+.. note::
+
+  This documentation is meant for end-users of the platform.
+
+.. toctree::
+   :maxdepth: 2
+
+   apps
diff --git a/front/config/index.js b/front/config/index.js
index 669ce54f37dbfe912cf61dda75b7ccb6366c0824..f4996f0203dd0d5b8f28820058c7e3c247c7474e 100644
--- a/front/config/index.js
+++ b/front/config/index.js
@@ -34,7 +34,7 @@ module.exports = {
         changeOrigin: true,
         ws: true,
         filter: function (pathname, req) {
-          let proxified = ['.well-known', 'staticfiles', 'media', 'federation', 'api']
+          let proxified = ['rest', '.well-known', 'staticfiles', 'media', 'federation', 'api']
           let matches = proxified.filter(e => {
             return pathname.match(`^/${e}`)
           })
diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue
index 03f4513e6cb645ab17dc79c75dcee32c202ef0b8..2af1bfc35b1b65a60a686268a31594c2604ada9a 100644
--- a/front/src/components/Home.vue
+++ b/front/src/components/Home.vue
@@ -5,7 +5,7 @@
         <h1 class="ui huge header">
           {{ $t('Welcome on Funkwhale') }}
         </h1>
-        <p>{{ $t('We think listening music should be simple.') }}</p>
+        <p>{{ $t('We think listening to music should be simple.') }}</p>
         <router-link class="ui icon button" to="/about">
           <i class="info icon"></i>
           {{ $t('Learn more about this instance') }}
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index 14d381ca19517efa6641e047fc94cd6aed4f1795..2662f30b33a5321a1e2d0121c3930c46b5f66097 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -2,7 +2,7 @@
   <div :class="['ui', {'tiny': discrete}, 'buttons']">
     <button
       :title="$t('Add to current queue')"
-      @click="add"
+      @click="addNext(true)"
       :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
       <i class="ui play icon"></i>
       <template v-if="!discrete"><slot><i18next path="Play"/></slot></template>
@@ -42,9 +42,7 @@ export default {
     }
   },
   mounted () {
-    if (!this.discrete) {
-      jQuery(this.$el).find('.ui.dropdown').dropdown()
-    }
+    jQuery(this.$el).find('.ui.dropdown').dropdown()
   },
   computed: {
     playable () {
@@ -98,9 +96,11 @@ export default {
     addNext (next) {
       let self = this
       this.triggerLoad()
+      let wasEmpty = this.$store.state.queue.tracks.length === 0
       this.getPlayableTracks().then((tracks) => {
         self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1})
-        if (next) {
+        let goNext = next && !wasEmpty
+        if (goNext) {
           self.$store.dispatch('queue/next')
         }
       })
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index 8eeae85a94a0831f2008428d92b4712547b0533a..5468358aeb438ff768dc9e66d1845c1ecdf5e34d 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -26,6 +26,10 @@
       <div class="ui hidden divider"></div>
       <div class="ui small text container">
         <h2 class="ui header"><i18next path="Change my password"/></h2>
+        <div class="ui message">
+          {{ $t('Changing your password will also change your Subsonic API password if you have requested one.') }}
+          {{ $t('You will have to update your password on your clients that use this password.') }}
+        </div>
         <form class="ui form" @submit.prevent="submitPassword()">
           <div v-if="passwordError" class="ui negative message">
             <div class="header"><i18next path="Cannot change your password"/></div>
@@ -41,10 +45,25 @@
           <div class="field">
             <label><i18next path="New password"/></label>
             <password-input required v-model="new_password" />
-
           </div>
-          <button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button>
+          <dangerous-button
+            color="yellow"
+            :class="['ui', {'loading': isLoading}, 'button']"
+            :action="submitPassword">
+            {{ $t('Change password') }}
+            <p slot="modal-header">{{ $t('Change your password?') }}</p>
+            <div slot="modal-content">
+              <p>{{ $t("Changing your password will have the following consequences") }}</p>
+              <ul>
+                <li>{{ $t('You will be logged out from this session and have to log out with the new one') }}</li>
+                <li>{{ $t('Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password') }}</li>
+              </ul>
+            </div>
+            <p slot="modal-confirm">{{ $t('Disable access') }}</p>
+          </dangerous-button>
         </form>
+        <div class="ui hidden divider" />
+        <subsonic-token-form />
       </div>
     </div>
   </div>
@@ -55,10 +74,12 @@ import $ from 'jquery'
 import axios from 'axios'
 import logger from '@/logging'
 import PasswordInput from '@/components/forms/PasswordInput'
+import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm'
 
 export default {
   components: {
-    PasswordInput
+    PasswordInput,
+    SubsonicTokenForm
   },
   data () {
     let d = {
diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1fa4b5d1de90be9430ff4e81923b8f11d3184220
--- /dev/null
+++ b/front/src/components/auth/SubsonicTokenForm.vue
@@ -0,0 +1,137 @@
+<template>
+  <form class="ui form" @submit.prevent="requestNewToken()">
+    <h2>{{ $t('Subsonic API password') }}</h2>
+    <p class="ui message" v-if="!subsonicEnabled">
+      {{ $t('The Subsonic API is not available on this Funkwhale instance.') }}
+    </p>
+    <p>
+      {{ $t('Funkwhale is compatible with other music players that support the Subsonic API.') }}
+      {{ $t('You can use those to enjoy your playlist and music in offline mode, on your smartphone or tablet, for instance.') }}
+    </p>
+    <p>
+      {{ $t('However, accessing Funkwhale from those clients require a separate password you can set below.') }}
+    </p>
+    <p><a href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients" target="_blank">
+      {{ $t('Discover how to use Funkwhale from other apps') }}
+    </a></p>
+    <div v-if="success" class="ui positive message">
+      <div class="header">{{ successMessage }}</div>
+    </div>
+    <div v-if="subsonicEnabled && errors.length > 0" class="ui negative message">
+      <div class="header">{{ $t('Error') }}</div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <template v-if="subsonicEnabled">
+      <div v-if="token" class="field">
+        <password-input v-model="token" />
+      </div>
+      <dangerous-button
+        v-if="token"
+        color="grey"
+        :class="['ui', {'loading': isLoading}, 'button']"
+        :action="requestNewToken">
+        {{ $t('Request a new password') }}
+        <p slot="modal-header">{{ $t('Request a new Subsonic API password?') }}</p>
+        <p slot="modal-content">{{ $t('This will log you out from existing devices that use the current password.') }}</p>
+        <p slot="modal-confirm">{{ $t('Request a new password') }}</p>
+      </dangerous-button>
+      <button
+        v-else
+        color="grey"
+        :class="['ui', {'loading': isLoading}, 'button']"
+        @click="requestNewToken">{{ $t('Request a password') }}</button>
+        <dangerous-button
+          v-if="token"
+          color="yellow"
+          :class="['ui', {'loading': isLoading}, 'button']"
+          :action="disable">
+          {{ $t('Disable Subsonic access') }}
+          <p slot="modal-header">{{ $t('Disable Subsonic API access?') }}</p>
+          <p slot="modal-content">{{ $t('This will completely disable access to the Subsonic API using from account.') }}</p>
+          <p slot="modal-confirm">{{ $t('Disable access') }}</p>
+        </dangerous-button>
+    </template>
+  </form>
+</template>
+
+<script>
+import axios from 'axios'
+import PasswordInput from '@/components/forms/PasswordInput'
+
+export default {
+  components: {
+    PasswordInput
+  },
+  data () {
+    return {
+      token: null,
+      errors: [],
+      success: false,
+      isLoading: false,
+      successMessage: ''
+    }
+  },
+  created () {
+    this.fetchToken()
+  },
+  methods: {
+    fetchToken () {
+      this.success = false
+      this.errors = []
+      this.isLoading = true
+      let self = this
+      let url = `users/users/${this.$store.state.auth.username}/subsonic-token/`
+      return axios.get(url).then(response => {
+        self.token = response.data['subsonic_api_token']
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    requestNewToken () {
+      this.successMessage = this.$t('Password updated')
+      this.success = false
+      this.errors = []
+      this.isLoading = true
+      let self = this
+      let url = `users/users/${this.$store.state.auth.username}/subsonic-token/`
+      return axios.post(url, {}).then(response => {
+        self.token = response.data['subsonic_api_token']
+        self.isLoading = false
+        self.success = true
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    disable () {
+      this.successMessage = this.$t('Access disabled')
+      this.success = false
+      this.errors = []
+      this.isLoading = true
+      let self = this
+      let url = `users/users/${this.$store.state.auth.username}/subsonic-token/`
+      return axios.delete(url).then(response => {
+        self.isLoading = false
+        self.token = null
+        self.success = true
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  },
+  computed: {
+    subsonicEnabled () {
+      return this.$store.state.instance.settings.subsonic.enabled.value
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/instance/Stats.vue b/front/src/components/instance/Stats.vue
index 7da9fc6ede056c4174f5dc6004f5c6233664008b..ac144ceb3a910e50e59ea9f9efd0575d2c037a0a 100644
--- a/front/src/components/instance/Stats.vue
+++ b/front/src/components/instance/Stats.vue
@@ -3,7 +3,7 @@
     <div v-if="stats" class="ui stackable two column grid">
       <div class="column">
         <h3 class="ui left aligned header"><i18next path="User activity"/></h3>
-        <div class="ui mini horizontal statistics">
+        <div v-if="stats" class="ui mini horizontal statistics">
           <div class="statistic">
             <div class="value">
               <i class="green user icon"></i>
@@ -19,7 +19,7 @@
           </div>
           <div class="statistic">
             <div class="value">
-              <i class="pink heart icon"></i> {{ stats.track_favorites }}
+              <i class="pink heart icon"></i> {{ stats.trackFavorites }}
             </div>
             <i18next tag="div" class="label" path="Tracks favorited"/>
           </div>
@@ -30,7 +30,7 @@
         <div class="ui mini horizontal statistics">
           <div class="statistic">
             <div class="value">
-              {{ parseInt(stats.music_duration) }}
+              {{ parseInt(stats.musicDuration) }}
             </div>
             <i18next tag="div" class="label" path="hours of music"/>
           </div>
@@ -59,6 +59,7 @@
 </template>
 
 <script>
+import _ from 'lodash'
 import axios from 'axios'
 import logger from '@/logging'
 
@@ -76,8 +77,16 @@ export default {
       var self = this
       this.isLoading = true
       logger.default.debug('Fetching instance stats...')
-      axios.get('instance/stats/').then((response) => {
-        self.stats = response.data
+      axios.get('instance/nodeinfo/2.0/').then((response) => {
+        let d = response.data
+        self.stats = {}
+        self.stats.users = _.get(d, 'usage.users.total')
+        self.stats.listenings = _.get(d, 'metadata.usage.listenings.total')
+        self.stats.trackFavorites = _.get(d, 'metadata.usage.favorites.tracks.total')
+        self.stats.musicDuration = _.get(d, 'metadata.library.music.hours')
+        self.stats.artists = _.get(d, 'metadata.library.artists.total')
+        self.stats.albums = _.get(d, 'metadata.library.albums.total')
+        self.stats.tracks = _.get(d, 'metadata.library.tracks.total')
         self.isLoading = false
       })
     }
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index 245acaf039adb4cc92e5df0760c3b38733e6b241..e78e804898c8c02a1f237297d3ab3dc653e62c0e 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -24,6 +24,11 @@ export default {
           value: true
         }
       },
+      subsonic: {
+        enabled: {
+          value: true
+        }
+      },
       raven: {
         front_enabled: {
           value: false