diff --git a/.env.dev b/.env.dev
index d9e2dd3ceb8c2625874cb4f7f902be5ab9c33320..e117dbe562c0fc536686a896994b62f96111d7d5 100644
--- a/.env.dev
+++ b/.env.dev
@@ -9,3 +9,5 @@ FUNKWHALE_PROTOCOL=http
 PYTHONDONTWRITEBYTECODE=true
 WEBPACK_DEVSERVER_PORT=8080
 MUSIC_DIRECTORY_PATH=/music
+BROWSABLE_API_ENABLED=True
+CACHEOPS_ENABLED=False
diff --git a/CHANGELOG b/CHANGELOG
index ba9b9f1ae0dbfac417fdb857eb46fbdb7718d744..c53714488a7a2050cc7cb09c0594ccbe4d9b02dd 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -10,6 +10,127 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
 
 .. towncrier
 
+0.13 (2018-05-19)
+-----------------
+
+Upgrade instructions are available at
+  https://docs.funkwhale.audio/upgrading.html
+
+Features:
+
+- Can now import and play flac files (#157)
+- Simpler permission system (#152)
+- Store file length, size and bitrate (#195)
+- We now have a brand new instance settings interface in the front-end (#206)
+
+
+Enhancements:
+
+- Disabled browsable HTML API in production (#205)
+- Instances can now indicate on the nodeinfo endpoint if they want to remain
+  private (#200)
+
+
+Bugfixes:
+
+- .well-known/nodeinfo endpoint can now answer to request with Accept:
+  application/json (#197)
+- Fixed escaping issue of track name in playlist modal (#201)
+- Fixed missing dot when downloading file (#204)
+- In-place imported tracks with non-ascii characters don't break reverse-proxy
+  serving (#196)
+- Removed Python 3.6 dependency (secrets module) (#198)
+- Uplayable tracks are now properly disabled in the interface (#199)
+
+
+Instance settings interface
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Prior to this release, the only way to update instance settings (such as
+instance description, signup policy, federation configuration, etc.) was using
+the admin interface provided by Django (the back-end framework which power the API).
+
+This interface worked, but was not really-user friendly and intuitive.
+
+Starting from this release, we now offer a dedicated interface directly
+in the front-end. You can view and edit all your instance settings from here,
+assuming you have the required permissions.
+
+This interface is available at ``/manage/settings` and via link in the sidebar.
+
+
+Storage of bitrate, size and length in database
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Starting with this release, when importing files, Funkwhale will store
+additional information about audio files:
+
+- Bitrate
+- Size (in bytes)
+- Duration
+
+This change is not retroactive, meaning already imported files will lack those
+informations. The interface and API should work as before in such case, however,
+we offer a command to deal with legacy files and populate the missing values.
+
+On docker setups:
+
+.. code-block:: shell
+
+    docker-compose run --rm api python manage.py fix_track_files
+
+
+On non-docker setups:
+
+.. code-block:: shell
+
+    # from your activated virtualenv
+    python manage.py fix_track_files
+
+.. note::
+
+    The execution time for this command is proportional to the number of
+    audio files stored on your instance. This is because we need to read the
+    files from disk to fetch the data. You can run it in the background
+    while Funkwhale is up.
+
+    It's also safe to interrupt this command and rerun it at a later point, or run
+    it multiple times.
+
+    Use the --dry-run flag to check how many files would be impacted.
+
+
+Simpler permission system
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Starting from this release, the permission system is much simpler. Up until now,
+we were using Django's built-in permission system, which was working, but also
+quite complex to deal with.
+
+The new implementation relies on simpler logic, which will make integration
+on the front-end in upcoming releases faster and easier.
+
+If you have manually given permissions to users on your instance,
+you can migrate those to the new system.
+
+On docker setups:
+
+.. code-block:: shell
+
+    docker-compose run --rm api python manage.py script django_permissions_to_user_permissions --no-input
+
+On non-docker setups:
+
+.. code-block:: shell
+
+    # in your virtualenv
+    python api/manage.py script django_permissions_to_user_permissions --no-input
+
+There is still no dedicated interface to manage user permissions, but you
+can use the admin interface at ``/api/admin/users/user/`` for that purpose in
+the meantime.
+
+
 0.12 (2018-05-09)
 -----------------
 
@@ -110,9 +231,7 @@ We offer two settings to manage nodeinfo in your Funkwhale instance:
    and user activity.
 
 To make your instance fully compatible with the nodeinfo protocol, you need to
-to edit your nginx configuration file:
-
-.. code-block::
+to edit your nginx configuration file::
 
     # before
     ...
@@ -130,9 +249,7 @@ to edit your nginx configuration file:
     }
     ...
 
-You can do the same if you use apache:
-
-.. code-block::
+You can do the same if you use apache::
 
     # before
     ...
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 5fed9f25e86d065aff3b8f4ab662d38c65870aaf..59aa93117e503b08f8a2fa53df8282b4ec0bbf32 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -406,8 +406,18 @@ REST_FRAMEWORK = {
     'DEFAULT_FILTER_BACKENDS': (
         'rest_framework.filters.OrderingFilter',
         'django_filters.rest_framework.DjangoFilterBackend',
+    ),
+    'DEFAULT_RENDERER_CLASSES': (
+        'rest_framework.renderers.JSONRenderer',
     )
 }
+
+BROWSABLE_API_ENABLED = env.bool('BROWSABLE_API_ENABLED', default=False)
+if BROWSABLE_API_ENABLED:
+    REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] += (
+        'rest_framework.renderers.BrowsableAPIRenderer',
+    )
+
 REST_AUTH_SERIALIZERS = {
     'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer'  # noqa
 }
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index f8b8af4126f34f3e55477c22d0c3a985ad827763..b0d7cc5173495755f7774850bcf4ccd0480133fe 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.12'
+__version__ = '0.13'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/common/dynamic_preferences_registry.py b/api/funkwhale_api/common/dynamic_preferences_registry.py
index 2374de7c79fe841ae63d8c18354335cf6fd022b2..15b182671bbf386b401eeb541c3839889342e29d 100644
--- a/api/funkwhale_api/common/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/common/dynamic_preferences_registry.py
@@ -16,5 +16,5 @@ class APIAutenticationRequired(
     help_text = (
         'If disabled, anonymous users will be able to query the API'
         'and access music data (as well as other data exposed in the API '
-        'without specific permissions)'
+        'without specific permissions).'
     )
diff --git a/api/funkwhale_api/common/management/__init__.py b/api/funkwhale_api/common/management/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/common/management/commands/__init__.py b/api/funkwhale_api/common/management/commands/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/common/management/commands/script.py b/api/funkwhale_api/common/management/commands/script.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d26a5836d46906967e24662a94bab553fa15880
--- /dev/null
+++ b/api/funkwhale_api/common/management/commands/script.py
@@ -0,0 +1,66 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from funkwhale_api.common import scripts
+
+
+class Command(BaseCommand):
+    help = 'Run a specific script from funkwhale_api/common/scripts/'
+
+    def add_arguments(self, parser):
+        parser.add_argument('script_name', nargs='?', type=str)
+        parser.add_argument(
+            '--noinput', '--no-input', action='store_false', dest='interactive',
+            help="Do NOT prompt the user for input of any kind.",
+        )
+
+    def handle(self, *args, **options):
+        name = options['script_name']
+        if not name:
+            self.show_help()
+
+        available_scripts = self.get_scripts()
+        try:
+            script = available_scripts[name]
+        except KeyError:
+            raise CommandError(
+                '{} is not a valid script. Run python manage.py script for a '
+                'list of available scripts'.format(name))
+
+        self.stdout.write('')
+        if options['interactive']:
+            message = (
+                'Are you sure you want to execute the script {}?\n\n'
+                "Type 'yes' to continue, or 'no' to cancel: "
+            ).format(name)
+            if input(''.join(message)) != 'yes':
+                raise CommandError("Script cancelled.")
+        script['entrypoint'](self, **options)
+
+    def show_help(self):
+        indentation = 4
+        self.stdout.write('')
+        self.stdout.write('Available scripts:')
+        self.stdout.write('Launch with: python manage.py <script_name>')
+        available_scripts = self.get_scripts()
+        for name, script in sorted(available_scripts.items()):
+            self.stdout.write('')
+            self.stdout.write(self.style.SUCCESS(name))
+            self.stdout.write('')
+            for line in script['help'].splitlines():
+                self.stdout.write('     {}'.format(line))
+        self.stdout.write('')
+
+    def get_scripts(self):
+        available_scripts = [
+            k for k in sorted(scripts.__dict__.keys())
+            if not k.startswith('__')
+        ]
+        data = {}
+        for name in available_scripts:
+            module = getattr(scripts, name)
+            data[name] = {
+                'name': name,
+                'help': module.__doc__.strip(),
+                'entrypoint': module.main
+            }
+        return data
diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py
index cab4b699d2a518cffc52aaf0cbad4473dcc28a6f..e9e8b8819f4b70e9ada3a0bcf71c709c9a5ef1de 100644
--- a/api/funkwhale_api/common/permissions.py
+++ b/api/funkwhale_api/common/permissions.py
@@ -3,7 +3,7 @@ import operator
 from django.conf import settings
 from django.http import Http404
 
-from rest_framework.permissions import BasePermission, DjangoModelPermissions
+from rest_framework.permissions import BasePermission
 
 from funkwhale_api.common import preferences
 
@@ -16,17 +16,6 @@ class ConditionalAuthentication(BasePermission):
         return True
 
 
-class HasModelPermission(DjangoModelPermissions):
-    """
-    Same as DjangoModelPermissions, but we pin the model:
-
-        class MyModelPermission(HasModelPermission):
-            model = User
-    """
-    def get_required_permissions(self, method, model_cls):
-        return super().get_required_permissions(method, self.model)
-
-
 class OwnerPermission(BasePermission):
     """
     Ensure the request user is the owner of the object.
diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b2d525202218c43483dae60b6df4f4c6090723e
--- /dev/null
+++ b/api/funkwhale_api/common/scripts/__init__.py
@@ -0,0 +1,2 @@
+from . import django_permissions_to_user_permissions
+from . import test
diff --git a/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..1bc971f80911de276ef2c210fafddfc052d71ee6
--- /dev/null
+++ b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py
@@ -0,0 +1,29 @@
+"""
+Convert django permissions to user permissions in the database,
+following the work done in #152.
+"""
+from django.db.models import Q
+from funkwhale_api.users import models
+
+from django.contrib.auth.models import Permission
+
+mapping = {
+    'dynamic_preferences.change_globalpreferencemodel': 'settings',
+    'music.add_importbatch': 'library',
+    'federation.change_library': 'federation',
+}
+
+
+def main(command, **kwargs):
+    for codename, user_permission in sorted(mapping.items()):
+        app_label, c = codename.split('.')
+        p = Permission.objects.get(
+            content_type__app_label=app_label, codename=c)
+        users = models.User.objects.filter(
+            Q(groups__permissions=p) | Q(user_permissions=p)).distinct()
+        total = users.count()
+
+        command.stdout.write('Updating {} users with {} permission...'.format(
+            total, user_permission
+        ))
+        users.update(**{'permission_{}'.format(user_permission): True})
diff --git a/api/funkwhale_api/common/scripts/test.py b/api/funkwhale_api/common/scripts/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..ab401dca4a7a46be53df6ffc1a09cd6657407ea8
--- /dev/null
+++ b/api/funkwhale_api/common/scripts/test.py
@@ -0,0 +1,8 @@
+"""
+This is a test script that does nothing.
+You can launch it just to check how it works.
+"""
+
+
+def main(command, **kwargs):
+    command.stdout.write('Test script run successfully')
diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py
index e86b9f6f2b8b30da1c82145ef288e591449aa9f3..8b1b2b03f9eb00f0eac1d467c099baf66d72d67d 100644
--- a/api/funkwhale_api/federation/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py
@@ -19,6 +19,9 @@ class MusicCacheDuration(types.IntPreference):
         'locally? Federated files that were not listened in this interval '
         'will be erased and refetched from the remote on the next listening.'
     )
+    field_kwargs = {
+        'required': False,
+    }
 
 
 @global_preferences_registry.register
@@ -29,7 +32,7 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
     verbose_name = 'Federation enabled'
     help_text = (
         'Use this setting to enable or disable federation logic and API'
-        ' globally'
+        ' globally.'
     )
 
 
@@ -41,8 +44,11 @@ class CollectionPageSize(
     setting = 'FEDERATION_COLLECTION_PAGE_SIZE'
     verbose_name = 'Federation collection page size'
     help_text = (
-        'How much items to display in ActivityPub collections'
+        'How much items to display in ActivityPub collections.'
     )
+    field_kwargs = {
+        'required': False,
+    }
 
 
 @global_preferences_registry.register
@@ -54,8 +60,11 @@ class ActorFetchDelay(
     verbose_name = 'Federation actor fetch delay'
     help_text = (
         'How much minutes to wait before refetching actors on '
-        'request authentication'
+        'request authentication.'
     )
+    field_kwargs = {
+        'required': False,
+    }
 
 
 @global_preferences_registry.register
@@ -66,6 +75,6 @@ class MusicNeedsApproval(
     setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL'
     verbose_name = 'Federation music needs approval'
     help_text = (
-        'When true, other federation actors will require your approval'
+        'When true, other federation actors will need your approval'
         ' before being able to browse your library.'
     )
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index 0754c4b2f44123e743f9df503c4457b529a17584..891609cbaa30117bbe0eccf802405a89c56b4914 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -233,6 +233,9 @@ class AudioMetadataFactory(factory.Factory):
     release = factory.LazyAttribute(
         lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4())
     )
+    bitrate = 42
+    length = 43
+    size = 44
 
     class Meta:
         model = dict
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index d91a00c8b50f5c103fc818f9dea47f6a55cbf9cf..69d0ea9254e7f28c7d54eb683b986a7b9d7b2033 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -216,3 +216,6 @@ class LibraryTrack(models.Model):
             for chunk in r.iter_content(chunk_size=512):
                 tmp_file.write(chunk)
             self.audio_file.save(filename, tmp_file)
+
+    def get_metadata(self, key):
+        return self.metadata.get(key)
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 426aabd771b1e5caaaa683648a123ccbe00aa986..8d3dd6379b2f327cfb9b11821b02d34527518171 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -688,6 +688,12 @@ class AudioMetadataSerializer(serializers.Serializer):
     artist = ArtistMetadataSerializer()
     release = ReleaseMetadataSerializer()
     recording = RecordingMetadataSerializer()
+    bitrate = serializers.IntegerField(
+        required=False, allow_null=True, min_value=0)
+    size = serializers.IntegerField(
+        required=False, allow_null=True, min_value=0)
+    length = serializers.IntegerField(
+        required=False, allow_null=True, min_value=0)
 
 
 class AudioSerializer(serializers.Serializer):
@@ -760,6 +766,9 @@ class AudioSerializer(serializers.Serializer):
                     'musicbrainz_id': str(track.mbid) if track.mbid else None,
                     'title': track.title,
                 },
+                'bitrate': instance.bitrate,
+                'size': instance.size,
+                'length': instance.duration,
             },
             'url': {
                 'href': utils.full_url(instance.path),
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index ef581408c2e187bf32f3922c651b903f3583884e..06a2cd040cfc0d94fe51ab419bf6159c6e14f631 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -15,8 +15,8 @@ from rest_framework.serializers import ValidationError
 
 from funkwhale_api.common import preferences
 from funkwhale_api.common import utils as funkwhale_utils
-from funkwhale_api.common.permissions import HasModelPermission
 from funkwhale_api.music.models import TrackFile
+from funkwhale_api.users.permissions import HasUserPermission
 
 from . import activity
 from . import actors
@@ -88,7 +88,7 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
 class WellKnownViewSet(viewsets.GenericViewSet):
     authentication_classes = []
     permission_classes = []
-    renderer_classes = [renderers.WebfingerRenderer]
+    renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer]
 
     @list_route(methods=['get'])
     def nodeinfo(self, request, *args, **kwargs):
@@ -187,16 +187,13 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
         return response.Response(data)
 
 
-class LibraryPermission(HasModelPermission):
-    model = models.Library
-
-
 class LibraryViewSet(
         mixins.RetrieveModelMixin,
         mixins.UpdateModelMixin,
         mixins.ListModelMixin,
         viewsets.GenericViewSet):
-    permission_classes = [LibraryPermission]
+    permission_classes = (HasUserPermission,)
+    required_permissions = ['federation']
     queryset = models.Library.objects.all().select_related(
         'actor',
         'follow',
@@ -291,7 +288,8 @@ class LibraryViewSet(
 class LibraryTrackViewSet(
         mixins.ListModelMixin,
         viewsets.GenericViewSet):
-    permission_classes = [LibraryPermission]
+    permission_classes = (HasUserPermission,)
+    required_permissions = ['federation']
     queryset = models.LibraryTrack.objects.all().select_related(
         'library__actor',
         'library__follow',
diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py
index 03555b0be57bde18f4fe13ba1b62d852ffd93ae4..8ccf80dd93448b4f2512ec2109cda32beaa33bf7 100644
--- a/api/funkwhale_api/instance/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py
@@ -13,8 +13,11 @@ class InstanceName(types.StringPreference):
     section = instance
     name = 'name'
     default = ''
-    help_text = 'Instance public name'
-    verbose_name = 'The public name of your instance'
+    verbose_name = 'Public name'
+    help_text = 'The public name of your instance, displayed in the about page.'
+    field_kwargs = {
+        'required': False,
+    }
 
 
 @global_preferences_registry.register
@@ -23,7 +26,11 @@ class InstanceShortDescription(types.StringPreference):
     section = instance
     name = 'short_description'
     default = ''
-    verbose_name = 'Instance succinct description'
+    verbose_name = 'Short description'
+    help_text = 'Instance succinct description, displayed in the about page.'
+    field_kwargs = {
+        'required': False,
+    }
 
 
 @global_preferences_registry.register
@@ -31,31 +38,31 @@ class InstanceLongDescription(types.StringPreference):
     show_in_api = True
     section = instance
     name = 'long_description'
+    verbose_name = 'Long description'
     default = ''
-    help_text = 'Instance long description (markdown allowed)'
+    help_text = 'Instance long description, displayed in the about page (markdown allowed).'
+    widget = widgets.Textarea
     field_kwargs = {
-        'widget': widgets.Textarea
+        'required': False,
     }
 
+
 @global_preferences_registry.register
 class RavenDSN(types.StringPreference):
     show_in_api = True
     section = raven
     name = 'front_dsn'
     default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4'
-    verbose_name = (
-        'A raven DSN key used to report front-ent errors to '
-        'a sentry instance'
-    )
+    verbose_name = 'Raven DSN key (front-end)'
+
     help_text = (
-        'Keeping the default one will report errors to funkwhale developers'
+        'A Raven DSN key used to report front-ent errors to '
+        'a sentry instance. Keeping the default one will report errors to '
+        'Funkwhale developers.'
     )
-
-
-SENTRY_HELP_TEXT = (
-    'Error reporting is disabled by default but you can enable it if'
-    ' you want to help us improve funkwhale'
-)
+    field_kwargs = {
+        'required': False,
+    }
 
 
 @global_preferences_registry.register
@@ -65,8 +72,7 @@ class RavenEnabled(types.BooleanPreference):
     name = 'front_enabled'
     default = False
     verbose_name = (
-        'Wether error reporting to a Sentry instance using raven is enabled'
-        ' for front-end errors'
+        'Report front-end errors with Raven'
     )
 
 
@@ -78,13 +84,27 @@ class InstanceNodeinfoEnabled(types.BooleanPreference):
     default = True
     verbose_name = 'Enable nodeinfo endpoint'
     help_text = (
-        'This endpoint is needed for your about page to work.'
+        'This endpoint is needed for your about page to work. '
         'It\'s also helpful for the various monitoring '
         'tools that map and analyzize the fediverse, '
         'but you can disable it completely if needed.'
     )
 
 
+@global_preferences_registry.register
+class InstanceNodeinfoPrivate(types.BooleanPreference):
+    show_in_api = False
+    section = instance
+    name = 'nodeinfo_private'
+    default = False
+    verbose_name = 'Private mode in nodeinfo'
+    help_text = (
+        'Indicate in the nodeinfo endpoint that you do not want your instance '
+        'to be tracked by third-party services. '
+        'There is no guarantee these tools will honor this setting though.'
+    )
+
+
 @global_preferences_registry.register
 class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
     show_in_api = False
@@ -93,6 +113,6 @@ class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
     default = True
     verbose_name = 'Enable usage and library stats in nodeinfo endpoint'
     help_text = (
-        'Disable this f you don\'t want to share usage and library statistics'
+        'Disable this if you don\'t want to share usage and library statistics '
         'in the nodeinfo endpoint but don\'t want to disable it completely.'
     )
diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py
index e267f197d17ce7c317128c684f1db8f72a1a47ba..dbc005af7b965b55a62abf7f47eb099174a88eac 100644
--- a/api/funkwhale_api/instance/nodeinfo.py
+++ b/api/funkwhale_api/instance/nodeinfo.py
@@ -12,6 +12,7 @@ memo = memoize.Memoizer(store, namespace='instance:stats')
 
 def get():
     share_stats = preferences.get('instance__nodeinfo_stats_enabled')
+    private = preferences.get('instance__nodeinfo_private')
     data = {
         'version': '2.0',
         'software': {
@@ -30,6 +31,7 @@ def get():
             }
         },
         'metadata': {
+            'private': preferences.get('instance__nodeinfo_private'),
             'shortDescription': preferences.get('instance__short_description'),
             'longDescription': preferences.get('instance__long_description'),
             'nodeName': preferences.get('instance__name'),
diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py
index f506488fc4db7819da6aae5d5c79fe33e8a9af5c..7992842c030c636057ed13236da282febda9cc4e 100644
--- a/api/funkwhale_api/instance/urls.py
+++ b/api/funkwhale_api/instance/urls.py
@@ -1,9 +1,11 @@
 from django.conf.urls import url
+from rest_framework import routers
 
 from . import views
-
+admin_router = routers.SimpleRouter()
+admin_router.register(r'admin/settings', views.AdminSettings, 'admin-settings')
 
 urlpatterns = [
     url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'),
     url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
-]
+] + admin_router.urls
diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py
index 5953ca555a3081d5e58a1d60da1d3dec58279e3b..b905acd3e6c1ce78811e44f691b17506b041ab6f 100644
--- a/api/funkwhale_api/instance/views.py
+++ b/api/funkwhale_api/instance/views.py
@@ -2,9 +2,11 @@ from rest_framework import views
 from rest_framework.response import Response
 
 from dynamic_preferences.api import serializers
+from dynamic_preferences.api import viewsets as preferences_viewsets
 from dynamic_preferences.registries import global_preferences_registry
 
 from funkwhale_api.common import preferences
+from funkwhale_api.users.permissions import HasUserPermission
 
 from . import nodeinfo
 from . import stats
@@ -15,6 +17,11 @@ NODEINFO_2_CONTENT_TYPE = (
 )
 
 
+class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
+    pagination_class = None
+    permission_classes = (HasUserPermission,)
+    required_permissions = ['settings']
+
 class InstanceSettings(views.APIView):
     permission_classes = []
     authentication_classes = []
diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py
index 219b40a91deeeb9d06dd55d3292aa5ca22ccc654..1654428baf866df98f9888c6b101cce2425702cd 100644
--- a/api/funkwhale_api/music/admin.py
+++ b/api/funkwhale_api/music/admin.py
@@ -38,6 +38,7 @@ class ImportBatchAdmin(admin.ModelAdmin):
     search_fields = [
         'import_request__name', 'source', 'batch__pk', 'mbid']
 
+
 @admin.register(models.ImportJob)
 class ImportJobAdmin(admin.ModelAdmin):
     list_display = ['source', 'batch', 'track_file', 'status', 'mbid']
@@ -73,9 +74,16 @@ class TrackFileAdmin(admin.ModelAdmin):
         'source',
         'duration',
         'mimetype',
+        'size',
+        'bitrate'
     ]
     list_select_related = [
         'track'
     ]
-    search_fields = ['source', 'acoustid_track_id']
+    search_fields = [
+        'source',
+        'acoustid_track_id',
+        'track__title',
+        'track__album__title',
+        'track__artist__name']
     list_filter = ['mimetype']
diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py
index 1df949904db992e2cd295d02e4acbfed9d212627..412e2f798835579217f6fa84b35e926d59baaba9 100644
--- a/api/funkwhale_api/music/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -54,6 +54,10 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
     audio_file = factory.django.FileField(
         from_path=os.path.join(SAMPLES_PATH, 'test.ogg'))
 
+    bitrate = None
+    size = None
+    duration = None
+
     class Meta:
         model = 'music.TrackFile'
 
diff --git a/api/funkwhale_api/music/management/commands/fix_track_files.py b/api/funkwhale_api/music/management/commands/fix_track_files.py
index f68bcf1359d4661710a98ee443ff1578824f46f2..9adc1b9bf1a9d24f3cb26b6da4dce68dc6078213 100644
--- a/api/funkwhale_api/music/management/commands/fix_track_files.py
+++ b/api/funkwhale_api/music/management/commands/fix_track_files.py
@@ -2,6 +2,7 @@ import cacheops
 import os
 
 from django.db import transaction
+from django.db.models import Q
 from django.conf import settings
 from django.core.management.base import BaseCommand, CommandError
 
@@ -24,6 +25,8 @@ class Command(BaseCommand):
         if options['dry_run']:
             self.stdout.write('Dry-run on, will not commit anything')
         self.fix_mimetypes(**options)
+        self.fix_file_data(**options)
+        self.fix_file_size(**options)
         cacheops.invalidate_model(models.TrackFile)
 
     @transaction.atomic
@@ -43,3 +46,60 @@ class Command(BaseCommand):
             if not dry_run:
                 self.stdout.write('[mimetypes] commiting...')
                 qs.update(mimetype=mimetype)
+
+    def fix_file_data(self, dry_run, **kwargs):
+        self.stdout.write('Fixing missing bitrate or length...')
+        matching = models.TrackFile.objects.filter(
+            Q(bitrate__isnull=True) | Q(duration__isnull=True))
+        total = matching.count()
+        self.stdout.write(
+            '[bitrate/length] {} entries found with missing values'.format(
+                total))
+        if dry_run:
+            return
+        for i, tf in enumerate(matching.only('audio_file')):
+            self.stdout.write(
+                '[bitrate/length] {}/{} fixing file #{}'.format(
+                    i+1, total, tf.pk
+                ))
+
+            try:
+                audio_file = tf.get_audio_file()
+                if audio_file:
+                    with audio_file as f:
+                        data = utils.get_audio_file_data(audio_file)
+                    tf.bitrate = data['bitrate']
+                    tf.duration = data['length']
+                    tf.save(update_fields=['duration', 'bitrate'])
+                else:
+                    self.stderr.write('[bitrate/length] no file found')
+            except Exception as e:
+                self.stderr.write(
+                    '[bitrate/length] error with file #{}: {}'.format(
+                        tf.pk, str(e)
+                    )
+                )
+
+    def fix_file_size(self, dry_run, **kwargs):
+        self.stdout.write('Fixing missing size...')
+        matching = models.TrackFile.objects.filter(size__isnull=True)
+        total = matching.count()
+        self.stdout.write(
+            '[size] {} entries found with missing values'.format(total))
+        if dry_run:
+            return
+        for i, tf in enumerate(matching.only('size')):
+            self.stdout.write(
+                '[size] {}/{} fixing file #{}'.format(
+                    i+1, total, tf.pk
+                ))
+
+            try:
+                tf.size = tf.get_file_size()
+                tf.save(update_fields=['size'])
+            except Exception as e:
+                self.stderr.write(
+                    '[size] error with file #{}: {}'.format(
+                        tf.pk, str(e)
+                    )
+                )
diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py
index a200697834d1f68aff4291c5fcf9857ad401a1cd..49425671199d5888ded30df31f9465ee9f239355 100644
--- a/api/funkwhale_api/music/metadata.py
+++ b/api/funkwhale_api/music/metadata.py
@@ -28,6 +28,13 @@ def get_id3_tag(f, k):
         raise TagNotFound(k)
 
 
+def get_flac_tag(f, k):
+    try:
+        return f.get(k)[0]
+    except (KeyError, IndexError):
+        raise TagNotFound(k)
+
+
 def get_mp3_recording_id(f, k):
     try:
         return [
@@ -121,7 +128,38 @@ CONF = {
                 'getter': get_mp3_recording_id,
             },
         }
-    }
+    },
+    'FLAC': {
+        'getter': get_flac_tag,
+        'fields': {
+            'track_number': {
+                'field': 'tracknumber',
+                'to_application': convert_track_number
+            },
+            'title': {
+                'field': 'title'
+            },
+            'artist': {
+                'field': 'artist'
+            },
+            'album': {
+                'field': 'album'
+            },
+            'date': {
+                'field': 'date',
+                'to_application': lambda v: arrow.get(str(v)).date()
+            },
+            'musicbrainz_albumid': {
+                'field': 'musicbrainz_albumid'
+            },
+            'musicbrainz_artistid': {
+                'field': 'musicbrainz_artistid'
+            },
+            'musicbrainz_recordingid': {
+                'field': 'musicbrainz_trackid'
+            },
+        }
+    },
 }
 
 
diff --git a/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py
new file mode 100644
index 0000000000000000000000000000000000000000..835e115a6571c010148c939f11f15023a1d42475
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.0.3 on 2018-05-15 18:08
+
+from django.db import migrations, models
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0026_trackfile_accessed_date'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='trackfile',
+            name='bitrate',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='trackfile',
+            name='size',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='track',
+            name='tags',
+            field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 5ee5d851dc122f2aa632952eed2b218d76b0dfd7..1259cc3c12406a7848649d829dfed7e8999f4539 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -361,7 +361,7 @@ class Track(APIModelMixin):
         import_tags
     ]
     objects = TrackQuerySet.as_manager()
-    tags = TaggableManager()
+    tags = TaggableManager(blank=True)
 
     class Meta:
         ordering = ['album', 'position']
@@ -429,6 +429,8 @@ class TrackFile(models.Model):
     modification_date = models.DateTimeField(auto_now=True)
     accessed_date = models.DateTimeField(null=True, blank=True)
     duration = models.IntegerField(null=True, blank=True)
+    size = models.IntegerField(null=True, blank=True)
+    bitrate = models.IntegerField(null=True, blank=True)
     acoustid_track_id = models.UUIDField(null=True, blank=True)
     mimetype = models.CharField(null=True, blank=True, max_length=200)
 
@@ -467,7 +469,7 @@ class TrackFile(models.Model):
 
     @property
     def filename(self):
-        return '{}{}'.format(
+        return '{}.{}'.format(
             self.track.full_name,
             self.extension)
 
@@ -477,6 +479,41 @@ class TrackFile(models.Model):
             return
         return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1)
 
+    def get_file_size(self):
+        if self.audio_file:
+            return self.audio_file.size
+
+        if self.source.startswith('file://'):
+            return os.path.getsize(self.source.replace('file://', '', 1))
+
+        if self.library_track and self.library_track.audio_file:
+            return self.library_track.audio_file.size
+
+    def get_audio_file(self):
+        if self.audio_file:
+            return self.audio_file.open()
+        if self.source.startswith('file://'):
+            return open(self.source.replace('file://', '', 1), 'rb')
+        if self.library_track and self.library_track.audio_file:
+            return self.library_track.audio_file.open()
+
+    def set_audio_data(self):
+        audio_file = self.get_audio_file()
+        if audio_file:
+            with audio_file as f:
+                audio_data = utils.get_audio_file_data(f)
+            if not audio_data:
+                return
+            self.duration = int(audio_data['length'])
+            self.bitrate = audio_data['bitrate']
+            self.size = self.get_file_size()
+        else:
+            lt = self.library_track
+            if lt:
+                self.duration = lt.get_metadata('length')
+                self.size = lt.get_metadata('size')
+                self.bitrate = lt.get_metadata('bitrate')
+
     def save(self, **kwargs):
         if not self.mimetype and self.audio_file:
             self.mimetype = utils.guess_mimetype(self.audio_file)
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 9dfc9147872871b0f0ba500c213f812b79bad8d7..d9d48496e487395be4ff0516b64f43b1074f37c1 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -27,6 +27,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer):
 
 class ArtistSerializer(serializers.ModelSerializer):
     tags = TagSerializer(many=True, read_only=True)
+
     class Meta:
         model = models.Artist
         fields = ('id', 'mbid', 'name', 'tags', 'creation_date')
@@ -40,11 +41,21 @@ class TrackFileSerializer(serializers.ModelSerializer):
         fields = (
             'id',
             'path',
-            'duration',
             'source',
             'filename',
             'mimetype',
-            'track')
+            'track',
+            'duration',
+            'mimetype',
+            'bitrate',
+            'size',
+        )
+        read_only_fields = [
+            'duration',
+            'mimetype',
+            'bitrate',
+            'size',
+        ]
 
     def get_path(self, o):
         url = o.path
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index bad0006aa98520e2afac00aad8d5466bde2b8934..34345e47b49e9f44f41bb341ebdb059ff5185464 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -134,6 +134,7 @@ def _do_import(import_job, replace=False, use_acoustid=True):
         # in place import, we set mimetype from extension
         path, ext = os.path.splitext(import_job.source)
         track_file.mimetype = music_utils.get_type_from_ext(ext)
+    track_file.set_audio_data()
     track_file.save()
     import_job.status = 'finished'
     import_job.track_file = track_file
diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py
index 49a63930349a081178bc36c87e73a702e4d9faac..f11e4507a7a0bc7d1648e5dfda18f3deb4542286 100644
--- a/api/funkwhale_api/music/utils.py
+++ b/api/funkwhale_api/music/utils.py
@@ -1,5 +1,6 @@
 import magic
 import mimetypes
+import mutagen
 import re
 
 from django.db.models import Q
@@ -66,6 +67,7 @@ def compute_status(jobs):
 AUDIO_EXTENSIONS_AND_MIMETYPE = [
     ('ogg', 'audio/ogg'),
     ('mp3', 'audio/mpeg'),
+    ('flac', 'audio/x-flac'),
 ]
 
 EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE}
@@ -81,3 +83,14 @@ def get_type_from_ext(extension):
         # we remove leading dot
         extension = extension[1:]
     return EXTENSION_TO_MIMETYPE.get(extension)
+
+
+def get_audio_file_data(f):
+    data = mutagen.File(f)
+    if not data:
+        return
+    d = {}
+    d['bitrate'] = data.info.bitrate
+    d['length'] = data.info.length
+
+    return d
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 98274e741293f6ef9b4b9cf9ce5f59e32eb8815f..e71d3555e67a21012ee34d79e2fd56b28354fcf8 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -25,8 +25,8 @@ from rest_framework import permissions
 from musicbrainzngs import ResponseError
 
 from funkwhale_api.common import utils as funkwhale_utils
-from funkwhale_api.common.permissions import (
-    ConditionalAuthentication, HasModelPermission)
+from funkwhale_api.common.permissions import ConditionalAuthentication
+from funkwhale_api.users.permissions import HasUserPermission
 from taggit.models import Tag
 from funkwhale_api.federation import actors
 from funkwhale_api.federation.authentication import SignatureAuthentication
@@ -107,25 +107,22 @@ class ImportBatchViewSet(
               .annotate(job_count=Count('jobs'))
     )
     serializer_class = serializers.ImportBatchSerializer
-    permission_classes = (permissions.DjangoModelPermissions, )
+    permission_classes = (HasUserPermission,)
+    required_permissions = ['library']
     filter_class = filters.ImportBatchFilter
 
     def perform_create(self, serializer):
         serializer.save(submitted_by=self.request.user)
 
 
-class ImportJobPermission(HasModelPermission):
-    # not a typo, perms on import job is proxied to import batch
-    model = models.ImportBatch
-
-
 class ImportJobViewSet(
         mixins.CreateModelMixin,
         mixins.ListModelMixin,
         viewsets.GenericViewSet):
     queryset = (models.ImportJob.objects.all().select_related())
     serializer_class = serializers.ImportJobSerializer
-    permission_classes = (ImportJobPermission, )
+    permission_classes = (HasUserPermission,)
+    required_permissions = ['library']
     filter_class = filters.ImportJobFilter
 
     @list_route(methods=['get'])
@@ -230,7 +227,7 @@ def get_file_path(audio_file):
                     'MUSIC_DIRECTORY_PATH to serve in-place imported files'
                 )
             path = '/music' + audio_file.replace(prefix, '', 1)
-        return settings.PROTECT_FILES_PATH + path
+        return (settings.PROTECT_FILES_PATH + path).encode('utf-8')
     if t == 'apache2':
         try:
             path = audio_file.path
@@ -241,7 +238,7 @@ def get_file_path(audio_file):
                     'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
                     'MUSIC_DIRECTORY_PATH to serve in-place imported files'
                 )
-            path = audio_file.replace(prefix, serve_path, 1)
+            path = audio_file.replace(prefix, serve_path, 1).encode('utf-8')
         return path
 
 
@@ -268,6 +265,10 @@ def handle_serve(track_file):
                 qs = LibraryTrack.objects.select_for_update()
                 library_track = qs.get(pk=library_track.pk)
                 library_track.download_audio()
+            track_file.library_track = library_track
+            track_file.set_audio_data()
+            track_file.save(update_fields=['bitrate', 'duration', 'size'])
+
         audio_file = library_track.audio_file
         file_path = get_file_path(audio_file)
         mt = library_track.audio_mimetype
@@ -275,7 +276,10 @@ def handle_serve(track_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()
+    if mt:
+        response = Response(content_type=mt)
+    else:
+        response = Response()
     filename = f.filename
     mapping = {
         'nginx': 'X-Accel-Redirect',
@@ -293,7 +297,11 @@ def handle_serve(track_file):
 
 
 class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
-    queryset = (models.TrackFile.objects.all().order_by('-id'))
+    queryset = (
+        models.TrackFile.objects.all()
+            .select_related('track__artist', 'track__album')
+            .order_by('-id')
+    )
     serializer_class = serializers.TrackFileSerializer
     authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [
         SignatureAuthentication
@@ -431,7 +439,8 @@ class Search(views.APIView):
 
 class SubmitViewSet(viewsets.ViewSet):
     queryset = models.ImportBatch.objects.none()
-    permission_classes = (permissions.DjangoModelPermissions, )
+    permission_classes = (HasUserPermission,)
+    required_permissions = ['library']
 
     @list_route(methods=['post'])
     @transaction.non_atomic_requests
diff --git a/api/funkwhale_api/playlists/dynamic_preferences_registry.py b/api/funkwhale_api/playlists/dynamic_preferences_registry.py
index 21140fa1495adb734aa64e41be4bae218ba74304..b717177a2368ccfb267f651bd76def8c6e9fc443 100644
--- a/api/funkwhale_api/playlists/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/playlists/dynamic_preferences_registry.py
@@ -13,3 +13,6 @@ class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference):
     name = 'max_tracks'
     verbose_name = 'Max tracks per playlist'
     setting = 'PLAYLISTS_MAX_TRACKS'
+    field_kwargs = {
+        'required': False,
+    }
diff --git a/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py b/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py
index da785df4065aff76cf8d1a4e9d19967b0b7bfa9c..33c9643b00b4ec96665a7e8cbcfae13098d13a04 100644
--- a/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py
@@ -1,3 +1,5 @@
+from django import forms
+
 from dynamic_preferences.types import StringPreference, Section
 from dynamic_preferences.registries import global_preferences_registry
 
@@ -11,3 +13,7 @@ class APIKey(StringPreference):
     default = ''
     verbose_name = 'Acoustid API key'
     help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.'
+    widget = forms.PasswordInput
+    field_kwargs = {
+        'required': False,
+    }
diff --git a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py
index fc7f7d793e5a535f344d63678f418b0736ae2862..ac5fc4bde29a313429b7644b51846fa3e33b0465 100644
--- a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py
@@ -1,3 +1,5 @@
+from django import forms
+
 from dynamic_preferences.types import StringPreference, Section
 from dynamic_preferences.registries import global_preferences_registry
 
@@ -11,3 +13,7 @@ class APIKey(StringPreference):
     default = 'CHANGEME'
     verbose_name = 'YouTube API key'
     help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.'
+    widget = forms.PasswordInput
+    field_kwargs = {
+        'required': False,
+    }
diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py
index 74cf13d887d9186a1bd50b4569fe59970daff762..3a56645012f07910a9c0e175b3b3e3d6f6bec8f2 100644
--- a/api/funkwhale_api/subsonic/renderers.py
+++ b/api/funkwhale_api/subsonic/renderers.py
@@ -15,6 +15,9 @@ class SubsonicJSONRenderer(renderers.JSONRenderer):
             }
         }
         final['subsonic-response'].update(data)
+        if 'error' in final:
+            # an error was returned
+            final['subsonic-response']['status'] = 'failed'
         return super().render(final, accepted_media_type, renderer_context)
 
 
@@ -31,6 +34,9 @@ class SubsonicXMLRenderer(renderers.JSONRenderer):
             'version': '1.16.0',
         }
         final.update(data)
+        if 'error' in final:
+            # an error was returned
+            final['status'] = 'failed'
         tree = dict_to_xml_tree('subsonic-response', final)
         return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(tree, encoding='utf-8')
 
diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py
index 5bc452886d7486bdc0ad7c1f5571ea69fc405895..6709930f56756abae175f1158bae0f9d94ec67b7 100644
--- a/api/funkwhale_api/subsonic/serializers.py
+++ b/api/funkwhale_api/subsonic/serializers.py
@@ -81,6 +81,10 @@ def get_track_data(album, track, tf):
         'artistId': album.artist.pk,
         'type': 'music',
     }
+    if tf.bitrate:
+        data['bitrate'] = int(tf.bitrate/1000)
+    if tf.size:
+        data['size'] = tf.size
     if album.release_date:
         data['year'] = album.release_date.year
     return data
@@ -211,5 +215,9 @@ def get_music_directory_data(artist):
             'parent': artist.id,
             'type': 'music',
         }
+        if tf.bitrate:
+            td['bitrate'] = int(tf.bitrate/1000)
+        if tf.size:
+            td['size'] = tf.size
         data['child'].append(td)
     return data
diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py
index 475e61aa73119ff8df9a71fa2a27cf3188dc0cff..2692a3dda804ad22c07ee340012ad037afb5e42b 100644
--- a/api/funkwhale_api/subsonic/views.py
+++ b/api/funkwhale_api/subsonic/views.py
@@ -31,15 +31,19 @@ def find_object(queryset, model_field='pk', field='id', cast=int):
                 raw_value = data[field]
             except KeyError:
                 return response.Response({
-                    'code': 10,
-                    'message': "required parameter '{}' not present".format(field)
+                    'error': {
+                        '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)
+                    'error': {
+                        'code': 0,
+                        'message': 'For input string "{}"'.format(raw_value)
+                    }
                 })
             qs = queryset
             if hasattr(qs, '__call__'):
@@ -48,9 +52,11 @@ def find_object(queryset, model_field='pk', field='id', cast=int):
                 obj = qs.get(**{model_field: value})
             except qs.model.DoesNotExist:
                 return response.Response({
-                    'code': 70,
-                    'message': '{} not found'.format(
-                        qs.model.__class__.__name__)
+                    'error': {
+                        'code': 70,
+                        'message': '{} not found'.format(
+                            qs.model.__class__.__name__)
+                    }
                 })
             kwargs['obj'] = obj
             return func(self, request, *args, **kwargs)
@@ -83,15 +89,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
         payload = {
             'status': 'failed'
         }
-        try:
+        if exc.__class__ in mapping:
             code, message = mapping[exc.__class__]
-        except KeyError:
-            return super().handle_exception(exc)
         else:
-            payload['error'] = {
-                'code': code,
-                'message': message
-            }
+            return super().handle_exception(exc)
+        payload['error'] = {
+            'code': code,
+            'message': message
+        }
 
         return response.Response(payload, status=200)
 
@@ -450,8 +455,10 @@ class SubsonicViewSet(viewsets.GenericViewSet):
         name = data.get('name', '')
         if not name:
             return response.Response({
-                'code': 10,
-                'message': 'Playlist ID or name must be specified.'
+                'error': {
+                    'code': 10,
+                    'message': 'Playlist ID or name must be specified.'
+                }
             }, data)
 
         playlist = request.user.playlists.create(
diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py
index 89b67d3df96de05b9ffd8fff9ce027f67f101318..7e9062a1308a298fb83453fa0cb1c53c5740dd51 100644
--- a/api/funkwhale_api/users/admin.py
+++ b/api/funkwhale_api/users/admin.py
@@ -5,6 +5,7 @@ from django import forms
 from django.contrib import admin
 from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
 from django.contrib.auth.forms import UserChangeForm, UserCreationForm
+from django.utils.translation import ugettext_lazy as _
 
 from .models import User
 
@@ -41,8 +42,33 @@ class UserAdmin(AuthUserAdmin):
         'email',
         'date_joined',
         'last_login',
-        'privacy_level',
+        'is_staff',
+        'is_superuser',
     ]
     list_filter = [
+        'is_superuser',
+        'is_staff',
         'privacy_level',
+        'permission_settings',
+        'permission_library',
+        'permission_federation',
     ]
+
+    fieldsets = (
+        (None, {'fields': ('username', 'password', 'privacy_level')}),
+        (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
+        (_('Permissions'), {
+            'fields': (
+                'is_active',
+                'is_staff',
+                'is_superuser',
+                'permission_library',
+                'permission_settings',
+                'permission_federation')}),
+        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
+        (_('Useless fields'), {
+            'fields': (
+                'user_permissions',
+                'groups',
+            )})
+        )
diff --git a/api/funkwhale_api/users/dynamic_preferences_registry.py b/api/funkwhale_api/users/dynamic_preferences_registry.py
index 16d79da143cb3139f9a92137044b6092288e1b40..4f736053088df4fad3ccd804744c8dec826d388f 100644
--- a/api/funkwhale_api/users/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/users/dynamic_preferences_registry.py
@@ -10,6 +10,7 @@ class RegistrationEnabled(types.BooleanPreference):
     section = users
     name = 'registration_enabled'
     default = False
-    verbose_name = (
-        'Can visitors open a new account on this instance?'
+    verbose_name = 'Open registrations to new users'
+    help_text = (
+        'When enabled, new users will be able to register on this instance.'
     )
diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py
index 12307f7fd109407f025b05dd364146204a59ae27..cd28f44073ded3ad425ee3c1f84a7c4b6dcabec6 100644
--- a/api/funkwhale_api/users/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -1,15 +1,41 @@
 import factory
 
-from funkwhale_api.factories import registry
+from funkwhale_api.factories import registry, ManyToManyFromList
 from django.contrib.auth.models import Permission
 
 
+@registry.register
+class GroupFactory(factory.django.DjangoModelFactory):
+    name = factory.Sequence(lambda n: 'group-{0}'.format(n))
+
+    class Meta:
+        model = 'auth.Group'
+
+    @factory.post_generation
+    def perms(self, create, extracted, **kwargs):
+        if not create:
+            # Simple build, do nothing.
+            return
+
+        if extracted:
+            perms = [
+                Permission.objects.get(
+                    content_type__app_label=p.split('.')[0],
+                    codename=p.split('.')[1],
+                )
+                for p in extracted
+            ]
+            # A list of permissions were passed in, use them
+            self.permissions.add(*perms)
+
+
 @registry.register
 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
+    groups = ManyToManyFromList('groups')
 
     class Meta:
         model = 'users.User'
diff --git a/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c9ab0fadc99016e8a62f609ace117ea0b941965
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.0.4 on 2018-05-17 23:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0005_user_subsonic_api_token'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='permission_federation',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='permission_library',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='permission_settings',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 773d60f38ebec50dd46cda63b05b37ac4659573c..c16cd62b319c01d4a9f1802c531d279f0ef802ed 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -1,11 +1,12 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals, absolute_import
 
+import binascii
+import os
 import uuid
-import secrets
 
 from django.conf import settings
-from django.contrib.auth.models import AbstractUser
+from django.contrib.auth.models import AbstractUser, Permission
 from django.urls import reverse
 from django.db import models
 from django.utils.encoding import python_2_unicode_compatible
@@ -14,6 +15,17 @@ from django.utils.translation import ugettext_lazy as _
 from funkwhale_api.common import fields
 
 
+def get_token():
+    return binascii.b2a_hex(os.urandom(15)).decode('utf-8')
+
+
+PERMISSIONS = [
+    'federation',
+    'library',
+    'settings',
+]
+
+
 @python_2_unicode_compatible
 class User(AbstractUser):
 
@@ -23,20 +35,6 @@ class User(AbstractUser):
 
     # updated on logout or password change, to invalidate JWT
     secret_key = models.UUIDField(default=uuid.uuid4, null=True)
-    # permissions that are used for API access and that worth serializing
-    relevant_permissions = {
-        # internal_codename : {external_codename}
-        'music.add_importbatch': {
-            'external_codename': 'import.launch',
-        },
-        'dynamic_preferences.change_globalpreferencemodel': {
-            'external_codename': 'settings.change',
-        },
-        'federation.change_library': {
-            'external_codename': 'federation.manage',
-        },
-    }
-
     privacy_level = fields.get_privacy_field()
 
     # Unfortunately, Subsonic API assumes a MD5/password authentication
@@ -47,9 +45,33 @@ class User(AbstractUser):
     subsonic_api_token = models.CharField(
         blank=True, null=True, max_length=255)
 
+    # permissions
+    permission_federation = models.BooleanField(
+        'Manage library federation',
+        help_text='Follow other instances, accept/deny library follow requests...',
+        default=False)
+    permission_library = models.BooleanField(
+        'Manage library',
+        help_text='Import new content, manage existing content',
+        default=False)
+    permission_settings = models.BooleanField(
+        'Manage instance-level settings',
+        default=False)
+
     def __str__(self):
         return self.username
 
+    def get_permissions(self):
+        perms = {}
+        for p in PERMISSIONS:
+            v = self.is_superuser or getattr(self, 'permission_{}'.format(p))
+            perms[p] = v
+        return perms
+
+    def has_permissions(self, *perms):
+        permissions = self.get_permissions()
+        return all([permissions[p] for p in perms])
+
     def get_absolute_url(self):
         return reverse('users:detail', kwargs={'username': self.username})
 
@@ -58,7 +80,7 @@ class User(AbstractUser):
         return self.secret_key
 
     def update_subsonic_api_token(self):
-        self.subsonic_api_token = secrets.token_hex(32)
+        self.subsonic_api_token = get_token()
         return self.subsonic_api_token
 
     def set_password(self, raw_password):
diff --git a/api/funkwhale_api/users/permissions.py b/api/funkwhale_api/users/permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ff49ff3fa6661aecd0616fdb87b5b43f89a95c2
--- /dev/null
+++ b/api/funkwhale_api/users/permissions.py
@@ -0,0 +1,19 @@
+from rest_framework.permissions import BasePermission
+
+
+class HasUserPermission(BasePermission):
+    """
+    Ensure the request user has the proper permissions.
+
+    Usage:
+
+    class MyView(APIView):
+        permission_classes = [HasUserPermission]
+        required_permissions = ['federation']
+    """
+    def has_permission(self, request, view):
+        if not hasattr(request, 'user') or not request.user:
+            return False
+        if request.user.is_anonymous:
+            return False
+        return request.user.has_permissions(*view.required_permissions)
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index eadce6154fa12c385f0655d3667b1634442716ec..3a095e78aa727b52ed35153cfffc8419b6142172 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -55,16 +55,11 @@ class UserReadSerializer(serializers.ModelSerializer):
             'is_superuser',
             'permissions',
             'date_joined',
-            'privacy_level'
+            'privacy_level',
         ]
 
     def get_permissions(self, o):
-        perms = {}
-        for internal_codename, conf in o.relevant_permissions.items():
-            perms[conf['external_codename']] = {
-                'status': o.has_perm(internal_codename)
-            }
-        return perms
+        return o.get_permissions()
 
 
 class PasswordResetSerializer(PRS):
diff --git a/api/tests/common/test_scripts.py b/api/tests/common/test_scripts.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce478ba048043b6c1da61d0febf72fd91e8c2444
--- /dev/null
+++ b/api/tests/common/test_scripts.py
@@ -0,0 +1,56 @@
+import pytest
+
+from funkwhale_api.common.management.commands import script
+from funkwhale_api.common import scripts
+
+
+@pytest.fixture
+def command():
+    return script.Command()
+
+
+@pytest.mark.parametrize('script_name', [
+    'django_permissions_to_user_permissions',
+    'test',
+])
+def test_script_command_list(command, script_name, mocker):
+    mocked = mocker.patch(
+        'funkwhale_api.common.scripts.{}.main'.format(script_name))
+
+    command.handle(script_name=script_name, interactive=False)
+
+    mocked.assert_called_once_with(
+        command, script_name=script_name, interactive=False)
+
+
+def test_django_permissions_to_user_permissions(factories, command):
+    group = factories['auth.Group'](
+        perms=[
+            'federation.change_library'
+        ]
+    )
+    user1 = factories['users.User'](
+        perms=[
+            'dynamic_preferences.change_globalpreferencemodel',
+            'music.add_importbatch',
+        ]
+    )
+    user2 = factories['users.User'](
+        perms=[
+            'music.add_importbatch',
+        ],
+        groups=[group]
+    )
+
+    scripts.django_permissions_to_user_permissions.main(command)
+
+    user1.refresh_from_db()
+    user2.refresh_from_db()
+
+    assert user1.permission_settings is True
+    assert user1.permission_library is True
+    assert user1.permission_federation is False
+
+    assert user2.permission_settings is False
+    assert user2.permission_library is True
+    assert user2.permission_federation is True
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index dda537801f3cf66efb7eec4e823816e5dcec8207..b7a7d071ab6e8e548b7026b84ff815c0f2e6e43a 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -14,6 +14,7 @@ from rest_framework.test import APIClient
 from rest_framework.test import APIRequestFactory
 
 from funkwhale_api.activity import record
+from funkwhale_api.users.permissions import HasUserPermission
 from funkwhale_api.taskapp import celery
 
 
@@ -224,3 +225,11 @@ def authenticated_actor(factories, mocker):
         'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
         return_value=actor)
     yield actor
+
+
+@pytest.fixture
+def assert_user_permission():
+    def inner(view, permissions):
+        assert HasUserPermission in view.permission_classes
+        assert set(view.required_permissions) == set(permissions)
+    return inner
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index 85208fa490b53dd52b998b24f6f743f9d465d785..f298c61f5fbd36f9516e607d00e60649c4b9b281 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -533,7 +533,12 @@ def test_activity_pub_audio_serializer_to_library_track_no_duplicate(
 
 
 def test_activity_pub_audio_serializer_to_ap(factories):
-    tf = factories['music.TrackFile'](mimetype='audio/mp3')
+    tf = factories['music.TrackFile'](
+        mimetype='audio/mp3',
+        bitrate=42,
+        duration=43,
+        size=44,
+    )
     library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     expected = {
         '@context': serializers.AP_CONTEXT,
@@ -555,6 +560,9 @@ def test_activity_pub_audio_serializer_to_ap(factories):
                 'musicbrainz_id': tf.track.mbid,
                 'title': tf.track.title,
             },
+            'size': tf.size,
+            'length': tf.duration,
+            'bitrate': tf.bitrate,
         },
         'url': {
             'href': utils.full_url(tf.path),
@@ -599,6 +607,9 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories):
                 'title': tf.track.title,
                 'musicbrainz_id': None,
             },
+            'size': None,
+            'length': None,
+            'bitrate': None,
         },
         'url': {
             'href': utils.full_url(tf.path),
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index cc81f0657754960d1389bfe36bb6c02787f4c5a3..10237ed9fd76d156656238edbce11495dc08934b 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -9,9 +9,18 @@ from funkwhale_api.federation import activity
 from funkwhale_api.federation import models
 from funkwhale_api.federation import serializers
 from funkwhale_api.federation import utils
+from funkwhale_api.federation import views
 from funkwhale_api.federation import webfinger
 
 
+@pytest.mark.parametrize('view,permissions', [
+    (views.LibraryViewSet, ['federation']),
+    (views.LibraryTrackViewSet, ['federation']),
+])
+def test_permissions(assert_user_permission, view, permissions):
+    assert_user_permission(view, permissions)
+
+
 @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
 def test_instance_actors(system_actor, db, api_client):
     actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
@@ -62,7 +71,10 @@ def test_wellknown_webfinger_system(
     actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
     url = reverse('federation:well-known-webfinger')
     response = api_client.get(
-        url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
+        url,
+        data={'resource': 'acct:{}'.format(actor.webfinger_subject)},
+        HTTP_ACCEPT='application/jrd+json',
+    )
     serializer = serializers.ActorWebfingerSerializer(actor)
 
     assert response.status_code == 200
@@ -83,7 +95,7 @@ def test_wellknown_nodeinfo(db, preferences, api_client, settings):
         ]
     }
     url = reverse('federation:well-known-nodeinfo')
-    response = api_client.get(url)
+    response = api_client.get(url, HTTP_ACCEPT='application/jrd+json')
     assert response.status_code == 200
     assert response['Content-Type'] == 'application/jrd+json'
     assert response.data == expected
diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py
index 4ca1c43a5835f94a6db20018a76e456c5e1b7e28..87b8882880752d14a5572ca7a324d399688974e7 100644
--- a/api/tests/instance/test_nodeinfo.py
+++ b/api/tests/instance/test_nodeinfo.py
@@ -36,6 +36,7 @@ def test_nodeinfo_dump(preferences, mocker):
             }
         },
         'metadata': {
+            'private': preferences['instance__nodeinfo_private'],
             'shortDescription': preferences['instance__short_description'],
             'longDescription': preferences['instance__long_description'],
             'nodeName': preferences['instance__name'],
@@ -92,6 +93,7 @@ def test_nodeinfo_dump_stats_disabled(preferences, mocker):
             }
         },
         'metadata': {
+            'private': preferences['instance__nodeinfo_private'],
             'shortDescription': preferences['instance__short_description'],
             'longDescription': preferences['instance__long_description'],
             'nodeName': preferences['instance__name'],
diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py
index 468c0ddae9de440b3edce7fd65fdc57c6ead8fff..daf54db51cb32181c380fca8719bc951c97404c6 100644
--- a/api/tests/instance/test_views.py
+++ b/api/tests/instance/test_views.py
@@ -1,5 +1,16 @@
+import pytest
+
 from django.urls import reverse
 
+from funkwhale_api.instance import views
+
+
+@pytest.mark.parametrize('view,permissions', [
+    (views.AdminSettings, ['settings']),
+])
+def test_permissions(assert_user_permission, view, permissions):
+    assert_user_permission(view, permissions)
+
 
 def test_nodeinfo_endpoint(db, api_client, mocker):
     payload = {
@@ -21,3 +32,32 @@ def test_nodeinfo_endpoint_disabled(db, api_client, preferences):
     response = api_client.get(url)
 
     assert response.status_code == 404
+
+
+def test_settings_only_list_public_settings(db, api_client, preferences):
+    url = reverse('api:v1:instance:settings')
+    response = api_client.get(url)
+
+    for conf in response.data:
+        p = preferences.model.objects.get(
+            section=conf['section'], name=conf['name'])
+        assert p.preference.show_in_api is True
+
+
+def test_admin_settings_restrict_access(db, logged_in_api_client, preferences):
+    url = reverse('api:v1:instance:admin-settings-list')
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 403
+
+
+def test_admin_settings_correct_permission(
+        db, logged_in_api_client, preferences):
+    user = logged_in_api_client.user
+    user.permission_settings = True
+    user.save()
+    url = reverse('api:v1:instance:admin-settings-list')
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert len(response.data) == len(preferences.all())
diff --git a/api/tests/music/sample.flac b/api/tests/music/sample.flac
new file mode 100644
index 0000000000000000000000000000000000000000..6eff1c06e43f6f536c2d810d0d059cc6af319f5e
Binary files /dev/null and b/api/tests/music/sample.flac differ
diff --git a/api/tests/music/test_commands.py b/api/tests/music/test_commands.py
new file mode 100644
index 0000000000000000000000000000000000000000..ff3343aa53fd13be34c99316c2da38c188571e93
--- /dev/null
+++ b/api/tests/music/test_commands.py
@@ -0,0 +1,45 @@
+from funkwhale_api.music.management.commands import fix_track_files
+
+
+def test_fix_track_files_bitrate_length(factories, mocker):
+    tf1 = factories['music.TrackFile'](bitrate=1, duration=2)
+    tf2 = factories['music.TrackFile'](bitrate=None, duration=None)
+    c = fix_track_files.Command()
+
+    mocker.patch(
+        'funkwhale_api.music.utils.get_audio_file_data',
+        return_value={'bitrate': 42, 'length': 43})
+
+    c.fix_file_data(dry_run=False)
+
+    tf1.refresh_from_db()
+    tf2.refresh_from_db()
+
+    # not updated
+    assert tf1.bitrate == 1
+    assert tf1.duration == 2
+
+    # updated
+    assert tf2.bitrate == 42
+    assert tf2.duration == 43
+
+
+def test_fix_track_files_size(factories, mocker):
+    tf1 = factories['music.TrackFile'](size=1)
+    tf2 = factories['music.TrackFile'](size=None)
+    c = fix_track_files.Command()
+
+    mocker.patch(
+        'funkwhale_api.music.models.TrackFile.get_file_size',
+        return_value=2)
+
+    c.fix_file_size(dry_run=False)
+
+    tf1.refresh_from_db()
+    tf2.refresh_from_db()
+
+    # not updated
+    assert tf1.size == 1
+
+    # updated
+    assert tf2.size == 2
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
index c7b40fb16e9ebc50e33060b88c0c82947e808451..8453dca8407ad551f70930a00d5e16494560ab38 100644
--- a/api/tests/music/test_import.py
+++ b/api/tests/music/test_import.py
@@ -1,4 +1,5 @@
 import json
+import os
 import pytest
 
 from django.urls import reverse
@@ -7,6 +8,8 @@ from funkwhale_api.federation import actors
 from funkwhale_api.federation import serializers as federation_serializers
 from funkwhale_api.music import tasks
 
+DATA_DIR = os.path.dirname(os.path.abspath(__file__))
+
 
 def test_create_import_can_bind_to_request(
         artists, albums, mocker, factories, superuser_api_client):
@@ -40,11 +43,20 @@ def test_create_import_can_bind_to_request(
     assert batch.import_request == request
 
 
-def test_import_job_from_federation_no_musicbrainz(factories):
+def test_import_job_from_federation_no_musicbrainz(factories, mocker):
+    mocker.patch(
+        'funkwhale_api.music.utils.get_audio_file_data',
+        return_value={'bitrate': 24, 'length': 666})
+    mocker.patch(
+        'funkwhale_api.music.models.TrackFile.get_file_size',
+        return_value=42)
     lt = factories['federation.LibraryTrack'](
         artist_name='Hello',
         album_title='World',
         title='Ping',
+        metadata__length=42,
+        metadata__bitrate=43,
+        metadata__size=44,
     )
     job = factories['music.ImportJob'](
         federation=True,
@@ -56,6 +68,9 @@ def test_import_job_from_federation_no_musicbrainz(factories):
 
     tf = job.track_file
     assert tf.mimetype == lt.audio_mimetype
+    assert tf.duration == 42
+    assert tf.bitrate == 43
+    assert tf.size == 44
     assert tf.library_track == job.library_track
     assert tf.track.title == 'Ping'
     assert tf.track.artist.name == 'Hello'
@@ -234,13 +249,13 @@ def test_import_batch_notifies_followers(
 
 
 def test__do_import_in_place_mbid(factories, tmpfile):
-    path = '/test.ogg'
+    path = os.path.join(DATA_DIR, 'test.ogg')
     job = factories['music.ImportJob'](
-        in_place=True, source='file:///test.ogg')
+        in_place=True, source='file://{}'.format(path))
 
     track = factories['music.Track'](mbid=job.mbid)
     tf = tasks._do_import(job, use_acoustid=False)
 
     assert bool(tf.audio_file) is False
-    assert tf.source == 'file:///test.ogg'
+    assert tf.source == 'file://{}'.format(path)
     assert tf.mimetype == 'audio/ogg'
diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py
index 342bc99b8115cf0646da9c9e61c10fe645738ec3..3f1ea9177d8e69f34294cabd9b42156e85644e86 100644
--- a/api/tests/music/test_metadata.py
+++ b/api/tests/music/test_metadata.py
@@ -40,3 +40,20 @@ def test_can_get_metadata_from_id3_mp3_file(field, value):
     data = metadata.Metadata(path)
 
     assert data.get(field) == value
+
+
+@pytest.mark.parametrize('field,value', [
+    ('title', '999,999'),
+    ('artist', 'Nine Inch Nails'),
+    ('album', 'The Slip'),
+    ('date', datetime.date(2008, 5, 5)),
+    ('track_number', 1),
+    ('musicbrainz_albumid', uuid.UUID('12b57d46-a192-499e-a91f-7da66790a1c1')),
+    ('musicbrainz_recordingid', uuid.UUID('30f3f33e-8d0c-4e69-8539-cbd701d18f28')),
+    ('musicbrainz_artistid', uuid.UUID('b7ffd2af-418f-4be2-bdd1-22f8b48613da')),
+])
+def test_can_get_metadata_from_flac_file(field, value):
+    path = os.path.join(DATA_DIR, 'sample.flac')
+    data = metadata.Metadata(path)
+
+    assert data.get(field) == value
diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py
index 9f52ba8874e50c10ea0216cacc8363ab767d6396..e926d07fa2be7c367b50e53acbc72666eca8b06b 100644
--- a/api/tests/music/test_models.py
+++ b/api/tests/music/test_models.py
@@ -77,3 +77,36 @@ def test_audio_track_mime_type(extention, mimetype, factories):
     tf = factories['music.TrackFile'](audio_file__from_path=path)
 
     assert tf.mimetype == mimetype
+
+
+def test_track_file_file_name(factories):
+    name = 'test.mp3'
+    path = os.path.join(DATA_DIR, name)
+    tf = factories['music.TrackFile'](audio_file__from_path=path)
+
+    assert tf.filename == tf.track.full_name + '.mp3'
+
+
+def test_track_get_file_size(factories):
+    name = 'test.mp3'
+    path = os.path.join(DATA_DIR, name)
+    tf = factories['music.TrackFile'](audio_file__from_path=path)
+
+    assert tf.get_file_size() == 297745
+
+
+def test_track_get_file_size_federation(factories):
+    tf = factories['music.TrackFile'](
+        federation=True,
+        library_track__with_audio_file=True)
+
+    assert tf.get_file_size() == tf.library_track.audio_file.size
+
+
+def test_track_get_file_size_in_place(factories):
+    name = 'test.mp3'
+    path = os.path.join(DATA_DIR, name)
+    tf = factories['music.TrackFile'](
+        in_place=True, source='file://{}'.format(path))
+
+    assert tf.get_file_size() == 297745
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index ddbc4ba9a2c7407bd067dc2799f499654cbb004c..c5839432bf128a3a03387bb2894d8ad56615594b 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -62,6 +62,9 @@ def test_import_job_can_run_with_file_and_acoustid(
             'score': 0.860825}],
         'status': 'ok'
     }
+    mocker.patch(
+        'funkwhale_api.music.utils.get_audio_file_data',
+        return_value={'bitrate': 42, 'length': 43})
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
         return_value=artists['get']['adhesive_wombat'])
@@ -82,7 +85,9 @@ def test_import_job_can_run_with_file_and_acoustid(
 
     with open(path, 'rb') as f:
         assert track_file.audio_file.read() == f.read()
-    assert track_file.duration == 268
+    assert track_file.bitrate == 42
+    assert track_file.duration == 43
+    assert track_file.size == os.path.getsize(path)
     # audio file is deleted from import job once persisted to audio file
     assert not job.audio_file
     assert job.status == 'finished'
diff --git a/api/tests/music/test_utils.py b/api/tests/music/test_utils.py
index 0a4f4b99424c34557fb846866b6e1b2958244a88..12b381a997c59ea85ebf1239024b1d3f288e2cdf 100644
--- a/api/tests/music/test_utils.py
+++ b/api/tests/music/test_utils.py
@@ -1,5 +1,10 @@
+import os
+import pytest
+
 from funkwhale_api.music import utils
 
+DATA_DIR = os.path.dirname(os.path.abspath(__file__))
+
 
 def test_guess_mimetype_try_using_extension(factories, mocker):
     mocker.patch(
@@ -17,3 +22,16 @@ def test_guess_mimetype_try_using_extension_if_fail(factories, mocker):
         audio_file__filename='test.mp3')
 
     assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg'
+
+
+@pytest.mark.parametrize('name, expected', [
+    ('sample.flac', {'bitrate': 1608000, 'length': 0.001}),
+    ('test.mp3', {'bitrate': 8000, 'length': 267.70285714285717}),
+    ('test.ogg', {'bitrate': 128000, 'length': 229.18304166666667}),
+])
+def test_get_audio_file_data(name, expected):
+    path = os.path.join(DATA_DIR, name)
+    with open(path, 'rb') as f:
+        result = utils.get_audio_file_data(f)
+
+    assert result == expected
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index b22ab7fd504fe1afd54b713cec55d9912e9890d8..030fc3a73eeeabaf0507d5bdbc9949e3d5c4bff5 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -8,6 +8,14 @@ from funkwhale_api.music import views
 from funkwhale_api.federation import actors
 
 
+@pytest.mark.parametrize('view,permissions', [
+    (views.ImportBatchViewSet, ['library']),
+    (views.ImportJobViewSet, ['library']),
+])
+def test_permissions(assert_user_permission, view, permissions):
+    assert_user_permission(view, permissions)
+
+
 @pytest.mark.parametrize('param,expected', [
     ('true', 'full'),
     ('false', 'empty'),
@@ -104,6 +112,24 @@ def test_serve_file_in_place(
     assert response[headers[proxy]] == expected
 
 
+@pytest.mark.parametrize('proxy,serve_path,expected', [
+    ('apache2', '/host/music', '/host/music/hello/worldéà.mp3'),
+    ('apache2', '/app/music', '/app/music/hello/worldéà.mp3'),
+    ('nginx', '/host/music', '/_protected/music/hello/worldéà.mp3'),
+    ('nginx', '/app/music', '/_protected/music/hello/worldéà.mp3'),
+])
+def test_serve_file_in_place_utf8(
+        proxy, serve_path, expected, factories, api_client, settings):
+    settings.PROTECT_AUDIO_FILES = False
+    settings.PROTECT_FILE_PATH = '/_protected/music'
+    settings.REVERSE_PROXY_TYPE = proxy
+    settings.MUSIC_DIRECTORY_PATH = '/app/music'
+    settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
+    path = views.get_file_path('/app/music/hello/worldéà.mp3')
+
+    assert path == expected.encode('utf-8')
+
+
 @pytest.mark.parametrize('proxy,serve_path,expected', [
     ('apache2', '/host/music', '/host/media/tracks/hello/world.mp3'),
     # apache with container not supported yet
diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py
index 6da9dd12e2e273643cf78ec0410535e77891fdad..ad9f739a1dd63ae2036142b2bdab149dc9e63635 100644
--- a/api/tests/subsonic/test_serializers.py
+++ b/api/tests/subsonic/test_serializers.py
@@ -77,7 +77,8 @@ 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)
+    tf = factories['music.TrackFile'](
+        track=track, bitrate=42000, duration=43, size=44)
 
     expected = {
         'id': album.pk,
@@ -98,7 +99,9 @@ def test_get_album_serializer(factories):
                 'year': track.album.release_date.year,
                 'contentType': tf.mimetype,
                 'suffix': tf.extension or '',
-                'duration': tf.duration or 0,
+                'bitrate': 42,
+                'duration': 43,
+                'size': 44,
                 'created': track.creation_date,
                 'albumId': album.pk,
                 'artistId': artist.pk,
@@ -177,7 +180,8 @@ def test_playlist_detail_serializer(factories):
 
 def test_directory_serializer_artist(factories):
     track = factories['music.Track']()
-    tf = factories['music.TrackFile'](track=track)
+    tf = factories['music.TrackFile'](
+        track=track, bitrate=42000, duration=43, size=44)
     album = track.album
     artist = track.artist
 
@@ -195,7 +199,9 @@ def test_directory_serializer_artist(factories):
             'year': track.album.release_date.year,
             'contentType': tf.mimetype,
             'suffix': tf.extension or '',
-            'duration': tf.duration or 0,
+            'bitrate': 42,
+            'duration': 43,
+            'size': 44,
             'created': track.creation_date,
             'albumId': album.pk,
             'artistId': artist.pk,
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index c7cd12e9e3ba19a457fa0d66d68e6b3679925c84..49199e0a781b990268431f109ac0c8804f8391ea 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -1,3 +1,7 @@
+import pytest
+
+from funkwhale_api.users import models
+
 
 def test__str__(factories):
     user = factories['users.User'](username='hello')
@@ -16,3 +20,33 @@ def test_changing_password_updates_subsonic_api_token(factories):
 
     assert user.subsonic_api_token is not None
     assert user.subsonic_api_token != 'test'
+
+
+def test_get_permissions_superuser(factories):
+    user = factories['users.User'](is_superuser=True)
+
+    perms = user.get_permissions()
+    for p in models.PERMISSIONS:
+        assert perms[p] is True
+
+
+def test_get_permissions_regular(factories):
+    user = factories['users.User'](permission_library=True)
+
+    perms = user.get_permissions()
+    for p in models.PERMISSIONS:
+        if p == 'library':
+            assert perms[p] is True
+        else:
+            assert perms[p] is False
+
+
+@pytest.mark.parametrize('args,perms,expected', [
+    ({'is_superuser': True}, ['federation', 'library'], True),
+    ({'is_superuser': False}, ['federation'], False),
+    ({'permission_library': True}, ['library'], True),
+    ({'permission_library': True}, ['library', 'federation'], False),
+])
+def test_has_permissions(args, perms, expected, factories):
+    user = factories['users.User'](**args)
+    assert user.has_permissions(*perms) is expected
diff --git a/api/tests/users/test_permissions.py b/api/tests/users/test_permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..1564c761db59239eee5169bffd6ee92c2c148301
--- /dev/null
+++ b/api/tests/users/test_permissions.py
@@ -0,0 +1,56 @@
+import pytest
+from rest_framework.views import APIView
+
+from funkwhale_api.users import permissions
+
+
+def test_has_user_permission_no_user(api_request):
+    view = APIView.as_view()
+    permission = permissions.HasUserPermission()
+    request = api_request.get('/')
+    assert permission.has_permission(request, view) is False
+
+
+def test_has_user_permission_anonymous(anonymous_user, api_request):
+    view = APIView.as_view()
+    permission = permissions.HasUserPermission()
+    request = api_request.get('/')
+    setattr(request, 'user', anonymous_user)
+    assert permission.has_permission(request, view) is False
+
+
+@pytest.mark.parametrize('value', [True, False])
+def test_has_user_permission_logged_in_single(value, factories, api_request):
+    user = factories['users.User'](permission_federation=value)
+
+    class View(APIView):
+        required_permissions = ['federation']
+    view = View()
+    permission = permissions.HasUserPermission()
+    request = api_request.get('/')
+    setattr(request, 'user', user)
+    result = permission.has_permission(request, view)
+    assert result == user.has_permissions('federation') == value
+
+
+@pytest.mark.parametrize('federation,library,expected', [
+    (True, False, False),
+    (False, True, False),
+    (False, False, False),
+    (True, True, True),
+])
+def test_has_user_permission_logged_in_single(
+        federation, library, expected, factories, api_request):
+    user = factories['users.User'](
+        permission_federation=federation,
+        permission_library=library,
+    )
+
+    class View(APIView):
+        required_permissions = ['federation', 'library']
+    view = View()
+    permission = permissions.HasUserPermission()
+    request = api_request.get('/')
+    setattr(request, 'user', user)
+    result = permission.has_permission(request, view)
+    assert result == user.has_permissions('federation', 'library') == expected
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index fffc762fde7cb6f6533f3781bc73e828fa8e5d56..1bbf8b9a2d065735a017802bba369e817d016992 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -53,33 +53,24 @@ def test_can_disable_registration_view(preferences, client, db):
     assert response.status_code == 403
 
 
-def test_can_fetch_data_from_api(client, factories):
+def test_can_fetch_data_from_api(api_client, factories):
     url = reverse('api:v1:users:users-me')
-    response = client.get(url)
+    response = api_client.get(url)
     # login required
     assert response.status_code == 401
 
     user = factories['users.User'](
-        is_staff=True,
-        perms=[
-            'music.add_importbatch',
-            'dynamic_preferences.change_globalpreferencemodel',
-        ]
+        permission_library=True
     )
-    assert user.has_perm('music.add_importbatch')
-    client.login(username=user.username, password='test')
-    response = client.get(url)
+    api_client.login(username=user.username, password='test')
+    response = api_client.get(url)
     assert response.status_code == 200
-
-    payload = json.loads(response.content.decode('utf-8'))
-
-    assert payload['username'] == user.username
-    assert payload['is_staff'] == user.is_staff
-    assert payload['is_superuser'] == user.is_superuser
-    assert payload['email'] == user.email
-    assert payload['name'] == user.name
-    assert payload['permissions']['import.launch']['status']
-    assert payload['permissions']['settings.change']['status']
+    assert response.data['username'] == user.username
+    assert response.data['is_staff'] == user.is_staff
+    assert response.data['is_superuser'] == user.is_superuser
+    assert response.data['email'] == user.email
+    assert response.data['name'] == user.name
+    assert response.data['permissions'] == user.get_permissions()
 
 
 def test_can_get_token_via_api(client, factories):
@@ -202,6 +193,8 @@ def test_user_can_get_new_subsonic_token(logged_in_api_client):
     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'
diff --git a/docs/configuration.rst b/docs/configuration.rst
index bbc658e087977e5eaa7c3f53598fdad99d68b4aa..46756bb266ccf918314f57b8ef8b01dcb68409ce 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -27,15 +27,24 @@ Those settings are stored in database and do not require a restart of your
 instance after modification. They typically relate to higher level configuration,
 such your instance description, signup policy and so on.
 
-There is no polished interface for those settings, yet, but you can view update
-them using the administration interface provided by Django (the framework funkwhale is built on).
-
-The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course).
+You can edit those settings directly from the web application, assuming
+you have the required permissions. The URL is ``/manage/settings``, and
+you will also find a link to this page in the sidebar.
 
 If you plan to use acoustid and external imports
 (e.g. with the youtube backends), you should edit the corresponding
 settings in this interface.
 
+.. note::
+
+    If you have any issue with the web application, a management interface is also
+    available for those settings from Django's administration interface. It's
+    less user friendly, though, and we recommend you use the web app interface
+    whenever possible.
+
+    The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course).
+
+
 Configuration reference
 -----------------------
 
@@ -108,3 +117,28 @@ Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be
 On non-docker setup, you don't need to configure this setting.
 
 .. note:: This path should not include any trailing slash
+
+User permissions
+----------------
+
+Funkwhale's permission model works as follows:
+
+- Anonymous users cannot do anything unless configured specifically
+- Logged-in users can use the application, but cannot do things that affect
+  the whole instance
+- Superusers can do anything
+
+To make things more granular and allow some delegation of responsability,
+superusers can grant specific permissions to specific users. Available
+permissions are:
+
+- **Manage instance-level settings**: users with this permission can edit instance
+  settings as described in :ref:`instance-settings`
+- **Manage library**: users with this permission can import new music in the
+  instance
+- **Manage library federation**: users with this permission can ask to federate with
+  other instances, and accept/deny federation requests from other intances
+
+There is no dedicated interface to manage users permissions, but superusers
+can login on the Django's admin at ``/api/admin/`` and grant permissions
+to users at ``/api/admin/users/user/``.
diff --git a/front/src/audio/formats.js b/front/src/audio/formats.js
index f6e2157a15d6c64afff10addd30ceba724dcdc65..d8a5a412546433ce73d5641c0aee075a480aba75 100644
--- a/front/src/audio/formats.js
+++ b/front/src/audio/formats.js
@@ -5,6 +5,7 @@ export default {
   ],
   formatsMap: {
     'audio/ogg': 'ogg',
-    'audio/mpeg': 'mp3'
+    'audio/mpeg': 'mp3',
+    'audio/x-flac': 'flac'
   }
 }
diff --git a/front/src/components/About.vue b/front/src/components/About.vue
index 524191250cf920fdbe4991381f827c1e50dd5ecd..59c8411ac131fe65ec72fa6ed1e4769eed5b361a 100644
--- a/front/src/components/About.vue
+++ b/front/src/components/About.vue
@@ -13,6 +13,12 @@
       <p v-if="!instance.short_description.value && !instance.long_description.value">
         {{ $t('Unfortunately, owners of this instance did not yet take the time to complete this page.') }}
       </p>
+      <router-link
+        class="ui button"
+        v-if="$store.state.auth.availablePermissions['settings']"
+        :to="{path: '/manage/settings', hash: 'instance'}">
+        <i class="pencil icon"></i>{{ $t('Edit instance info') }}
+      </router-link>
       <div
         v-if="instance.short_description.value"
         class="ui middle aligned stackable text container">
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 97c743bbe804a5ca64bfe39ce69ad5b725745365..9f3134c2a1cd38a64a1761c505eac57fa6970666 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -60,7 +60,7 @@
           <div class="menu">
             <router-link
               class="item"
-              v-if="$store.state.auth.availablePermissions['import.launch']"
+              v-if="$store.state.auth.availablePermissions['library']"
               :to="{name: 'library.requests', query: {status: 'pending' }}">
               <i class="download icon"></i>{{ $t('Import requests') }}
               <div
@@ -70,7 +70,7 @@
             </router-link>
             <router-link
               class="item"
-              v-if="$store.state.auth.availablePermissions['federation.manage']"
+              v-if="$store.state.auth.availablePermissions['federation']"
               :to="{path: '/manage/federation/libraries'}">
               <i class="sitemap icon"></i>{{ $t('Federation') }}
               <div
@@ -78,6 +78,12 @@
                 :title="$t('Pending follow requests')">
                 {{ notifications.federation }}</div>
             </router-link>
+            <router-link
+              class="item"
+              v-if="$store.state.auth.availablePermissions['settings']"
+              :to="{path: '/manage/settings'}">
+              <i class="settings icon"></i>{{ $t('Settings') }}
+            </router-link>
           </div>
         </div>
       </div>
@@ -186,8 +192,8 @@ export default {
     }),
     showAdmin () {
       let adminPermissions = [
-        this.$store.state.auth.availablePermissions['federation.manage'],
-        this.$store.state.auth.availablePermissions['import.launch']
+        this.$store.state.auth.availablePermissions['federation'],
+        this.$store.state.auth.availablePermissions['library']
       ]
       return adminPermissions.filter(e => {
         return e
@@ -203,7 +209,7 @@ export default {
       this.fetchFederationImportRequestsCount()
     },
     fetchFederationNotificationsCount () {
-      if (!this.$store.state.auth.availablePermissions['federation.manage']) {
+      if (!this.$store.state.auth.availablePermissions['federation']) {
         return
       }
       let self = this
@@ -212,12 +218,11 @@ export default {
       })
     },
     fetchFederationImportRequestsCount () {
-      if (!this.$store.state.auth.availablePermissions['import.launch']) {
+      if (!this.$store.state.auth.availablePermissions['library']) {
         return
       }
       let self = this
       axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
-        console.log('YOLo')
         self.notifications.importRequests = response.data.count
       })
     },
@@ -256,7 +261,6 @@ export default {
     },
     '$store.state.availablePermissions': {
       handler () {
-        console.log('YOLO')
         this.fetchNotificationsCount()
       },
       deep: true
diff --git a/front/src/components/admin/SettingsGroup.vue b/front/src/components/admin/SettingsGroup.vue
new file mode 100644
index 0000000000000000000000000000000000000000..255f04488973fbc4eb5c1ecc5e97418294c258ff
--- /dev/null
+++ b/front/src/components/admin/SettingsGroup.vue
@@ -0,0 +1,120 @@
+<template>
+  <form :id="group.id" class="ui form" @submit.prevent="save">
+    <div class="ui divider" />
+    <h3 class="ui header">{{ group.label }}</h3>
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header">{{ $t('Error while saving settings') }}</div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div v-if="result" class="ui positive message">
+      {{ $t('Settings updated successfully.') }}
+    </div>
+    <p v-if="group.help">{{ group.help }}</p>
+    <div v-for="setting in settings" class="ui field">
+      <template v-if="setting.field.widget.class !== 'CheckboxInput'">
+        <label :for="setting.identifier">{{ setting.verbose_name }}</label>
+        <p v-if="setting.help_text">{{ setting.help_text }}</p>
+      </template>
+      <input
+        :id="setting.identifier"
+        v-if="setting.field.widget.class === 'PasswordInput'"
+        type="password"
+        class="ui input"
+        v-model="values[setting.identifier]" />
+      <input
+        :id="setting.identifier"
+        v-if="setting.field.widget.class === 'TextInput'"
+        type="text"
+        class="ui input"
+        v-model="values[setting.identifier]" />
+      <input
+        :id="setting.identifier"
+        v-if="setting.field.class === 'IntegerField'"
+        type="number"
+        class="ui input"
+        v-model.number="values[setting.identifier]" />
+      <textarea
+        :id="setting.identifier"
+        v-else-if="setting.field.widget.class === 'Textarea'"
+        type="text"
+        class="ui input"
+        v-model="values[setting.identifier]" />
+      <div v-else-if="setting.field.widget.class === 'CheckboxInput'" class="ui toggle checkbox">
+        <input
+          :id="setting.identifier"
+          :name="setting.identifier"
+          v-model="values[setting.identifier]"
+          type="checkbox" />
+        <label :for="setting.identifier">{{ setting.verbose_name }}</label>
+        <p v-if="setting.help_text">{{ setting.help_text }}</p>
+      </div>
+    </div>
+    <button
+      type="submit"
+      :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']">
+        {{ $t('Save') }}
+    </button>
+  </form>
+</template>
+
+<script>
+import axios from 'axios'
+
+export default {
+  props: {
+    group: {type: Object, required: true},
+    settingsData: {type: Array, required: true}
+  },
+  data () {
+    return {
+      values: {},
+      result: null,
+      errors: [],
+      isLoading: false
+    }
+  },
+  created () {
+    let self = this
+    this.settings.forEach(e => {
+      self.values[e.identifier] = e.value
+    })
+  },
+  methods: {
+    save () {
+      let self = this
+      this.isLoading = true
+      self.errors = []
+      self.result = null
+      axios.post('instance/admin/settings/bulk/', self.values).then((response) => {
+        self.result = true
+        self.isLoading = false
+        self.$store.dispatch('instance/fetchSettings')
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  },
+  computed: {
+    settings () {
+      let byIdentifier = {}
+      this.settingsData.forEach(e => {
+        byIdentifier[e.identifier] = e
+      })
+      return this.group.settings.map(e => {
+        return byIdentifier[e]
+      })
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+
+.ui.checkbox p {
+  margin-top: 1rem;
+}
+</style>
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index 2662f30b33a5321a1e2d0121c3930c46b5f66097..efa59a29d5c65fe30b88aedc5f453b8e9a250a61 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -1,8 +1,9 @@
 <template>
-  <div :class="['ui', {'tiny': discrete}, 'buttons']">
+  <div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']">
     <button
       :title="$t('Add to current queue')"
       @click="addNext(true)"
+      :disabled="!playable"
       :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
       <i class="ui play icon"></i>
       <template v-if="!discrete"><slot><i18next path="Play"/></slot></template>
@@ -10,9 +11,9 @@
     <div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
       <i class="dropdown icon"></i>
       <div class="menu">
-        <div class="item"@click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div>
-        <div class="item"@click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div>
-        <div class="item"@click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div>
+        <div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div>
+        <div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div>
+        <div class="item" :disabled="!playable" @click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div>
       </div>
     </div>
   </div>
@@ -45,9 +46,18 @@ export default {
     jQuery(this.$el).find('.ui.dropdown').dropdown()
   },
   computed: {
+    title () {
+      if (this.playable) {
+        return this.$t('Play immediatly')
+      } else {
+        if (this.track) {
+          return this.$t('This track is not imported and cannot be played')
+        }
+      }
+    },
     playable () {
       if (this.track) {
-        return true
+        return this.track.files.length > 0
       } else if (this.tracks) {
         return this.tracks.length > 0
       } else if (this.playlist) {
diff --git a/front/src/components/federation/LibraryFollowTable.vue b/front/src/components/federation/LibraryFollowTable.vue
index fd16d83710cc956def2a8ce46a1b0e7d4b3506c9..4a7fe59f06c483d940a80d0cd57d89f8916b794f 100644
--- a/front/src/components/federation/LibraryFollowTable.vue
+++ b/front/src/components/federation/LibraryFollowTable.vue
@@ -55,7 +55,7 @@
               <p slot="modal-confirm"><i18next path="Deny"/></p>
             </dangerous-button>
             <dangerous-button v-if="follow.approved !== true" class="tiny basic labeled icon" color='green' @confirm="updateFollow(follow, true)">
-              <i class="x icon"></i> <i18next path="Approve"/>
+              <i class="check icon"></i> <i18next path="Approve"/>
               <p slot="modal-header"><i18next path="Approve access?"/></p>
               <p slot="modal-content">
                 <i18next path="By confirming, {%0%}@{%1%} will be granted access to your library.">
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index 820d60a5438545f1af49bd9bcf597b8c227534b9..e360ccb1c5c80985e5adc33f203ff316c610f504 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -13,10 +13,10 @@
           exact>
           <i18next path="Requests"/>
         </router-link>
-        <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>
+        <router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/launch" exact>
           <i18next path="Import"/>
         </router-link>
-        <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">
+        <router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/batches">
           <i18next path="Import batches"/>
         </router-link>
       </div>
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index 940086e02aa9263bcab6924a75c161aacb5aa5dd..155a1245a58c413169059a5db988b88d2c8e7b6e 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -44,6 +44,46 @@
           </a>
         </div>
       </div>
+      <div v-if="file" class="ui vertical stripe center aligned segment">
+        <h2 class="ui header">{{ $t('Track information') }}</h2>
+        <table class="ui very basic collapsing celled center aligned table">
+          <tbody>
+            <tr>
+              <td>
+                {{ $t('Duration') }}
+              </td>
+              <td v-if="file.duration">
+                {{ time.parse(file.duration) }}
+              </td>
+              <td v-else>
+                {{ $t('N/A') }}
+              </td>
+            </tr>
+            <tr>
+              <td>
+                {{ $t('Size') }}
+              </td>
+              <td v-if="file.size">
+                {{ file.size | humanSize }}
+              </td>
+              <td v-else>
+                {{ $t('N/A') }}
+              </td>
+            </tr>
+            <tr>
+              <td>
+                {{ $t('Bitrate') }}
+              </td>
+              <td v-if="file.bitrate">
+                {{ file.bitrate | humanSize }}/s
+              </td>
+              <td v-else>
+                {{ $t('N/A') }}
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
       <div class="ui vertical stripe center aligned segment">
         <h2><i18next path="Lyrics"/></h2>
         <div v-if="isLoadingLyrics" class="ui vertical segment">
@@ -64,6 +104,8 @@
 </template>
 
 <script>
+
+import time from '@/utils/time'
 import axios from 'axios'
 import url from '@/utils/url'
 import logger from '@/logging'
@@ -83,6 +125,7 @@ export default {
   },
   data () {
     return {
+      time,
       isLoadingTrack: true,
       isLoadingLyrics: true,
       track: null,
@@ -134,6 +177,9 @@ export default {
         return u
       }
     },
+    file () {
+      return this.track.files[0]
+    },
     lyricsSearchUrl () {
       let base = 'http://lyrics.wikia.com/wiki/Special:Search?query='
       let query = this.track.artist.name + ' ' + this.track.title
@@ -159,5 +205,8 @@ export default {
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style scoped lang="scss">
-
+.table.center.aligned {
+  margin-left: auto;
+  margin-right: auto;
+}
 </style>
diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue
index 404948dc0793cfce4a9515bfde6b76c95afa17a9..da25241ce55dd697f2039797ce8e7d372e5b5ab3 100644
--- a/front/src/components/playlists/PlaylistModal.vue
+++ b/front/src/components/playlists/PlaylistModal.vue
@@ -7,9 +7,7 @@
       <div class="description">
         <template v-if="track">
           <h4 class="ui header">{{ $t('Current track') }}</h4>
-          <div>
-            {{ $t('"{%title%}" by {%artist%}', { title: track.title, artist: track.artist.name }) }}
-          </div>
+          <div v-html='trackDisplay'></div>
           <div class="ui divider"></div>
         </template>
 
@@ -112,6 +110,12 @@ export default {
       let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }])
       p.reverse()
       return p
+    },
+    trackDisplay () {
+      return this.$t('"{%title%}" by {%artist%}', {
+        title: this.track.title,
+        artist: this.track.artist.name }
+      )
     }
   },
   watch: {
diff --git a/front/src/components/requests/Card.vue b/front/src/components/requests/Card.vue
index 9d0bf0b771bdaa398c882e84be4f07afb642c6ce..7743bb6d4403653fedbc355983ec0bb4e7adecf1 100644
--- a/front/src/components/requests/Card.vue
+++ b/front/src/components/requests/Card.vue
@@ -22,7 +22,7 @@
       </span>
       <button
         @click="createImport"
-        v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']"
+        v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['library']"
         class="ui mini basic green right floated button">{{ $t('Create import') }}</button>
 
     </div>
diff --git a/front/src/filters.js b/front/src/filters.js
index afc393d402e8f80e046d2bd4443e3f03da2f07a7..11751559961c393b9d5bb3369aba199f8badf34a 100644
--- a/front/src/filters.js
+++ b/front/src/filters.js
@@ -47,4 +47,23 @@ export function capitalize (str) {
 
 Vue.filter('capitalize', capitalize)
 
+export function humanSize (bytes) {
+  let si = true
+  var thresh = si ? 1000 : 1024
+  if (Math.abs(bytes) < thresh) {
+    return bytes + ' B'
+  }
+  var units = si
+    ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+    : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
+  var u = -1
+  do {
+    bytes /= thresh
+    ++u
+  } while (Math.abs(bytes) >= thresh && u < units.length - 1)
+  return bytes.toFixed(1) + ' ' + units[u]
+}
+
+Vue.filter('humanSize', humanSize)
+
 export default {}
diff --git a/front/src/main.js b/front/src/main.js
index 5481615f2006025cea7e009dc9ddd8a49b97623f..2e92fbbd2243e20f567f47dcbcc5e5f3c7937659 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -35,8 +35,26 @@ Vue.use(VueMasonryPlugin)
 Vue.use(VueLazyload)
 Vue.config.productionTip = false
 Vue.directive('title', {
-  inserted: (el, binding) => { document.title = binding.value + ' - Funkwhale' },
-  updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' }
+  inserted: (el, binding) => {
+    let parts = []
+    let instanceName = store.state.instance.settings.instance.name.value
+    if (instanceName.length === 0) {
+      instanceName = 'Funkwhale'
+    }
+    parts.unshift(instanceName)
+    parts.unshift(binding.value)
+    document.title = parts.join(' - ')
+  },
+  updated: (el, binding) => {
+    let parts = []
+    let instanceName = store.state.instance.settings.instance.name.value
+    if (instanceName.length === 0) {
+      instanceName = 'Funkwhale'
+    }
+    parts.unshift(instanceName)
+    parts.unshift(binding.value)
+    document.title = parts.join(' - ')
+  }
 })
 
 axios.defaults.baseURL = config.API_URL
diff --git a/front/src/router/index.js b/front/src/router/index.js
index b1e208023335945f42c7b255aa05ebaffeac5e2b..f71dab7f92decf05f5df0ca2129207bf7830fb48 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -28,6 +28,7 @@ import RequestsList from '@/components/requests/RequestsList'
 import PlaylistDetail from '@/views/playlists/Detail'
 import PlaylistList from '@/views/playlists/List'
 import Favorites from '@/components/favorites/List'
+import AdminSettings from '@/views/admin/Settings'
 import FederationBase from '@/views/federation/Base'
 import FederationScan from '@/views/federation/Scan'
 import FederationLibraryDetail from '@/views/federation/LibraryDetail'
@@ -117,6 +118,11 @@ export default new Router({
         defaultPaginateBy: route.query.paginateBy
       })
     },
+    {
+      path: '/manage/settings',
+      name: 'manage.settings',
+      component: AdminSettings
+    },
     {
       path: '/manage/federation',
       component: FederationBase,
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index 68a15090b5c289d2825563743f4cad7f2d3cdbf0..87af081d26fdfd09d2d02b16db6196f91d6cee95 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -112,7 +112,7 @@ export default {
         dispatch('playlists/fetchOwn', null, {root: true})
         Object.keys(data.permissions).forEach(function (key) {
           // this makes it easier to check for permissions in templates
-          commit('permission', {key, status: data.permissions[String(key)].status})
+          commit('permission', {key, status: data.permissions[String(key)]})
         })
         return response.data
       }, (response) => {
diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7174ab516c1e438d0aa09b0491e803b26f5f4316
--- /dev/null
+++ b/front/src/views/admin/Settings.vue
@@ -0,0 +1,155 @@
+<template>
+  <div class="main pusher"  v-title="$t('Instance settings')">
+    <div class="ui vertical stripe segment">
+      <div class="ui text container">
+        <div :class="['ui', {'loading': isLoading}, 'form']"></div>
+        <div id="settings-grid" v-if="settingsData" class="ui grid">
+          <div class="twelve wide stretched column">
+            <settings-group
+              :settings-data="settingsData"
+              :group="group"
+              :key="group.title"
+              v-for="group in groups" />
+          </div>
+          <div class="four wide column">
+            <div class="ui sticky vertical secondary menu">
+              <div class="header item">{{ $t('Sections') }}</div>
+              <a :class="['menu', {active: group.id === current}, 'item']"
+                @click.prevent="scrollTo(group.id)"
+                :href="'#' + group.id"
+                v-for="group in groups">{{ group.label }}</a>
+            </div>
+          </div>
+        </div>
+
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import $ from 'jquery'
+
+import SettingsGroup from '@/components/admin/SettingsGroup'
+
+export default {
+  components: {
+    SettingsGroup
+  },
+  data () {
+    return {
+      isLoading: false,
+      settingsData: null,
+      current: null
+    }
+  },
+  created () {
+    let self = this
+    this.fetchSettings().then(r => {
+      self.$nextTick(() => {
+        if (self.$store.state.route.hash) {
+          self.scrollTo(self.$store.state.route.hash.substr(1))
+        }
+      })
+    })
+  },
+  methods: {
+    scrollTo (id) {
+      console.log(id, 'hello')
+      this.current = id
+      document.getElementById(id).scrollIntoView()
+    },
+    fetchSettings () {
+      let self = this
+      self.isLoading = true
+      return axios.get('instance/admin/settings/').then((response) => {
+        self.settingsData = response.data
+        self.isLoading = false
+      })
+    }
+  },
+  computed: {
+    groups () {
+      return [
+        {
+          label: this.$t('Instance information'),
+          id: 'instance',
+          settings: [
+            'instance__name',
+            'instance__short_description',
+            'instance__long_description'
+          ]
+        },
+        {
+          label: this.$t('Users'),
+          id: 'users',
+          settings: [
+            'users__registration_enabled',
+            'common__api_authentication_required'
+          ]
+        },
+        {
+          label: this.$t('Imports'),
+          id: 'imports',
+          settings: [
+            'providers_youtube__api_key',
+            'providers_acoustid__api_key'
+          ]
+        },
+        {
+          label: this.$t('Playlists'),
+          id: 'playlists',
+          settings: [
+            'playlists__max_tracks'
+          ]
+        },
+        {
+          label: this.$t('Federation'),
+          id: 'federation',
+          settings: [
+            'federation__enabled',
+            'federation__music_needs_approval',
+            'federation__collection_page_size',
+            'federation__music_cache_duration',
+            'federation__actor_fetch_delay'
+          ]
+        },
+        {
+          label: this.$t('Subsonic'),
+          id: 'subsonic',
+          settings: [
+            'subsonic__enabled'
+          ]
+        },
+        {
+          label: this.$t('Statistics'),
+          id: 'statistics',
+          settings: [
+            'instance__nodeinfo_enabled',
+            'instance__nodeinfo_stats_enabled',
+            'instance__nodeinfo_private'
+          ]
+        },
+        {
+          label: this.$t('Error reporting'),
+          id: 'reporting',
+          settings: [
+            'raven__front_enabled',
+            'raven__front_dsn'
+
+          ]
+        }
+      ]
+    }
+  },
+  watch: {
+    settingsData () {
+      let self = this
+      this.$nextTick(() => {
+        $(self.$el).find('.sticky').sticky({context: '#settings-grid'})
+      })
+    }
+  }
+}
+</script>
diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue
index 32ee5aafaa9d28e34d1c824f479c6b0efea0447f..5001fb14db9db32bd129e0fdcefa3a04f60a848b 100644
--- a/front/src/views/playlists/List.vue
+++ b/front/src/views/playlists/List.vue
@@ -76,7 +76,6 @@ export default {
     Pagination
   },
   data () {
-    console.log('YOLO', this.$t)
     let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
     return {
       isLoading: true,
diff --git a/front/test/unit/specs/store/auth.spec.js b/front/test/unit/specs/store/auth.spec.js
index 3d175e9f9fa0e31b3b51b79314fe182e0516549c..46901cd16edd25ee9be4f7137a8835f1336174fb 100644
--- a/front/test/unit/specs/store/auth.spec.js
+++ b/front/test/unit/specs/store/auth.spec.js
@@ -164,9 +164,7 @@ describe('store/auth', () => {
       const profile = {
         username: 'bob',
         permissions: {
-          admin: {
-            status: true
-          }
+          admin: true
         }
       }
       moxios.stubRequest('users/users/me/', {