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/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 560e1c7f03a338e406e3b7299c616631f1d7ae25..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
@@ -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))
@@ -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/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/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/changes/changelog.d/75.feature b/changes/changelog.d/75.feature
new file mode 100644
index 0000000000000000000000000000000000000000..e603b0810d907052d7dc39e70cb4772199561b8a
--- /dev/null
+++ b/changes/changelog.d/75.feature
@@ -0,0 +1,43 @@
+Subsonic API implementation to offer compatibility with existing clients such as DSub (#75)
+
+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.
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/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/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/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/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