diff --git a/.gitignore b/.gitignore
index 25b088739964de23eb6ab5916132062e65d931fb..2582cc534fa5fb6be2667c879fd6a0342168432d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,3 +90,4 @@ data/
 
 po/*.po
 docs/swagger
+_build
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 5dfbf0642691e4033bac3f82bdc6fcdeff49f878..684e3233a4c860470d21521299e4b40952bb84dc 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -7,11 +7,93 @@ variables:
 
 
 stages:
+  - review
   - lint
   - test
   - build
   - deploy
 
+review_front:
+  stage: review
+  image: node:9
+  when: manual
+  allow_failure: true
+  before_script:
+    - cd front
+  script:
+    - yarn install
+    # this is to ensure we don't have any errors in the output,
+    # cf https://code.eliotberriot.com/funkwhale/funkwhale/issues/169
+    - INSTANCE_URL=$REVIEW_INSTANCE_URL yarn run build | tee /dev/stderr | (! grep -i 'ERROR in')
+    - mkdir -p /static/front/$CI_BUILD_REF_SLUG
+    - cp -r dist/* /static/front/$CI_BUILD_REF_SLUG
+  cache:
+    key: "$CI_PROJECT_ID__front_dependencies"
+    paths:
+      - front/node_modules
+      - front/yarn.lock
+  environment:
+    name: review/front-$CI_BUILD_REF_NAME
+    url: http://front-$CI_BUILD_REF_SLUG.$REVIEW_DOMAIN
+    on_stop: stop_front_review
+  only:
+    - branches@funkwhale/funkwhale
+  tags:
+    - funkwhale-review
+
+stop_front_review:
+  stage: review
+  script:
+    - rm -rf /static/front/$CI_BUILD_REF_SLUG/
+  variables:
+    GIT_STRATEGY: none
+  when: manual
+  environment:
+    name: review/front-$CI_BUILD_REF_NAME
+    action: stop
+  tags:
+    - funkwhale-review
+
+review_docs:
+  stage: review
+  image: python:3.6
+  when: manual
+  allow_failure: true
+  variables:
+    BUILD_PATH: "../public"
+  before_script:
+    - cd docs
+  cache:
+    key: "$CI_PROJECT_ID__sphinx"
+    paths:
+      - "$PIP_CACHE_DIR"
+  script:
+    - pip install sphinx
+    - ./build_docs.sh
+    - mkdir -p /static/docs/$CI_BUILD_REF_SLUG
+    - cp -r $CI_PROJECT_DIR/public/* /static/docs/$CI_BUILD_REF_SLUG
+  environment:
+    name: review/docs-$CI_BUILD_REF_NAME
+    url: http://docs-$CI_BUILD_REF_SLUG.$REVIEW_DOMAIN
+    on_stop: stop_docs_review
+  only:
+    - branches@funkwhale/funkwhale
+  tags:
+    - funkwhale-review
+
+stop_docs_review:
+  stage: review
+  script:
+    - rm -rf /static/docs/$CI_BUILD_REF_SLUG/
+  variables:
+    GIT_STRATEGY: none
+  when: manual
+  environment:
+    name: review/docs-$CI_BUILD_REF_NAME
+    action: stop
+  tags:
+    - funkwhale-review
+
 black:
   image: python:3.6
   stage: lint
@@ -20,7 +102,7 @@ black:
   before_script:
     - pip install black
   script:
-    - black --check --diff api/
+    - black --exclude "/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|migrations)/" --check --diff api/
 
 flake8:
   image: python:3.6
@@ -126,6 +208,10 @@ pages:
   script:
     - pip install sphinx
     - ./build_docs.sh
+  cache:
+    key: "$CI_PROJECT_ID__sphinx"
+    paths:
+      - "$PIP_CACHE_DIR"
   artifacts:
     paths:
       - public
diff --git a/CHANGELOG b/CHANGELOG
index ee59b7f20ebda0565beb693bcda8355d3f5644ae..3c26a5e9211144f1caedb0cb18076deea23cab17 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -10,6 +10,83 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
 
 .. towncrier
 
+0.15 (2018-06-24)
+-----------------
+
+Upgrade instructions are available at
+https://docs.funkwhale.audio/upgrading.html
+
+Features:
+
+- Added admin interface to manage import requests (#190)
+- Added replace flag during import to replace already present tracks with a new
+  version of their track file (#222)
+- Funkwhale's front-end can now point to any instance (#327) Removed front-end
+  and back-end coupling
+- Management interface for users (#212)
+- New invite system (#248) New invite system
+
+
+Enhancements:
+
+- Added "TV" to the list of highlighted words during YouTube import (#154)
+- Command line import now accepts unlimited args (#242)
+
+
+Bugfixes:
+
+- Expose track files date in manage API (#307)
+- Fixed current track restart/hiccup when shuffling queue, deleting track from
+  queue or reordering (#310)
+- Include user's current private playlists on playlist list (#302)
+- Remove link to generic radios, since they don't have detail pages (#324)
+
+
+Documentation:
+
+- Document that Funkwhale may be installed with YunoHost (#325)
+- Documented a saner layout with symlinks for in-place imports (#254)
+- Upgrade documentation now use the correct user on non-docker setups (#265)
+
+
+Invite system
+^^^^^^^^^^^^^
+
+On closed instances, it has always been a little bit painful to create accounts
+by hand for new users. This release solve that by adding invitations.
+
+You can generate invitation codes via the "users" admin interface (you'll find a
+link in the sidebar). Those codes are valid for 14 days, and can be used once
+to create a new account on the instance, even if registrations are closed.
+
+By default, we generate a random code for invitations, but you can also use custom codes
+if you need to print them or make them fancier ;)
+
+Invitations generation and management requires the "settings" permission.
+
+
+Removed front-end and back-end coupling
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Eventhough Funkwhale's front-end has always been a Single Page Application,
+talking to an API, it was only able to talk to an API on the same domain.
+
+There was no real technical justification behind this (only lazyness), and it was
+also blocking interesting use cases:
+
+- Use multiple customized versions of the front-end with the same instance
+- Use a customized version of the front-end with multiple instances
+- Use a locally hosted front-end with a remote API, which is especially useful in development
+
+From now on, Funkwhale's front-end can connect to any Funkwhale server. You can
+change the server you are connecting to in the footer.
+
+Fixing this also unlocked a really interesting feature in our development/review workflow:
+by leveraging Gitlab CI and review apps, we are now able to deploy automatically live versions of
+a merge request, making it possible for anyone to review front-end changes easily, without
+the need to install a local environment.
+
+
 0.14.2 (2018-06-16)
 -------------------
 
diff --git a/CONTRIBUTING b/CONTRIBUTING
index f79512def15bc14d18774c9a5706bcbe4e2b5eea..6fb76a56c08d0699dc4f69318552d7ef0f5b3de8 100644
--- a/CONTRIBUTING
+++ b/CONTRIBUTING
@@ -1,4 +1,4 @@
-Contibute to Funkwhale development
+Contribute to Funkwhale development
 ==================================
 
 First of all, thank you for your interest in the project! We really
@@ -12,6 +12,42 @@ This document will guide you through common operations such as:
 - Writing unit tests to validate your work
 - Submit your work
 
+A quick path to contribute on the front-end
+-------------------------------------------
+
+The next sections of this document include a full installation guide to help
+you setup a local, development version of Funkwhale. If you only want to fix small things
+on the front-end, and don't want to manage a full development environment, there is anoter way.
+
+As the front-end can work with any Funkwhale server, you can work with the front-end only,
+and make it talk with an existing instance (like the demo one, or you own instance, if you have one).
+
+If even that is too much for you, you can also make your changes without any development environment,
+and open a merge request. We will be able to to review your work easily by spawning automatically a
+live version of your changes, thanks to Gitlab Review apps.
+
+Setup front-end only development environment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+1. Clone the repository::
+
+    git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
+    cd funkwhale
+    cd front
+
+2. Install [nodejs](https://nodejs.org/en/download/package-manager/) and [yarn](https://yarnpkg.com/lang/en/docs/install/#debian-stable)
+3. Install the dependencies::
+
+    yarn install
+
+4. Launch the development server::
+
+    # this will serve the front-end on http://localhost:8000
+    WEBPACK_DEVSERVER_PORT=8000 yarn dev
+
+5. Make the front-end talk with an existing server (like https://demo.funkwhale.audio),
+   by clicking on the corresponding link in the footer
+6. Start hacking!
 
 Setup your development environment
 ----------------------------------
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index cb5573ed58ddf3edc8ae4df14b7ecf799b0946e4..b74c2bdfe499af75053748ddb8d90b96b8b42760 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -146,6 +146,7 @@ MIDDLEWARE = (
     "django.contrib.auth.middleware.AuthenticationMiddleware",
     "django.contrib.messages.middleware.MessageMiddleware",
     "django.middleware.clickjacking.XFrameOptionsMiddleware",
+    "funkwhale_api.users.middleware.RecordActivityMiddleware",
 )
 
 # MIGRATIONS CONFIGURATION
@@ -460,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
 MUSIC_DIRECTORY_SERVE_PATH = env(
     "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
 )
+
+USERS_INVITATION_EXPIRATION_DAYS = env.int(
+    "USERS_INVITATION_EXPIRATION_DAYS", default=14
+)
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index 44b80d2dc5ae0236d67d26dc29b668ad321aca84..fd35fd34dabe1f3272f55d2f687fe50fb445891f 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-__version__ = "0.14.2"
+__version__ = "0.15"
 __version_info__ = tuple(
     [
         int(num) if num.isdigit() else num
diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
index 190576efa688db2d5d9748841791b76531749136..890aee42566ffd7883857f90ac1a7ec70f19358b 100644
--- a/api/funkwhale_api/common/fields.py
+++ b/api/funkwhale_api/common/fields.py
@@ -17,13 +17,13 @@ def get_privacy_field():
     )
 
 
-def privacy_level_query(user, lookup_field="privacy_level"):
+def privacy_level_query(user, lookup_field="privacy_level", user_field="user"):
     if user.is_anonymous:
         return models.Q(**{lookup_field: "everyone"})
 
     return models.Q(
-        **{"{}__in".format(lookup_field): ["followers", "instance", "everyone"]}
-    )
+        **{"{}__in".format(lookup_field): ["instance", "everyone"]}
+    ) | models.Q(**{lookup_field: "me", user_field: user})
 
 
 class SearchFilter(django_filters.CharFilter):
diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py
index 029338ef992c6a57e3d88f096d4c7619df39d63b..161c581025da4c68d33de0277d956ed710f1b4ed 100644
--- a/api/funkwhale_api/common/serializers.py
+++ b/api/funkwhale_api/common/serializers.py
@@ -1,6 +1,16 @@
 from rest_framework import serializers
 
 
+class Action(object):
+    def __init__(self, name, allow_all=False, qs_filter=None):
+        self.name = name
+        self.allow_all = allow_all
+        self.qs_filter = qs_filter
+
+    def __repr__(self):
+        return "<Action {}>".format(self.name)
+
+
 class ActionSerializer(serializers.Serializer):
     """
     A special serializer that can operate on a list of objects
@@ -11,19 +21,16 @@ class ActionSerializer(serializers.Serializer):
     objects = serializers.JSONField(required=True)
     filters = serializers.DictField(required=False)
     actions = None
-    filterset_class = None
-    # those are actions identifier where we don't want to allow the "all"
-    # selector because it's to dangerous. Like object deletion.
-    dangerous_actions = []
 
     def __init__(self, *args, **kwargs):
+        self.actions_by_name = {a.name: a for a in self.actions}
         self.queryset = kwargs.pop("queryset")
         if self.actions is None:
             raise ValueError(
                 "You must declare a list of actions on " "the serializer class"
             )
 
-        for action in self.actions:
+        for action in self.actions_by_name.keys():
             handler_name = "handle_{}".format(action)
             assert hasattr(self, handler_name), "{} miss a {} method".format(
                 self.__class__.__name__, handler_name
@@ -31,13 +38,14 @@ class ActionSerializer(serializers.Serializer):
         super().__init__(self, *args, **kwargs)
 
     def validate_action(self, value):
-        if value not in self.actions:
+        try:
+            return self.actions_by_name[value]
+        except KeyError:
             raise serializers.ValidationError(
                 "{} is not a valid action. Pick one of {}.".format(
-                    value, ", ".join(self.actions)
+                    value, ", ".join(self.actions_by_name.keys())
                 )
             )
-        return value
 
     def validate_objects(self, value):
         if value == "all":
@@ -51,33 +59,35 @@ class ActionSerializer(serializers.Serializer):
         )
 
     def validate(self, data):
-        dangerous = data["action"] in self.dangerous_actions
-        if dangerous and self.initial_data["objects"] == "all":
+        allow_all = data["action"].allow_all
+        if not allow_all and self.initial_data["objects"] == "all":
             raise serializers.ValidationError(
-                "This action is to dangerous to be applied to all objects"
-            )
-        if self.filterset_class and "filters" in data:
-            qs_filterset = self.filterset_class(
-                data["filters"], queryset=data["objects"]
+                "You cannot apply this action on all objects"
             )
+        final_filters = data.get("filters", {}) or {}
+        if self.filterset_class and final_filters:
+            qs_filterset = self.filterset_class(final_filters, queryset=data["objects"])
             try:
                 assert qs_filterset.form.is_valid()
             except (AssertionError, TypeError):
                 raise serializers.ValidationError("Invalid filters")
             data["objects"] = qs_filterset.qs
 
+        if data["action"].qs_filter:
+            data["objects"] = data["action"].qs_filter(data["objects"])
+
         data["count"] = data["objects"].count()
         if data["count"] < 1:
             raise serializers.ValidationError("No object matching your request")
         return data
 
     def save(self):
-        handler_name = "handle_{}".format(self.validated_data["action"])
+        handler_name = "handle_{}".format(self.validated_data["action"].name)
         handler = getattr(self, handler_name)
         result = handler(self.validated_data["objects"])
         payload = {
             "updated": self.validated_data["count"],
-            "action": self.validated_data["action"],
+            "action": self.validated_data["action"].name,
             "result": result,
         }
         return payload
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 062f74f476da3b818477306683a9f284a4e126bc..44de5d3129536e0c18a5617007a99694b1aa2864 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -769,7 +769,7 @@ class CollectionSerializer(serializers.Serializer):
 
 
 class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
-    actions = ["import"]
+    actions = [common_serializers.Action("import", allow_all=True)]
     filterset_class = filters.LibraryTrackFilter
 
     @transaction.atomic
diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index 2f2bde838fa90695ebc1f077e88650f2d69536c5..8098ef1a2f49ee8b6275351a56faae77eb7e8dd6 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -1,8 +1,9 @@
-
 from django_filters import rest_framework as filters
 
 from funkwhale_api.common import fields
 from funkwhale_api.music import models as music_models
+from funkwhale_api.requests import models as requests_models
+from funkwhale_api.users import models as users_models
 
 
 class ManageTrackFileFilterSet(filters.FilterSet):
@@ -18,3 +19,45 @@ class ManageTrackFileFilterSet(filters.FilterSet):
     class Meta:
         model = music_models.TrackFile
         fields = ["q", "track__album", "track__artist", "track", "library_track"]
+
+
+class ManageUserFilterSet(filters.FilterSet):
+    q = fields.SearchFilter(search_fields=["username", "email", "name"])
+
+    class Meta:
+        model = users_models.User
+        fields = [
+            "q",
+            "is_active",
+            "privacy_level",
+            "is_staff",
+            "is_superuser",
+            "permission_upload",
+            "permission_library",
+            "permission_settings",
+            "permission_federation",
+        ]
+
+
+class ManageInvitationFilterSet(filters.FilterSet):
+    q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"])
+    is_open = filters.BooleanFilter(method="filter_is_open")
+
+    class Meta:
+        model = users_models.Invitation
+        fields = ["q", "is_open"]
+
+    def filter_is_open(self, queryset, field_name, value):
+        if value is None:
+            return queryset
+        return queryset.open(value)
+
+
+class ManageImportRequestFilterSet(filters.FilterSet):
+    q = fields.SearchFilter(
+        search_fields=["user__username", "albums", "artist_name", "comment"]
+    )
+
+    class Meta:
+        model = requests_models.ImportRequest
+        fields = ["q", "status"]
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index 1c94cf5538171973a16e29a8f54591daf7778f2e..42585d6a7ed173b4c56dd17e0a1e973d3757bccc 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -1,8 +1,11 @@
 from django.db import transaction
+from django.utils import timezone
 from rest_framework import serializers
 
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.music import models as music_models
+from funkwhale_api.requests import models as requests_models
+from funkwhale_api.users import models as users_models
 
 from . import filters
 
@@ -52,6 +55,7 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
             "track",
             "duration",
             "mimetype",
+            "creation_date",
             "bitrate",
             "size",
             "path",
@@ -60,10 +64,172 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
 
 
 class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
-    actions = ["delete"]
-    dangerous_actions = ["delete"]
+    actions = [common_serializers.Action("delete", allow_all=False)]
     filterset_class = filters.ManageTrackFileFilterSet
 
     @transaction.atomic
     def handle_delete(self, objects):
         return objects.delete()
+
+
+class PermissionsSerializer(serializers.Serializer):
+    def to_representation(self, o):
+        return o.get_permissions(defaults=self.context.get("default_permissions"))
+
+    def to_internal_value(self, o):
+        return {"permissions": o}
+
+
+class ManageUserSimpleSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = users_models.User
+        fields = (
+            "id",
+            "username",
+            "email",
+            "name",
+            "is_active",
+            "is_staff",
+            "is_superuser",
+            "date_joined",
+            "last_activity",
+            "privacy_level",
+        )
+
+
+class ManageUserSerializer(serializers.ModelSerializer):
+    permissions = PermissionsSerializer(source="*")
+
+    class Meta:
+        model = users_models.User
+        fields = (
+            "id",
+            "username",
+            "email",
+            "name",
+            "is_active",
+            "is_staff",
+            "is_superuser",
+            "date_joined",
+            "last_activity",
+            "permissions",
+            "privacy_level",
+        )
+        read_only_fields = [
+            "id",
+            "email",
+            "privacy_level",
+            "username",
+            "date_joined",
+            "last_activity",
+        ]
+
+    def update(self, instance, validated_data):
+        instance = super().update(instance, validated_data)
+        permissions = validated_data.pop("permissions", {})
+        if permissions:
+            for p, value in permissions.items():
+                setattr(instance, "permission_{}".format(p), value)
+            instance.save(
+                update_fields=["permission_{}".format(p) for p in permissions.keys()]
+            )
+        return instance
+
+
+class ManageInvitationSerializer(serializers.ModelSerializer):
+    users = ManageUserSimpleSerializer(many=True, required=False)
+    owner = ManageUserSimpleSerializer(required=False)
+    code = serializers.CharField(required=False, allow_null=True)
+
+    class Meta:
+        model = users_models.Invitation
+        fields = ("id", "owner", "code", "expiration_date", "creation_date", "users")
+        read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"]
+
+    def validate_code(self, value):
+        if not value:
+            return value
+        if users_models.Invitation.objects.filter(code__iexact=value).exists():
+            raise serializers.ValidationError(
+                "An invitation with this code already exists"
+            )
+        return value
+
+
+class ManageInvitationActionSerializer(common_serializers.ActionSerializer):
+    actions = [
+        common_serializers.Action(
+            "delete", allow_all=False, qs_filter=lambda qs: qs.open()
+        )
+    ]
+    filterset_class = filters.ManageInvitationFilterSet
+
+    @transaction.atomic
+    def handle_delete(self, objects):
+        return objects.delete()
+
+
+class ManageImportRequestSerializer(serializers.ModelSerializer):
+    user = ManageUserSimpleSerializer(required=False)
+
+    class Meta:
+        model = requests_models.ImportRequest
+        fields = [
+            "id",
+            "status",
+            "creation_date",
+            "imported_date",
+            "user",
+            "albums",
+            "artist_name",
+            "comment",
+        ]
+        read_only_fields = [
+            "id",
+            "status",
+            "creation_date",
+            "imported_date",
+            "user",
+            "albums",
+            "artist_name",
+            "comment",
+        ]
+
+    def validate_code(self, value):
+        if not value:
+            return value
+        if users_models.Invitation.objects.filter(code__iexact=value).exists():
+            raise serializers.ValidationError(
+                "An invitation with this code already exists"
+            )
+        return value
+
+
+class ManageImportRequestActionSerializer(common_serializers.ActionSerializer):
+    actions = [
+        common_serializers.Action(
+            "mark_closed",
+            allow_all=True,
+            qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]),
+        ),
+        common_serializers.Action(
+            "mark_imported",
+            allow_all=True,
+            qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]),
+        ),
+        common_serializers.Action("delete", allow_all=False),
+    ]
+    filterset_class = filters.ManageImportRequestFilterSet
+
+    @transaction.atomic
+    def handle_delete(self, objects):
+        return objects.delete()
+
+    @transaction.atomic
+    def handle_mark_closed(self, objects):
+        return objects.update(status="closed")
+
+    @transaction.atomic
+    def handle_mark_imported(self, objects):
+        now = timezone.now()
+        return objects.update(status="imported", imported_date=now)
diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py
index 60853034f0a0552c01b67b6a0354158691d49783..8285ade0699b45e49cc45e654bfa1baa467406ee 100644
--- a/api/funkwhale_api/manage/urls.py
+++ b/api/funkwhale_api/manage/urls.py
@@ -5,7 +5,18 @@ from . import views
 
 library_router = routers.SimpleRouter()
 library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
+requests_router = routers.SimpleRouter()
+requests_router.register(
+    r"import-requests", views.ManageImportRequestViewSet, "import-requests"
+)
+users_router = routers.SimpleRouter()
+users_router.register(r"users", views.ManageUserViewSet, "users")
+users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
 
 urlpatterns = [
-    url(r"^library/", include((library_router.urls, "instance"), namespace="library"))
+    url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
+    url(r"^users/", include((users_router.urls, "instance"), namespace="users")),
+    url(
+        r"^requests/", include((requests_router.urls, "instance"), namespace="requests")
+    ),
 ]
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index 8511732c96b287e7c2c82da799dc864c7a455e6a..89d2afe4593f5fe0c34118af9976e43307feaf05 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -1,17 +1,17 @@
 from rest_framework import mixins, response, viewsets
 from rest_framework.decorators import list_route
 
+from funkwhale_api.common import preferences
 from funkwhale_api.music import models as music_models
+from funkwhale_api.requests import models as requests_models
+from funkwhale_api.users import models as users_models
 from funkwhale_api.users.permissions import HasUserPermission
 
 from . import filters, serializers
 
 
 class ManageTrackFileViewSet(
-    mixins.ListModelMixin,
-    mixins.RetrieveModelMixin,
-    mixins.DestroyModelMixin,
-    viewsets.GenericViewSet,
+    mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
 ):
     queryset = (
         music_models.TrackFile.objects.all()
@@ -41,3 +41,83 @@ class ManageTrackFileViewSet(
         serializer.is_valid(raise_exception=True)
         result = serializer.save()
         return response.Response(result, status=200)
+
+
+class ManageUserViewSet(
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.UpdateModelMixin,
+    viewsets.GenericViewSet,
+):
+    queryset = users_models.User.objects.all().order_by("-id")
+    serializer_class = serializers.ManageUserSerializer
+    filter_class = filters.ManageUserFilterSet
+    permission_classes = (HasUserPermission,)
+    required_permissions = ["settings"]
+    ordering_fields = ["date_joined", "last_activity", "username"]
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        context["default_permissions"] = preferences.get("users__default_permissions")
+        return context
+
+
+class ManageInvitationViewSet(
+    mixins.CreateModelMixin,
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.UpdateModelMixin,
+    viewsets.GenericViewSet,
+):
+    queryset = (
+        users_models.Invitation.objects.all()
+        .order_by("-id")
+        .prefetch_related("users")
+        .select_related("owner")
+    )
+    serializer_class = serializers.ManageInvitationSerializer
+    filter_class = filters.ManageInvitationFilterSet
+    permission_classes = (HasUserPermission,)
+    required_permissions = ["settings"]
+    ordering_fields = ["creation_date", "expiration_date"]
+
+    def perform_create(self, serializer):
+        serializer.save(owner=self.request.user)
+
+    @list_route(methods=["post"])
+    def action(self, request, *args, **kwargs):
+        queryset = self.get_queryset()
+        serializer = serializers.ManageInvitationActionSerializer(
+            request.data, queryset=queryset
+        )
+        serializer.is_valid(raise_exception=True)
+        result = serializer.save()
+        return response.Response(result, status=200)
+
+
+class ManageImportRequestViewSet(
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.UpdateModelMixin,
+    viewsets.GenericViewSet,
+):
+    queryset = (
+        requests_models.ImportRequest.objects.all()
+        .order_by("-id")
+        .select_related("user")
+    )
+    serializer_class = serializers.ManageImportRequestSerializer
+    filter_class = filters.ManageImportRequestFilterSet
+    permission_classes = (HasUserPermission,)
+    required_permissions = ["library"]
+    ordering_fields = ["creation_date", "imported_date"]
+
+    @list_route(methods=["post"])
+    def action(self, request, *args, **kwargs):
+        queryset = self.get_queryset()
+        serializer = serializers.ManageImportRequestActionSerializer(
+            request.data, queryset=queryset
+        )
+        serializer.is_valid(raise_exception=True)
+        result = serializer.save()
+        return response.Response(result, status=200)
diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py
index 2dd4ba3038593cc2bb2c60610735caeb7f61bc8f..9bcc4350ffae3e56ddef431b9c33960b9899364a 100644
--- a/api/funkwhale_api/music/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -89,6 +89,7 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
     batch = factory.SubFactory(ImportBatchFactory)
     source = factory.Faker("url")
     mbid = factory.Faker("uuid4")
+    replace_if_duplicate = False
 
     class Meta:
         model = "music.ImportJob"
diff --git a/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py
new file mode 100644
index 0000000000000000000000000000000000000000..d02a17ad2299cd1da67d514e3abc99271ade788a
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.6 on 2018-06-22 13:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0027_auto_20180515_1808'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='importjob',
+            name='replace_if_duplicate',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 8b638ce7daff025cd7d68eaba2d93dcb7ec1f562..d533d852588ac401288c070ada67cb9fcea2b7eb 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -539,7 +539,7 @@ class ImportBatch(models.Model):
         related_name="import_batches",
         null=True,
         blank=True,
-        on_delete=models.CASCADE,
+        on_delete=models.SET_NULL,
     )
 
     class Meta:
@@ -567,6 +567,7 @@ class ImportBatch(models.Model):
 
 class ImportJob(models.Model):
     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
+    replace_if_duplicate = models.BooleanField(default=False)
     batch = models.ForeignKey(
         ImportBatch, related_name="jobs", on_delete=models.CASCADE
     )
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index 355af7706d6cca3d2d637311a6205dfa26c77c1c..2092b6ee76e0ea8db8dff489b95c14bd6837117f 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -80,10 +80,11 @@ def import_track_from_remote(library_track):
     )[0]
 
 
-def _do_import(import_job, replace=False, use_acoustid=False):
+def _do_import(import_job, use_acoustid=False):
     logger.info("[Import Job %s] starting job", import_job.pk)
     from_file = bool(import_job.audio_file)
     mbid = import_job.mbid
+    replace = import_job.replace_if_duplicate
     acoustid_track_id = None
     duration = None
     track = None
@@ -135,8 +136,8 @@ def _do_import(import_job, replace=False, use_acoustid=False):
 
     track_file = None
     if replace:
-        logger.info("[Import Job %s] replacing existing audio file", import_job.pk)
-        track_file = track.files.first()
+        logger.info("[Import Job %s] deleting existing audio file", import_job.pk)
+        track.files.all().delete()
     elif track.files.count() > 0:
         logger.info(
             "[Import Job %s] skipping, we already have a file for this track",
@@ -163,7 +164,7 @@ def _do_import(import_job, replace=False, use_acoustid=False):
             # no downloading, we hotlink
             pass
     elif not import_job.audio_file and not import_job.source.startswith("file://"):
-        # not an implace import, and we have a source, so let's download it
+        # not an inplace import, and we have a source, so let's download it
         logger.info("[Import Job %s] downloading audio file from remote", import_job.pk)
         track_file.download_file()
     elif not import_job.audio_file and import_job.source.startswith("file://"):
@@ -243,14 +244,14 @@ def get_cover_from_fs(dir_path):
 @celery.require_instance(
     models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job"
 )
-def import_job_run(self, import_job, replace=False, use_acoustid=False):
+def import_job_run(self, import_job, use_acoustid=False):
     def mark_errored(exc):
         logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc))
         import_job.status = "errored"
         import_job.save(update_fields=["status"])
 
     try:
-        tf = _do_import(import_job, replace, use_acoustid=use_acoustid)
+        tf = _do_import(import_job, use_acoustid=use_acoustid)
         return tf.pk if tf else None
     except Exception as exc:
         if not settings.DEBUG:
diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py
index d5d19df74b66289c0230c38633dc591c19200bb1..21e35f50a8c711fa2a7cdee401d8eba7b9986023 100644
--- a/api/funkwhale_api/playlists/views.py
+++ b/api/funkwhale_api/playlists/views.py
@@ -110,7 +110,9 @@ class PlaylistTrackViewSet(
     def get_queryset(self):
         return self.queryset.filter(
             fields.privacy_level_query(
-                self.request.user, lookup_field="playlist__privacy_level"
+                self.request.user,
+                lookup_field="playlist__privacy_level",
+                user_field="playlist__user",
             )
         )
 
diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
index de2560d3c8d064a0bddd93208de82e0b7d3c99ca..b59c0046fa154e2e8f8c1e62aba6ce283416e232 100644
--- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
+++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
@@ -13,7 +13,7 @@ class Command(BaseCommand):
     help = "Import audio files mathinc given glob pattern"
 
     def add_arguments(self, parser):
-        parser.add_argument("path", type=str)
+        parser.add_argument("path", nargs="+", type=str)
         parser.add_argument(
             "--recursive",
             action="store_true",
@@ -55,6 +55,17 @@ class Command(BaseCommand):
                 "import and not much disk space available."
             ),
         )
+        parser.add_argument(
+            "--replace",
+            action="store_true",
+            dest="replace",
+            default=False,
+            help=(
+                "Use this flag to replace duplicates (tracks with same "
+                "musicbrainz mbid, or same artist, album and title) on import "
+                "with their newest version."
+            ),
+        )
         parser.add_argument(
             "--noinput",
             "--no-input",
@@ -65,10 +76,13 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
         glob_kwargs = {}
+        matching = []
         if options["recursive"]:
             glob_kwargs["recursive"] = True
         try:
-            matching = sorted(glob.glob(options["path"], **glob_kwargs))
+            for import_path in options["path"]:
+                matching += glob.glob(import_path, **glob_kwargs)
+            matching = sorted(list(set(matching)))
         except TypeError:
             raise Exception("You need Python 3.5 to use the --recursive flag")
 
@@ -109,16 +123,23 @@ class Command(BaseCommand):
                     "No superuser available, please provide a --username"
                 )
 
-        filtered = self.filter_matching(matching, options)
+        if options["replace"]:
+            filtered = {"initial": matching, "skipped": [], "new": matching}
+            message = "- {} files to be replaced"
+            import_paths = matching
+        else:
+            filtered = self.filter_matching(matching)
+            message = "- {} files already found in database"
+            import_paths = filtered["new"]
+
         self.stdout.write("Import summary:")
         self.stdout.write(
             "- {} files found matching this pattern: {}".format(
                 len(matching), options["path"]
             )
         )
-        self.stdout.write(
-            "- {} files already found in database".format(len(filtered["skipped"]))
-        )
+        self.stdout.write(message.format(len(filtered["skipped"])))
+
         self.stdout.write("- {} new files".format(len(filtered["new"])))
 
         self.stdout.write(
@@ -138,12 +159,12 @@ class Command(BaseCommand):
             if input("".join(message)) != "yes":
                 raise CommandError("Import cancelled.")
 
-        batch, errors = self.do_import(filtered["new"], user=user, options=options)
+        batch, errors = self.do_import(import_paths, user=user, options=options)
         message = "Successfully imported {} tracks"
         if options["async"]:
             message = "Successfully launched import for {} tracks"
 
-        self.stdout.write(message.format(len(filtered["new"])))
+        self.stdout.write(message.format(len(import_paths)))
         if len(errors) > 0:
             self.stderr.write("{} tracks could not be imported:".format(len(errors)))
 
@@ -153,7 +174,7 @@ class Command(BaseCommand):
             "For details, please refer to import batch #{}".format(batch.pk)
         )
 
-    def filter_matching(self, matching, options):
+    def filter_matching(self, matching):
         sources = ["file://{}".format(p) for p in matching]
         # we skip reimport for path that are already found
         # as a TrackFile.source
@@ -193,7 +214,9 @@ class Command(BaseCommand):
         return batch, errors
 
     def import_file(self, path, batch, import_handler, options):
-        job = batch.jobs.create(source="file://" + path)
+        job = batch.jobs.create(
+            source="file://" + path, replace_if_duplicate=options["replace"]
+        )
         if not options["in_place"]:
             name = os.path.basename(path)
             with open(path, "rb") as f:
diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py
index 5c694ab0ee962c2977dd227c709861dfef93098f..205c7c36703ebfa153dab7b2a393f4432152e162 100644
--- a/api/funkwhale_api/users/admin.py
+++ b/api/funkwhale_api/users/admin.py
@@ -7,12 +7,12 @@ 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
+from . import models
 
 
 class MyUserChangeForm(UserChangeForm):
     class Meta(UserChangeForm.Meta):
-        model = User
+        model = models.User
 
 
 class MyUserCreationForm(UserCreationForm):
@@ -22,18 +22,18 @@ class MyUserCreationForm(UserCreationForm):
     )
 
     class Meta(UserCreationForm.Meta):
-        model = User
+        model = models.User
 
     def clean_username(self):
         username = self.cleaned_data["username"]
         try:
-            User.objects.get(username=username)
-        except User.DoesNotExist:
+            models.User.objects.get(username=username)
+        except models.User.DoesNotExist:
             return username
         raise forms.ValidationError(self.error_messages["duplicate_username"])
 
 
-@admin.register(User)
+@admin.register(models.User)
 class UserAdmin(AuthUserAdmin):
     form = MyUserChangeForm
     add_form = MyUserCreationForm
@@ -74,3 +74,11 @@ class UserAdmin(AuthUserAdmin):
         (_("Important dates"), {"fields": ("last_login", "date_joined")}),
         (_("Useless fields"), {"fields": ("user_permissions", "groups")}),
     )
+
+
+@admin.register(models.Invitation)
+class InvitationAdmin(admin.ModelAdmin):
+    list_select_related = True
+    list_display = ["owner", "code", "creation_date", "expiration_date"]
+    search_fields = ["owner__username", "code"]
+    readonly_fields = ["expiration_date", "code"]
diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py
index eed8c7175a2dacdb65404aaf827c2bee04582dbb..5fceb57bbc17bdd8ac70e95bc81e672869b4abe8 100644
--- a/api/funkwhale_api/users/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -1,5 +1,6 @@
 import factory
 from django.contrib.auth.models import Permission
+from django.utils import timezone
 
 from funkwhale_api.factories import ManyToManyFromList, registry
 
@@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory):
             self.permissions.add(*perms)
 
 
+@registry.register
+class InvitationFactory(factory.django.DjangoModelFactory):
+    owner = factory.LazyFunction(lambda: UserFactory())
+
+    class Meta:
+        model = "users.Invitation"
+
+    class Params:
+        expired = factory.Trait(expiration_date=factory.LazyFunction(timezone.now))
+
+
 @registry.register
 class UserFactory(factory.django.DjangoModelFactory):
     username = factory.Sequence(lambda n: "user-{0}".format(n))
@@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory):
         model = "users.User"
         django_get_or_create = ("username",)
 
+    class Params:
+        invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory))
+
     @factory.post_generation
     def perms(self, create, extracted, **kwargs):
         if not create:
diff --git a/api/funkwhale_api/users/middleware.py b/api/funkwhale_api/users/middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..d5e83f0809336c335db7f7c21beaa5ac2b6995f1
--- /dev/null
+++ b/api/funkwhale_api/users/middleware.py
@@ -0,0 +1,9 @@
+class RecordActivityMiddleware:
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        response = self.get_response(request)
+        if hasattr(request, "user") and request.user.is_authenticated:
+            request.user.record_activity()
+        return response
diff --git a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py
new file mode 100644
index 0000000000000000000000000000000000000000..b731e327951573b3092d098ab9c9b3c0dfcdf9df
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.0.6 on 2018-06-17 15:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0007_auto_20180524_2009'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='last_activity',
+            field=models.DateTimeField(blank=True, default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='permission_library',
+            field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8204c4e4748371b7906d9115b0d529f30a35db7
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.0.6 on 2018-06-19 20:24
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0008_auto_20180617_1531'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Invitation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('expiration_date', models.DateTimeField()),
+                ('code', models.CharField(max_length=50, unique=True)),
+                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='invitation',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index caf1e452bbcab42ff52587a50d35c6039fdf132c..ec9c39fd69a47d08f08f2a59ba20f480e8cfdb9c 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -2,13 +2,17 @@
 from __future__ import absolute_import, unicode_literals
 
 import binascii
+import datetime
 import os
+import random
+import string
 import uuid
 
 from django.conf import settings
 from django.contrib.auth.models import AbstractUser
 from django.db import models
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 
@@ -75,11 +79,21 @@ class User(AbstractUser):
         default=False,
     )
 
+    last_activity = models.DateTimeField(default=None, null=True, blank=True)
+
+    invitation = models.ForeignKey(
+        "Invitation",
+        related_name="users",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+    )
+
     def __str__(self):
         return self.username
 
-    def get_permissions(self):
-        defaults = preferences.get("users__default_permissions")
+    def get_permissions(self, defaults=None):
+        defaults = defaults or preferences.get("users__default_permissions")
         perms = {}
         for p in PERMISSIONS:
             v = (
@@ -90,6 +104,10 @@ class User(AbstractUser):
             perms[p] = v
         return perms
 
+    @property
+    def all_permissions(self):
+        return self.get_permissions()
+
     def has_permissions(self, *perms, **kwargs):
         operator = kwargs.pop("operator", "and")
         if operator not in ["and", "or"]:
@@ -117,3 +135,53 @@ class User(AbstractUser):
 
     def get_activity_url(self):
         return settings.FUNKWHALE_URL + "/@{}".format(self.username)
+
+    def record_activity(self):
+        """
+        Simply update the last_activity field if current value is too old
+        than a threshold. This is useful to keep a track of inactive accounts.
+        """
+        current = self.last_activity
+        delay = 60 * 15  # fifteen minutes
+        now = timezone.now()
+
+        if current is None or current < now - datetime.timedelta(seconds=delay):
+            self.last_activity = now
+            self.save(update_fields=["last_activity"])
+
+
+def generate_code(length=10):
+    return "".join(
+        random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length)
+    )
+
+
+class InvitationQuerySet(models.QuerySet):
+    def open(self, include=True):
+        now = timezone.now()
+        qs = self.annotate(_users=models.Count("users"))
+        query = models.Q(_users=0, expiration_date__gt=now)
+        if include:
+            return qs.filter(query)
+        return qs.exclude(query)
+
+
+class Invitation(models.Model):
+    creation_date = models.DateTimeField(default=timezone.now)
+    expiration_date = models.DateTimeField()
+    owner = models.ForeignKey(
+        User, related_name="invitations", on_delete=models.CASCADE
+    )
+    code = models.CharField(max_length=50, unique=True)
+
+    objects = InvitationQuerySet.as_manager()
+
+    def save(self, **kwargs):
+        if not self.code:
+            self.code = generate_code()
+        if not self.expiration_date:
+            self.expiration_date = self.creation_date + datetime.timedelta(
+                days=settings.USERS_INVITATION_EXPIRATION_DAYS
+            )
+
+        return super().save(**kwargs)
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index b3bd431c722fc5f8e4751270a9b1690973119de2..4389512650327a2da66fcd35fee362106814d43a 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -1,5 +1,6 @@
 from django.conf import settings
 from rest_auth.serializers import PasswordResetSerializer as PRS
+from rest_auth.registration.serializers import RegisterSerializer as RS
 from rest_framework import serializers
 
 from funkwhale_api.activity import serializers as activity_serializers
@@ -7,6 +8,28 @@ from funkwhale_api.activity import serializers as activity_serializers
 from . import models
 
 
+class RegisterSerializer(RS):
+    invitation = serializers.CharField(
+        required=False, allow_null=True, allow_blank=True
+    )
+
+    def validate_invitation(self, value):
+        if not value:
+            return
+
+        try:
+            return models.Invitation.objects.open().get(code__iexact=value)
+        except models.Invitation.DoesNotExist:
+            raise serializers.ValidationError("Invalid invitation code")
+
+    def save(self, request):
+        user = super().save(request)
+        if self.validated_data.get("invitation"):
+            user.invitation = self.validated_data.get("invitation")
+            user.save(update_fields=["invitation"])
+        return user
+
+
 class UserActivitySerializer(activity_serializers.ModelSerializer):
     type = serializers.SerializerMethodField()
     name = serializers.CharField(source="username")
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index 69e69d26e6b987426ef59a450d07e3d6458f6ab2..20d63d788f349643b9065038102cabfd60a49ce0 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -10,8 +10,11 @@ from . import models, serializers
 
 
 class RegisterView(BaseRegisterView):
+    serializer_class = serializers.RegisterSerializer
+
     def create(self, request, *args, **kwargs):
-        if not self.is_open_for_signup(request):
+        invitation_code = request.data.get("invitation")
+        if not invitation_code and not self.is_open_for_signup(request):
             r = {"detail": "Registration has been disabled"}
             return Response(r, status=403)
         return super().create(request, *args, **kwargs)
diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py
index d2692314854c3c6761974789ed82f96132beb9dd..72aa8b4c35601e87555843a843566369c61e2375 100644
--- a/api/tests/common/test_fields.py
+++ b/api/tests/common/test_fields.py
@@ -12,7 +12,8 @@ from funkwhale_api.users.factories import UserFactory
         (AnonymousUser(), Q(privacy_level="everyone")),
         (
             UserFactory.build(pk=1),
-            Q(privacy_level__in=["followers", "instance", "everyone"]),
+            Q(privacy_level__in=["instance", "everyone"])
+            | Q(privacy_level="me", user=UserFactory.build(pk=1)),
         ),
     ],
 )
diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py
index ca5e5ad8f6fd2317af880eef0aec3f28b1184b04..e07bf8e826bfec8ebe77de5be57b1b7ce9f2d553 100644
--- a/api/tests/common/test_serializers.py
+++ b/api/tests/common/test_serializers.py
@@ -11,7 +11,7 @@ class TestActionFilterSet(django_filters.FilterSet):
 
 
 class TestSerializer(serializers.ActionSerializer):
-    actions = ["test"]
+    actions = [serializers.Action("test", allow_all=True)]
     filterset_class = TestActionFilterSet
 
     def handle_test(self, objects):
@@ -19,8 +19,10 @@ class TestSerializer(serializers.ActionSerializer):
 
 
 class TestDangerousSerializer(serializers.ActionSerializer):
-    actions = ["test", "test_dangerous"]
-    dangerous_actions = ["test_dangerous"]
+    actions = [
+        serializers.Action("test", allow_all=True),
+        serializers.Action("test_dangerous"),
+    ]
 
     def handle_test(self, objects):
         pass
@@ -29,6 +31,18 @@ class TestDangerousSerializer(serializers.ActionSerializer):
         pass
 
 
+class TestDeleteOnlyInactiveSerializer(serializers.ActionSerializer):
+    actions = [
+        serializers.Action(
+            "test", allow_all=True, qs_filter=lambda qs: qs.filter(is_active=False)
+        )
+    ]
+    filterset_class = TestActionFilterSet
+
+    def handle_test(self, objects):
+        pass
+
+
 def test_action_serializer_validates_action():
     data = {"objects": "all", "action": "nope"}
     serializer = TestSerializer(data, queryset=models.User.objects.none())
@@ -52,7 +66,7 @@ def test_action_serializers_objects_clean_ids(factories):
     data = {"objects": [user1.pk], "action": "test"}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     assert list(serializer.validated_data["objects"]) == [user1]
 
 
@@ -63,7 +77,7 @@ def test_action_serializers_objects_clean_all(factories):
     data = {"objects": "all", "action": "test"}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     assert list(serializer.validated_data["objects"]) == [user1, user2]
 
 
@@ -75,7 +89,7 @@ def test_action_serializers_save(factories, mocker):
     data = {"objects": "all", "action": "test"}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     result = serializer.save()
     assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}}
     handler.assert_called_once()
@@ -88,7 +102,7 @@ def test_action_serializers_filterset(factories):
     data = {"objects": "all", "action": "test", "filters": {"is_active": True}}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     assert list(serializer.validated_data["objects"]) == [user2]
 
 
@@ -109,9 +123,14 @@ def test_dangerous_actions_refuses_all(factories):
     assert "non_field_errors" in serializer.errors
 
 
-def test_dangerous_actions_refuses_not_listed(factories):
-    factories["users.User"]()
+def test_action_serializers_can_require_filter(factories):
+    user1 = factories["users.User"](is_active=False)
+    factories["users.User"](is_active=True)
+
     data = {"objects": "all", "action": "test"}
-    serializer = TestDangerousSerializer(data, queryset=models.User.objects.all())
+    serializer = TestDeleteOnlyInactiveSerializer(
+        data, queryset=models.User.objects.all()
+    )
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
+    assert list(serializer.validated_data["objects"]) == [user1]
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 40203ee3d472693d7304d92ab68094949535fab9..aa36e1f76f60d836a33b0c82a44be55a4f3cb372 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -7,6 +7,7 @@ import pytest
 import requests_mock
 from django.contrib.auth.models import AnonymousUser
 from django.core.cache import cache as django_cache
+from django.utils import timezone
 from django.test import client
 from dynamic_preferences.registries import global_preferences_registry
 from rest_framework import fields as rest_fields
@@ -250,3 +251,10 @@ def to_api_date():
         raise ValueError("Invalid value: {}".format(value))
 
     return inner
+
+
+@pytest.fixture()
+def now(mocker):
+    now = timezone.now()
+    mocker.patch("django.utils.timezone.now", return_value=now)
+    return now
diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py
index 893cfd86e46e5720446c78dcde5457a7358206b1..9742b098d2026bd5c0da09b810bc29bea9f91a50 100644
--- a/api/tests/manage/test_serializers.py
+++ b/api/tests/manage/test_serializers.py
@@ -8,3 +8,67 @@ def test_manage_track_file_action_delete(factories):
     s.handle_delete(tfs.__class__.objects.all())
 
     assert tfs.__class__.objects.count() == 0
+
+
+def test_user_update_permission(factories):
+    user = factories["users.User"](
+        permission_library=False,
+        permission_upload=False,
+        permission_federation=True,
+        permission_settings=True,
+        is_active=True,
+    )
+    s = serializers.ManageUserSerializer(
+        user,
+        data={"is_active": False, "permissions": {"federation": False, "upload": True}},
+    )
+    s.is_valid(raise_exception=True)
+    s.save()
+    user.refresh_from_db()
+
+    assert user.is_active is False
+    assert user.permission_federation is False
+    assert user.permission_upload is True
+    assert user.permission_library is False
+    assert user.permission_settings is True
+
+
+def test_manage_import_request_mark_closed(factories):
+    affected = factories["requests.ImportRequest"].create_batch(
+        size=5, status="pending"
+    )
+    # we do not update imported requests
+    factories["requests.ImportRequest"].create_batch(size=5, status="imported")
+    s = serializers.ManageImportRequestActionSerializer(
+        queryset=affected[0].__class__.objects.all(),
+        data={"objects": "all", "action": "mark_closed"},
+    )
+
+    assert s.is_valid(raise_exception=True) is True
+    s.save()
+
+    assert affected[0].__class__.objects.filter(status="imported").count() == 5
+    for ir in affected:
+        ir.refresh_from_db()
+        assert ir.status == "closed"
+
+
+def test_manage_import_request_mark_imported(factories, now):
+    affected = factories["requests.ImportRequest"].create_batch(
+        size=5, status="pending"
+    )
+    # we do not update closed requests
+    factories["requests.ImportRequest"].create_batch(size=5, status="closed")
+    s = serializers.ManageImportRequestActionSerializer(
+        queryset=affected[0].__class__.objects.all(),
+        data={"objects": "all", "action": "mark_imported"},
+    )
+
+    assert s.is_valid(raise_exception=True) is True
+    s.save()
+
+    assert affected[0].__class__.objects.filter(status="closed").count() == 5
+    for ir in affected:
+        ir.refresh_from_db()
+        assert ir.status == "imported"
+        assert ir.imported_date == now
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index e2bfbf3a81511dbf313cd630e1d1353840e38b83..baf816fc860ba7d2dbc3b1f6dbdfdc8d2187b541 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -5,7 +5,13 @@ from funkwhale_api.manage import serializers, views
 
 
 @pytest.mark.parametrize(
-    "view,permissions,operator", [(views.ManageTrackFileViewSet, ["library"], "and")]
+    "view,permissions,operator",
+    [
+        (views.ManageTrackFileViewSet, ["library"], "and"),
+        (views.ManageUserViewSet, ["settings"], "and"),
+        (views.ManageInvitationViewSet, ["settings"], "and"),
+        (views.ManageImportRequestViewSet, ["library"], "and"),
+    ],
 )
 def test_permissions(assert_user_permission, view, permissions, operator):
     assert_user_permission(view, permissions, operator)
@@ -23,3 +29,50 @@ def test_track_file_view(factories, superuser_api_client):
 
     assert response.data["count"] == len(tfs)
     assert response.data["results"] == expected
+
+
+def test_user_view(factories, superuser_api_client, mocker):
+    mocker.patch("funkwhale_api.users.models.User.record_activity")
+    users = factories["users.User"].create_batch(size=5) + [superuser_api_client.user]
+    qs = users[0].__class__.objects.order_by("-id")
+    url = reverse("api:v1:manage:users:users-list")
+
+    response = superuser_api_client.get(url, {"sort": "-id"})
+    expected = serializers.ManageUserSerializer(
+        qs, many=True, context={"request": response.wsgi_request}
+    ).data
+
+    assert response.data["count"] == len(users)
+    assert response.data["results"] == expected
+
+
+def test_invitation_view(factories, superuser_api_client, mocker):
+    invitations = factories["users.Invitation"].create_batch(size=5)
+    qs = invitations[0].__class__.objects.order_by("-id")
+    url = reverse("api:v1:manage:users:invitations-list")
+
+    response = superuser_api_client.get(url, {"sort": "-id"})
+    expected = serializers.ManageInvitationSerializer(qs, many=True).data
+
+    assert response.data["count"] == len(invitations)
+    assert response.data["results"] == expected
+
+
+def test_invitation_view_create(factories, superuser_api_client, mocker):
+    url = reverse("api:v1:manage:users:invitations-list")
+    response = superuser_api_client.post(url)
+
+    assert response.status_code == 201
+    assert superuser_api_client.user.invitations.latest("id") is not None
+
+
+def test_music_requests_view(factories, superuser_api_client, mocker):
+    invitations = factories["requests.ImportRequest"].create_batch(size=5)
+    qs = invitations[0].__class__.objects.order_by("-id")
+    url = reverse("api:v1:manage:requests:import-requests-list")
+
+    response = superuser_api_client.get(url, {"sort": "-id"})
+    expected = serializers.ManageImportRequestSerializer(qs, many=True).data
+
+    assert response.data["count"] == len(invitations)
+    assert response.data["results"] == expected
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index 71d605b2b3768ab7b5dafa73738c0d4fb5af8c2d..e91594d4727146c45f7cc36123c406e4524a8f5d 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -118,7 +118,7 @@ def test_run_import_skipping_accoustid(factories, mocker):
     path = os.path.join(DATA_DIR, "test.ogg")
     job = factories["music.FileImportJob"](audio_file__path=path)
     tasks.import_job_run(import_job_id=job.pk, use_acoustid=False)
-    m.assert_called_once_with(job, False, use_acoustid=False)
+    m.assert_called_once_with(job, use_acoustid=False)
 
 
 def test__do_import_skipping_accoustid(factories, mocker):
@@ -130,7 +130,7 @@ def test__do_import_skipping_accoustid(factories, mocker):
     path = os.path.join(DATA_DIR, "test.ogg")
     job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
     p = job.audio_file.path
-    tasks._do_import(job, replace=False, use_acoustid=False)
+    tasks._do_import(job, use_acoustid=False)
     m.assert_called_once_with(p)
 
 
@@ -144,10 +144,27 @@ def test__do_import_skipping_accoustid_if_no_key(factories, mocker, preferences)
     path = os.path.join(DATA_DIR, "test.ogg")
     job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
     p = job.audio_file.path
-    tasks._do_import(job, replace=False, use_acoustid=False)
+    tasks._do_import(job, use_acoustid=False)
     m.assert_called_once_with(p)
 
 
+def test__do_import_replace_if_duplicate(factories, mocker):
+    existing_file = factories["music.TrackFile"]()
+    existing_track = existing_file.track
+    path = os.path.join(DATA_DIR, "test.ogg")
+    mocker.patch(
+        "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path",
+        return_value=existing_track,
+    )
+    job = factories["music.FileImportJob"](
+        replace_if_duplicate=True, audio_file__path=path
+    )
+    tasks._do_import(job)
+    with pytest.raises(existing_file.__class__.DoesNotExist):
+        existing_file.refresh_from_db()
+    assert existing_file.creation_date != job.track_file.creation_date
+
+
 def test_import_job_skip_if_already_exists(artists, albums, tracks, factories, mocker):
     path = os.path.join(DATA_DIR, "test.ogg")
     mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py
index 67f6c489d9304c13d1721a529ebc870eb35dca98..43e596ff7c14a27b6b2db69f1ab7c7206632355c 100644
--- a/api/tests/test_import_audio_file.py
+++ b/api/tests/test_import_audio_file.py
@@ -6,6 +6,7 @@ from django.core.management import call_command
 from django.core.management.base import CommandError
 
 from funkwhale_api.providers.audiofile import tasks
+from funkwhale_api.music.models import ImportJob
 
 DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files")
 
@@ -103,6 +104,31 @@ def test_in_place_import_only_from_music_dir(factories, settings):
         )
 
 
+def test_import_with_multiple_argument(factories, mocker):
+    factories["users.User"](username="me")
+    path1 = os.path.join(DATA_DIR, "dummy_file.ogg")
+    path2 = os.path.join(DATA_DIR, "utf8-éà◌.ogg")
+    mocked_filter = mocker.patch(
+        "funkwhale_api.providers.audiofile.management.commands.import_files.Command.filter_matching",
+        return_value=({"new": [], "skipped": []}),
+    )
+    call_command("import_files", path1, path2, username="me", interactive=False)
+    mocked_filter.assert_called_once_with([path1, path2])
+
+
+def test_import_with_replace_flag(factories, mocker):
+    factories["users.User"](username="me")
+    path = os.path.join(DATA_DIR, "dummy_file.ogg")
+    mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run")
+    call_command("import_files", path, username="me", replace=True, interactive=False)
+    created_job = ImportJob.objects.latest("id")
+
+    assert created_job.replace_if_duplicate is True
+    mocked_job_run.assert_called_once_with(
+        import_job_id=created_job.id, use_acoustid=False
+    )
+
+
 def test_import_files_creates_a_batch_and_job(factories, mocker):
     m = mocker.patch("funkwhale_api.music.tasks.import_job_run")
     user = factories["users.User"](username="me")
diff --git a/api/tests/users/test_middleware.py b/api/tests/users/test_middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd13df4b31b7e8cefb1fa61347574b43fc6ab727
--- /dev/null
+++ b/api/tests/users/test_middleware.py
@@ -0,0 +1,18 @@
+from funkwhale_api.users import middleware
+
+
+def test_record_activity_middleware(factories, api_request, mocker):
+    m = middleware.RecordActivityMiddleware(lambda request: None)
+    user = factories["users.User"]()
+    record_activity = mocker.patch("funkwhale_api.users.models.User.record_activity")
+    request = api_request.get("/")
+    request.user = user
+    m(request)
+
+    record_activity.assert_called_once_with()
+
+
+def test_record_activity_middleware_no_user(api_request):
+    m = middleware.RecordActivityMiddleware(lambda request: None)
+    request = api_request.get("/")
+    m(request)
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index c73a4a1b1a4b4cd351278d7757fb2b0307e3b193..ea760cc6c6b5a49f39903f1d641bd3d664819348 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -1,3 +1,4 @@
+import datetime
 import pytest
 
 from funkwhale_api.users import models
@@ -78,3 +79,51 @@ def test_has_permissions_and(args, perms, expected, factories):
 def test_has_permissions_or(args, perms, expected, factories):
     user = factories["users.User"](**args)
     assert user.has_permissions(*perms, operator="or") is expected
+
+
+def test_record_activity(factories, now):
+    user = factories["users.User"]()
+    assert user.last_activity is None
+
+    user.record_activity()
+
+    assert user.last_activity == now
+
+
+def test_record_activity_does_nothing_if_already(factories, now, mocker):
+    user = factories["users.User"](last_activity=now)
+    save = mocker.patch("funkwhale_api.users.models.User.save")
+    user.record_activity()
+
+    save.assert_not_called()
+
+
+def test_invitation_generates_random_code_on_save(factories):
+    invitation = factories["users.Invitation"]()
+    assert len(invitation.code) >= 6
+
+
+def test_invitation_expires_after_delay(factories, settings):
+    delay = settings.USERS_INVITATION_EXPIRATION_DAYS
+    invitation = factories["users.Invitation"]()
+    assert invitation.expiration_date == (
+        invitation.creation_date + datetime.timedelta(days=delay)
+    )
+
+
+def test_can_filter_open_invitations(factories):
+    okay = factories["users.Invitation"]()
+    factories["users.Invitation"](expired=True)
+    factories["users.User"](invited=True)
+
+    assert models.Invitation.objects.count() == 3
+    assert list(models.Invitation.objects.open()) == [okay]
+
+
+def test_can_filter_closed_invitations(factories):
+    factories["users.Invitation"]()
+    expired = factories["users.Invitation"](expired=True)
+    used = factories["users.User"](invited=True).invitation
+
+    assert models.Invitation.objects.count() == 3
+    assert list(models.Invitation.objects.open(False)) == [expired, used]
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 00272c2aea76026c3df2b125c39fdee852acf1ec..fca66d302efc499cb7ef7cf0c2d8124ec7b5668b 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -50,6 +50,39 @@ def test_can_disable_registration_view(preferences, api_client, db):
     assert response.status_code == 403
 
 
+def test_can_signup_with_invitation(preferences, factories, api_client):
+    url = reverse("rest_register")
+    invitation = factories["users.Invitation"](code="Hello")
+    data = {
+        "username": "test1",
+        "email": "test1@test.com",
+        "password1": "testtest",
+        "password2": "testtest",
+        "invitation": "hello",
+    }
+    preferences["users__registration_enabled"] = False
+    response = api_client.post(url, data)
+    assert response.status_code == 201
+    u = User.objects.get(email="test1@test.com")
+    assert u.username == "test1"
+    assert u.invitation == invitation
+
+
+def test_can_signup_with_invitation_invalid(preferences, factories, api_client):
+    url = reverse("rest_register")
+    factories["users.Invitation"](code="hello")
+    data = {
+        "username": "test1",
+        "email": "test1@test.com",
+        "password1": "testtest",
+        "password2": "testtest",
+        "invitation": "nope",
+    }
+    response = api_client.post(url, data)
+    assert response.status_code == 400
+    assert "invitation" in response.data
+
+
 def test_can_fetch_data_from_api(api_client, factories):
     url = reverse("api:v1:users:users-me")
     response = api_client.get(url)
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index b403f4388f9bf69399ed3479715c778fc1b837e6..c8f64cc384eb14cb4fb15dd4d0a1c42c716fb8b2 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -79,6 +79,14 @@ server {
         alias /srv/funkwhale/data/media/;
     }
 
+    location /_protected/media {
+        # this is an internal location that is used to serve
+        # audio files once correct permission / authentication
+        # has been checked on API side
+        internal;
+        alias   /srv/funkwhale/data/media;
+    }
+
     location /_protected/music {
         # this is an internal location that is used to serve
         # audio files once correct permission / authentication
diff --git a/docs/importing-music.rst b/docs/importing-music.rst
index b190dff368f1fae04b6a14860aaf799cb4673242..b40eb7b88843b39eff40d2cad7d2cf6bbe91510c 100644
--- a/docs/importing-music.rst
+++ b/docs/importing-music.rst
@@ -76,6 +76,39 @@ configuration options to ensure the webserver can serve them properly:
 
     Thus, be especially careful when you manipulate the source files.
 
+We recommend you symlink all your music directories into ``/srv/funkwhale/data/music``
+and run the `import_files` command from that directory. This will make it possible
+to use multiple music music directories, without any additional configuration
+on the webserver side.
+
+For instance, if you have a NFS share with your music mounted at ``/media/mynfsshare``,
+you can create a symlink like this::
+
+    ln -s /media/mynfsshare /srv/funkwhale/data/music/nfsshare
+
+And import music from this share with this command::
+
+    python api/manage.py import_files "/srv/funkwhale/data/music/nfsshare/**/*.ogg" --recursive --noinput --in-place
+
+On docker setups, it will require a bit more work, because while the ``/srv/funkwhale/data/music`` is mounted
+in containers, symlinked directories are not. To fix that, in your ``docker-compose.yml`` file, ensure each symlinked
+directory is mounted as a volume as well::
+
+    celeryworker:
+      volumes:
+      - ./data/music:/music:ro
+      - ./data/media:/app/funkwhale_api/media
+      # add your symlinked dirs here
+      - /media/nfsshare:/media/nfsshare:ro
+
+    api:
+      volumes:
+      - ./data/music:/music:ro
+      - ./data/media:/app/funkwhale_api/media
+      # add your symlinked dirs here
+      - /media/nfsshare:/media/nfsshare:ro
+
+
 Album covers
 ^^^^^^^^^^^^
 
diff --git a/docs/installation/index.rst b/docs/installation/index.rst
index 034f8e9ba30d9f3b22c19ba0fd475c80f5acac2e..83c47a101e5e7c37a7d89871c24ab985e61796ad 100644
--- a/docs/installation/index.rst
+++ b/docs/installation/index.rst
@@ -67,6 +67,11 @@ We also maintain an installation guide for Debian 9.
    systemd
 
 
+Funkwhale packages are available for the following platforms:
+
+- `YunoHost 3 <https://yunohost.org/>`_: https://github.com/YunoHost-Apps/funkwhale_ynh (kindly maintained by `@Jibec <https://github.com/Jibec>`_)
+
+
 .. _frontend-setup:
 
 Frontend setup
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
index 1b092d74706995c335e73aa2d4346c24ec0e4300..85a2b50577efbc5fc49659c0bb31ad870aecd656 100644
--- a/docs/upgrading.rst
+++ b/docs/upgrading.rst
@@ -64,9 +64,9 @@ The following example assume your setup match :ref:`frontend-setup`.
     # this assumes you want to upgrade to version "|version|"
     export FUNKWHALE_VERSION="|version|"
     cd /srv/funkwhale
-    curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
-    unzip -o front.zip
-    rm front.zip
+    sudo -u funkwhale curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
+    sudo -u funkwhale unzip -o front.zip
+    sudo -u funkwhale rm front.zip
 
 Upgrading the API
 ^^^^^^^^^^^^^^^^^
@@ -76,33 +76,33 @@ match what is described in :doc:`debian`:
 
 .. parsed-literal::
 
-    # stop the services
-    sudo systemctl stop funkwhale.target
-
     # this assumes you want to upgrade to version "|version|"
-    export FUNKWALE_VERSION="|version|"
+    export FUNKWHALE_VERSION="|version|"
     cd /srv/funkwhale
 
     # download more recent API files
-    curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWALE_VERSION/download?job=build_api"
-    unzip "api-$FUNKWALE_VERSION.zip" -d extracted
-    rm -rf api/ && mv extracted/api .
-    rm -rf extracted
+    sudo -u funkwhale curl -L -o "api-$FUNKWHALE_VERSION.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWHALE_VERSION/download?job=build_api"
+    sudo -u funkwhale unzip "api-$FUNKWHALE_VERSION.zip" -d extracted
+    sudo -u funkwhale rm -rf api/ && mv extracted/api .
+    sudo -u funkwhale rm -rf extracted
 
     # update os dependencies
     sudo api/install_os_dependencies.sh install
     # update python dependencies
     source /srv/funkwhale/load_env
-    source /srv/funkwhale/virtualenv/bin/activate
-    pip install -r api/requirements.txt
+    sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/pip install -r api/requirements.txt
 
-    # apply database migrations
-    python api/manage.py migrate
     # collect static files
-    python api/manage.py collectstatic --no-input
+    sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/python api/manage.py collectstatic --no-input
+
+    # stop the services
+    sudo systemctl stop funkwhale.target
+
+    # apply database migrations
+    sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/python api/manage.py migrate
 
     # restart the services
-    sudo systemctl restart funkwhale.target
+    sudo systemctl start funkwhale.target
 
 .. warning::
 
diff --git a/front/config/index.js b/front/config/index.js
index f4996f0203dd0d5b8f28820058c7e3c247c7474e..d10f35e9146258cc4b2271ca2270bd6ae09b4c98 100644
--- a/front/config/index.js
+++ b/front/config/index.js
@@ -8,7 +8,7 @@ module.exports = {
     assetsRoot: path.resolve(__dirname, '../dist'),
     assetsSubDirectory: 'static',
     assetsPublicPath: '/',
-    productionSourceMap: true,
+    productionSourceMap: false,
     // Gzip off by default as many popular static hosts such as
     // Surge or Netlify already gzip all static assets for you.
     // Before setting to `true`, make sure to:
diff --git a/front/config/prod.env.js b/front/config/prod.env.js
index decfe36154adc59fbf4a432cecac77119bbcdbf7..40cf48973416fbe1cfaa181e54821b51730f5398 100644
--- a/front/config/prod.env.js
+++ b/front/config/prod.env.js
@@ -1,4 +1,5 @@
+let url = process.env.INSTANCE_URL || '/'
 module.exports = {
   NODE_ENV: '"production"',
-  BACKEND_URL: '"/"'
+  INSTANCE_URL: `"${url}"`
 }
diff --git a/front/src/App.vue b/front/src/App.vue
index 2eb673ab4bf1800920111616091aa80cffba1c08..56dbe0aad41f7af3d382202832ab6828bb431837 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -1,44 +1,71 @@
 <template>
   <div id="app">
-    <sidebar></sidebar>
-    <service-messages v-if="messages.length > 0" />
-    <router-view :key="$route.fullPath"></router-view>
-    <div class="ui fitted divider"></div>
-    <div id="footer" class="ui vertical footer segment">
-      <div class="ui container">
-        <div class="ui stackable equal height stackable grid">
-          <div class="three wide column">
-            <i18next tag="h4" class="ui header" path="Links"></i18next>
-            <div class="ui link list">
-              <router-link class="item" to="/about">
-                <i18next path="About this instance" />
-              </router-link>
-              <a href="https://funkwhale.audio" class="item" target="_blank">{{ $t('Official website') }}</a>
-              <a href="https://docs.funkwhale.audio" class="item" target="_blank">{{ $t('Documentation') }}</a>
-              <a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">
-                <template v-if="version">{{ $t('Source code ({% version %})', {version: version}) }}</template>
-                <template v-else>{{ $t('Source code') }}</template>
-              </a>
-              <a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">{{ $t('Issue tracker') }}</a>
+    <div class="ui main text container instance-chooser" v-if="!$store.state.instance.instanceUrl">
+      <div class="ui padded segment">
+        <h1 class="ui header">{{ $t('Choose your instance') }}</h1>
+        <form class="ui form" @submit.prevent="$store.dispatch('instance/setUrl', instanceUrl)">
+          <p>{{ $t('You need to select an instance in order to continue') }}</p>
+          <div class="ui action input">
+            <input type="text" v-model="instanceUrl">
+            <button type="submit" class="ui button">{{ $t('Submit') }}</button>
+          </div>
+          <p>{{ $t('Suggested choices') }}</p>
+          <div class="ui bulleted list">
+            <div class="ui item" v-for="url in suggestedInstances">
+              <a @click="instanceUrl = url">{{ url }}</a>
             </div>
           </div>
-          <div class="ten wide column">
-            <i18next tag="h4" class="ui header" path="About funkwhale" />
-            <p>
-              <i18next path="Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!"/>
-            </p>
-            <p>
-              <i18next path="The funkwhale logo was kindly designed and provided by Francis Gading."/>
-            </p>
+        </form>
+      </div>
+    </div>
+    <template v-else>
+      <sidebar></sidebar>
+      <service-messages v-if="messages.length > 0" />
+      <router-view :key="$route.fullPath"></router-view>
+      <div class="ui fitted divider"></div>
+      <div id="footer" class="ui vertical footer segment">
+        <div class="ui container">
+          <div class="ui stackable equal height stackable grid">
+            <div class="three wide column">
+              <i18next tag="h4" class="ui header" path="Links"></i18next>
+              <div class="ui link list">
+                <router-link class="item" to="/about">
+                  <i18next path="About this instance" />
+                </router-link>
+                <a href="https://funkwhale.audio" class="item" target="_blank">{{ $t('Official website') }}</a>
+                <a href="https://docs.funkwhale.audio" class="item" target="_blank">{{ $t('Documentation') }}</a>
+                <a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">
+                  <template v-if="version">{{ $t('Source code ({% version %})', {version: version}) }}</template>
+                  <template v-else>{{ $t('Source code') }}</template>
+                </a>
+                <a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">{{ $t('Issue tracker') }}</a>
+                <a @click="switchInstance" class="item" >
+                  {{ $t('Use another instance') }}
+                  <template v-if="$store.state.instance.instanceUrl !== '/'">
+                    <br>
+                    ({{ $store.state.instance.instanceUrl }})
+                  </template>
+                </a>
+              </div>
+            </div>
+            <div class="ten wide column">
+              <i18next tag="h4" class="ui header" path="About funkwhale" />
+              <p>
+                <i18next path="Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!"/>
+              </p>
+              <p>
+                <i18next path="The funkwhale logo was kindly designed and provided by Francis Gading."/>
+              </p>
+            </div>
           </div>
         </div>
       </div>
-    </div>
-    <raven
-      v-if="$store.state.instance.settings.raven.front_enabled.value"
-      :dsn="$store.state.instance.settings.raven.front_dsn.value">
-    </raven>
-    <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
+      <raven
+        v-if="$store.state.instance.settings.raven.front_enabled.value"
+        :dsn="$store.state.instance.settings.raven.front_dsn.value">
+      </raven>
+      <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
+    </template>
   </div>
 </template>
 
@@ -63,17 +90,22 @@ export default {
   },
   data () {
     return {
-      nodeinfo: null
+      nodeinfo: null,
+      instanceUrl: null
     }
   },
   created () {
-    this.$store.dispatch('instance/fetchSettings')
     let self = this
     setInterval(() => {
       // used to redraw ago dates every minute
       self.$store.commit('ui/computeLastDate')
     }, 1000 * 60)
-    this.fetchNodeInfo()
+    if (this.$store.state.instance.instanceUrl) {
+      this.$store.commit('instance/instanceUrl', this.$store.state.instance.instanceUrl)
+      this.$store.dispatch('auth/check')
+      this.$store.dispatch('instance/fetchSettings')
+      this.fetchNodeInfo()
+    }
   },
   methods: {
     fetchNodeInfo () {
@@ -81,18 +113,38 @@ export default {
       axios.get('instance/nodeinfo/2.0/').then(response => {
         self.nodeinfo = response.data
       })
+    },
+    switchInstance () {
+      let confirm = window.confirm(this.$t('This will erase your local data and disconnect you, do you want to continue?'))
+      if (confirm) {
+        this.$store.commit('instance/instanceUrl', null)
+      }
     }
   },
   computed: {
     ...mapState({
       messages: state => state.ui.messages
     }),
+    suggestedInstances () {
+      let rootUrl = (
+        window.location.protocol + '//' + window.location.hostname +
+        (window.location.port ? ':' + window.location.port : '')
+      )
+      let instances = [rootUrl, 'https://demo.funkwhale.audio']
+      return instances
+    },
     version () {
       if (!this.nodeinfo) {
         return null
       }
       return _.get(this.nodeinfo, 'software.version')
     }
+  },
+  watch: {
+    '$store.state.instance.instanceUrl' () {
+      this.$store.dispatch('instance/fetchSettings')
+      this.fetchNodeInfo()
+    }
   }
 }
 </script>
@@ -116,6 +168,11 @@ html, body {
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
 }
+
+.instance-chooser {
+  margin-top: 2em;
+}
+
 .main.pusher, .footer {
   @include media(">desktop") {
     margin-left: 350px !important;
@@ -173,7 +230,7 @@ html, body {
 .ui.icon.header .circular.icon {
   display: flex;
   justify-content: center;
-  
+
 }
 
 .segment-content .button{
diff --git a/front/src/audio/backend.js b/front/src/audio/backend.js
index 619f3cefdbd7b08f9879be343ce246e41e86de0d..5a82719a3a0d403ce6ee264eed47e2f5926068f0 100644
--- a/front/src/audio/backend.js
+++ b/front/src/audio/backend.js
@@ -1,5 +1,3 @@
-import config from '@/config'
-
 var Album = {
   clean (album) {
     // we manually rebind the album and artist to each child track
@@ -21,21 +19,6 @@ var Artist = {
   }
 }
 export default {
-  absoluteUrl (url) {
-    if (url.startsWith('http')) {
-      return url
-    }
-    if (url.startsWith('/')) {
-      let rootUrl = (
-        window.location.protocol + '//' + window.location.hostname +
-        (window.location.port ? ':' + window.location.port : '')
-      )
-      return rootUrl + url
-    } else {
-      return config.BACKEND_URL + url
-    }
-  },
   Artist: Artist,
   Album: Album
-
 }
diff --git a/front/src/audio/track.js b/front/src/audio/track.js
deleted file mode 100644
index 9873b74ec5405bd941999df63e6071a3ac8056d3..0000000000000000000000000000000000000000
--- a/front/src/audio/track.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import backend from './backend'
-
-export default {
-  getCover (track) {
-    return backend.absoluteUrl(track.album.cover)
-  }
-}
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index d46fb846cf1d76fd23265b18f1fd34a6e361dfe7..9eec6c0e2721f50ba9209474cabddf028ca4c163 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -58,21 +58,16 @@
         <div class="item" v-if="showAdmin">
           <div class="header">{{ $t('Administration') }}</div>
           <div class="menu">
-            <router-link
-              class="item"
-              v-if="$store.state.auth.availablePermissions['library']"
-              :to="{name: 'library.requests', query: {status: 'pending' }}">
-              <i class="download icon"></i>{{ $t('Import requests') }}
-              <div
-                :class="['ui', {'teal': notifications.importRequests > 0}, 'label']"
-                :title="$t('Pending import requests')">
-                {{ notifications.importRequests }}</div>
-            </router-link>
             <router-link
               class="item"
               v-if="$store.state.auth.availablePermissions['library']"
               :to="{name: 'manage.library.files'}">
               <i class="book icon"></i>{{ $t('Library') }}
+              <div
+                :class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']"
+                :title="$t('Pending import requests')">
+                {{ $store.state.ui.notifications.importRequests }}</div>
+
             </router-link>
             <router-link
               class="item"
@@ -86,9 +81,9 @@
               :to="{path: '/manage/federation/libraries'}">
               <i class="sitemap icon"></i>{{ $t('Federation') }}
               <div
-                :class="['ui', {'teal': notifications.federation > 0}, 'label']"
+                :class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']"
                 :title="$t('Pending follow requests')">
-                {{ notifications.federation }}</div>
+                {{ $store.state.ui.notifications.federation }}</div>
             </router-link>
             <router-link
               class="item"
@@ -96,6 +91,12 @@
               :to="{path: '/manage/settings'}">
               <i class="settings icon"></i>{{ $t('Settings') }}
             </router-link>
+            <router-link
+              class="item"
+              v-if="$store.state.auth.availablePermissions['settings']"
+              :to="{name: 'manage.users.users.list'}">
+              <i class="users icon"></i>{{ $t('Users') }}
+            </router-link>
           </div>
         </div>
       </div>
@@ -115,11 +116,11 @@
     </div>
     <div class="ui bottom attached tab" data-tab="queue">
       <table class="ui compact inverted very basic fixed single line unstackable table">
-        <draggable v-model="queue.tracks" element="tbody" @update="reorder">
-          <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
+        <draggable v-model="tracks" element="tbody" @update="reorder">
+          <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
               <td class="right aligned">{{ index + 1}}</td>
               <td class="center aligned">
-                  <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
+                  <img class="ui mini image" v-if="track.album.cover" :src="$store.getters['instance/absoluteUrl'](track.album.cover)">
                   <img class="ui mini image" v-else src="../assets/audio/default-cover.png">
               </td>
               <td colspan="4">
@@ -154,7 +155,6 @@
 
 <script>
 import {mapState, mapActions} from 'vuex'
-import axios from 'axios'
 
 import Player from '@/components/audio/Player'
 import Logo from '@/components/Logo'
@@ -176,12 +176,9 @@ export default {
     return {
       selectedTab: 'library',
       backend: backend,
+      tracksChangeBuffer: null,
       isCollapsed: true,
-      fetchInterval: null,
-      notifications: {
-        federation: 0,
-        importRequests: 0
-      }
+      fetchInterval: null
     }
   },
   mounted () {
@@ -211,6 +208,14 @@ export default {
       return adminPermissions.filter(e => {
         return e
       }).length > 0
+    },
+    tracks: {
+      get () {
+        return this.$store.state.queue.tracks
+      },
+      set (value) {
+        this.tracksChangeBuffer = value
+      }
     }
   },
   methods: {
@@ -218,30 +223,12 @@ export default {
       cleanTrack: 'queue/cleanTrack'
     }),
     fetchNotificationsCount () {
-      this.fetchFederationNotificationsCount()
-      this.fetchFederationImportRequestsCount()
-    },
-    fetchFederationNotificationsCount () {
-      if (!this.$store.state.auth.availablePermissions['federation']) {
-        return
-      }
-      let self = this
-      axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
-        self.notifications.federation = response.data.count
-      })
-    },
-    fetchFederationImportRequestsCount () {
-      if (!this.$store.state.auth.availablePermissions['library']) {
-        return
-      }
-      let self = this
-      axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
-        self.notifications.importRequests = response.data.count
-      })
+      this.$store.dispatch('ui/fetchFederationNotificationsCount')
+      this.$store.dispatch('ui/fetchImportRequestsCount')
     },
     reorder: function (event) {
       this.$store.commit('queue/reorder', {
-        oldIndex: event.oldIndex, newIndex: event.newIndex})
+        tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex})
     },
     scrollToCurrent () {
       let current = $(this.$el).find('[data-tab="queue"] .active')[0]
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 3c922e14ad323d75413d969ba7c033add19e92e0..8eecb232f67b770be9f28477c4a3380f56a3607d 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -1,20 +1,20 @@
 <template>
   <div class="ui inverted segment player-wrapper" :style="style">
     <div class="player">
-      <audio-track
-        ref="currentAudio"
-        v-if="renderAudio && currentTrack"
-        :key="currentTrack.id"
-        :is-current="true"
-        :start-time="$store.state.player.currentTime"
-        :autoplay="$store.state.player.playing"
-        :track="currentTrack">
-      </audio-track>
-
+      <keep-alive>
+        <audio-track
+          ref="currentAudio"
+          v-if="renderAudio && currentTrack"
+          :is-current="true"
+          :start-time="$store.state.player.currentTime"
+          :autoplay="$store.state.player.playing"
+          :track="currentTrack">
+        </audio-track>
+      </keep-alive>
       <div v-if="currentTrack" class="track-area ui unstackable items">
         <div class="ui inverted item">
           <div class="ui tiny image">
-            <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)">
+            <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover)">
             <img v-else src="../../assets/audio/default-cover.png">
           </div>
           <div class="middle aligned content">
@@ -143,7 +143,6 @@ import {mapState, mapGetters, mapActions} from 'vuex'
 import GlobalEvents from '@/components/utils/global-events'
 import ColorThief from '@/vendor/color-thief'
 
-import Track from '@/audio/track'
 import AudioTrack from '@/components/audio/Track'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
 import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
@@ -162,7 +161,6 @@ export default {
       isShuffling: false,
       renderAudio: true,
       sliderVolume: this.volume,
-      Track: Track,
       defaultAmbiantColors: defaultAmbiantColors,
       ambiantColors: defaultAmbiantColors
     }
diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue
index 99896d04beda7bc51a559fb10b8205a45e254610..d5cb39e3232fd83c1c807d457fecf78249e7951d 100644
--- a/front/src/components/audio/SearchBar.vue
+++ b/front/src/components/audio/SearchBar.vue
@@ -11,11 +11,8 @@
 
 <script>
 import jQuery from 'jquery'
-import config from '@/config'
 import router from '@/router'
 
-const SEARCH_URL = config.API_URL + 'search?query={query}'
-
 export default {
   mounted () {
     let self = this
@@ -94,7 +91,7 @@ export default {
           })
           return {results: results}
         },
-        url: SEARCH_URL
+        url: this.$store.getters['instance/absoluteUrl']('api/v1/search?query={query}')
       }
     })
   }
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
index 366f104f1fc021fbe5478346439e94034b345e2a..9be38337717e8d024e3f711e019a960762d7c355 100644
--- a/front/src/components/audio/Track.vue
+++ b/front/src/components/audio/Track.vue
@@ -4,7 +4,7 @@
     @error="errored"
     @loadeddata="loaded"
     @durationchange="updateDuration"
-    @timeupdate="updateProgress"
+    @timeupdate="updateProgressThrottled"
     @ended="ended"
     preload>
     <source
@@ -30,6 +30,7 @@ export default {
   },
   data () {
     return {
+      realTrack: this.track,
       sourceErrors: 0,
       isUpdatingTime: false
     }
@@ -43,13 +44,13 @@ export default {
       looping: state => state.player.looping
     }),
     srcs: function () {
-      let file = this.track.files[0]
+      let file = this.realTrack.files[0]
       if (!file) {
         this.$store.dispatch('player/trackErrored')
         return []
       }
       let sources = [
-        {type: file.mimetype, url: file.path}
+        {type: file.mimetype, url: this.$store.getters['instance/absoluteUrl'](file.path)}
       ]
       if (this.$store.state.auth.authenticated) {
         // we need to send the token directly in url
@@ -61,6 +62,9 @@ export default {
         })
       }
       return sources
+    },
+    updateProgressThrottled () {
+      return _.throttle(this.updateProgress, 250)
     }
   },
   methods: {
@@ -100,30 +104,40 @@ export default {
         }
       }
     },
-    updateProgress: _.throttle(function () {
+    updateProgress: function () {
       this.isUpdatingTime = true
       if (this.$refs.audio) {
         this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime)
       }
-    }, 250),
+    },
     ended: function () {
       let onlyTrack = this.$store.state.queue.tracks.length === 1
       if (this.looping === 1 || (onlyTrack && this.looping === 2)) {
         this.setCurrentTime(0)
         this.$refs.audio.play()
       } else {
-        this.$store.dispatch('player/trackEnded', this.track)
+        this.$store.dispatch('player/trackEnded', this.realTrack)
       }
     },
     setCurrentTime (t) {
       if (t < 0 | t > this.duration) {
         return
       }
-      this.updateProgress(t)
+      if (t === this.$refs.audio.currentTime) {
+        return
+      }
+      if (t === 0) {
+        this.updateProgressThrottled.cancel()
+      }
       this.$refs.audio.currentTime = t
     }
   },
   watch: {
+    track: _.debounce(function (newValue) {
+      this.realTrack = newValue
+      this.setCurrentTime(0)
+      this.$refs.audio.load()
+    }, 1000, {leading: true, trailing: true}),
     playing: function (newValue) {
       if (newValue === true) {
         this.$refs.audio.play()
@@ -131,6 +145,11 @@ export default {
         this.$refs.audio.pause()
       }
     },
+    '$store.state.queue.currentIndex' () {
+      if (this.$store.state.player.playing) {
+        this.$refs.audio.play()
+      }
+    },
     volume: function (newValue) {
       this.$refs.audio.volume = newValue
     },
diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue
index 6742dca4f9b94e88983ad6ec6d4396b11dc14648..3782771803edce6432a125957e9d10346b073fbd 100644
--- a/front/src/components/audio/album/Card.vue
+++ b/front/src/components/audio/album/Card.vue
@@ -2,7 +2,7 @@
     <div class="ui card">
       <div class="content">
         <div class="right floated tiny ui image">
-          <img v-if="album.cover" v-lazy="backend.absoluteUrl(album.cover)">
+          <img v-if="album.cover" v-lazy="$store.getters['instance/absoluteUrl'](album.cover)">
           <img v-else src="../../../assets/audio/default-cover.png">
         </div>
         <div class="header">
diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue
index a46506791e083eb5cc750c8f84918a6bc1fb2318..b19c5e12d727b6a32c6a57c04e9dab4b0b9c3134 100644
--- a/front/src/components/audio/artist/Card.vue
+++ b/front/src/components/audio/artist/Card.vue
@@ -11,7 +11,7 @@
             <tbody>
               <tr v-for="album in albums">
                 <td>
-                  <img class="ui mini image" v-if="album.cover" :src="backend.absoluteUrl(album.cover)">
+                  <img class="ui mini image" v-if="album.cover" :src="$store.getters['instance/absoluteUrl'](album.cover)">
                   <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
                 </td>
                 <td colspan="4">
diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue
index 8310e89c4a4ad6ab749f2386003f1c59ae7dccea..bd3ceb2aaa869350fa013ca36c709f16cc97cdbc 100644
--- a/front/src/components/audio/track/Row.vue
+++ b/front/src/components/audio/track/Row.vue
@@ -4,7 +4,7 @@
       <play-button class="basic icon" :discrete="true" :track="track"></play-button>
     </td>
     <td>
-      <img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
+      <img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)">
       <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
     </td>
     <td colspan="6">
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index 4559b3c41b59ccf798eec071b97c1ab55a81dceb..81869ff564af2f8b5ee5260535b7fda5f95f924e 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -35,7 +35,7 @@
                 <pre>
 export PRIVATE_TOKEN="{{ $store.state.auth.token }}"
 <template v-for="track in tracks"><template v-if="track.files.length > 0">
-curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template>
+curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ $store.getters['instance/absoluteUrl'](track.files[0].path) }}"</template></template>
 </pre>
               </div>
             </div>
diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue
index 89f4cb1f1266c956283142cfc4f470e3b0c5d031..e4e5cebbce950b7470204f710e06ffe01e7632e7 100644
--- a/front/src/components/auth/Signup.vue
+++ b/front/src/components/auth/Signup.vue
@@ -2,19 +2,22 @@
   <div class="main pusher" v-title="'Sign Up'">
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
-        <h2><i18next path="Create a funkwhale account"/></h2>
+        <h2>{{ $t("Create a funkwhale account") }}</h2>
         <form
-          v-if="$store.state.instance.settings.users.registration_enabled.value"
           :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
           @submit.prevent="submit()">
+          <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value">
+            {{ $t('Registration are closed on this instance, you will need an invitation code to signup.') }}
+          </p>
+
           <div v-if="errors.length > 0" class="ui negative message">
-            <div class="header"><i18next path="We cannot create your account"/></div>
+            <div class="header">{{ $t("We cannot create your account") }}</div>
             <ul class="list">
               <li v-for="error in errors">{{ error }}</li>
             </ul>
           </div>
           <div class="field">
-            <i18next tag="label" path="Username"/>
+            <label>{{ $t("Username") }}</label>
             <input
             ref="username"
             required
@@ -24,7 +27,7 @@
             v-model="username">
           </div>
           <div class="field">
-            <i18next tag="label" path="Email"/>
+            <label>{{ $t("Email") }}</label>
             <input
             ref="email"
             required
@@ -33,12 +36,22 @@
             v-model="email">
           </div>
           <div class="field">
-            <i18next tag="label" path="Password"/>
+            <label>{{ $t("Password") }}</label>
             <password-input v-model="password" />
           </div>
-          <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button>
+          <div class="field">
+            <label v-if="!$store.state.instance.settings.users.registration_enabled.value">{{ $t("Invitation code") }}</label>
+            <label v-else>{{ $t("Invitation code (optional)") }}</label>
+            <input
+            :required="!$store.state.instance.settings.users.registration_enabled.value"
+            type="text"
+            :placeholder="$t('Enter your invitation code (case insensitive)')"
+            v-model="invitation">
+          </div>
+          <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit">
+            {{ $t("Create my account") }}
+          </button>
         </form>
-        <i18next v-else tag="p" path="Registration is currently disabled on this instance, please try again later."/>
       </div>
     </div>
   </div>
@@ -51,13 +64,13 @@ import logger from '@/logging'
 import PasswordInput from '@/components/forms/PasswordInput'
 
 export default {
-  name: 'login',
-  components: {
-    PasswordInput
-  },
   props: {
+    invitation: {type: String, required: false, default: null},
     next: {type: String, default: '/'}
   },
+  components: {
+    PasswordInput
+  },
   data () {
     return {
       username: '',
@@ -85,7 +98,8 @@ export default {
         username: this.username,
         password1: this.password,
         password2: this.password,
-        email: this.email
+        email: this.email,
+        invitation: this.invitation
       }
       return axios.post('auth/registration/', payload).then(response => {
         logger.default.info('Successfully created account')
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue
index 5221c328292c317c199850d6c9a3de58fec188f9..097fb29385eb495d00bc349d88f733c5d9ac5813 100644
--- a/front/src/components/common/ActionTable.vue
+++ b/front/src/components/common/ActionTable.vue
@@ -36,7 +36,7 @@
               <div class="count field">
                 <span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
                 <span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
-                <template v-if="!currentAction.isDangerous && checkable.length === checked.length">
+                <template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length">
                   <a @click="selectAll = true" v-if="!selectAll">
                     {{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
                   </a>
@@ -61,7 +61,7 @@
         </th>
       </tr>
       <tr>
-        <th>
+        <th v-if="actions.length > 0">
           <div class="ui checkbox">
             <input
               type="checkbox"
@@ -75,7 +75,7 @@
     </thead>
     <tbody v-if="objectsData.count > 0">
       <tr v-for="(obj, index) in objectsData.results">
-        <td class="collapsing">
+        <td v-if="actions.length > 0" class="collapsing">
           <input
             type="checkbox"
             :disabled="checkable.indexOf(obj.id) === -1"
@@ -157,6 +157,7 @@ export default {
       let self = this
       self.actionLoading = true
       self.result = null
+      self.actionErrors = []
       let payload = {
         action: this.currentActionName,
         filters: this.filters
@@ -184,6 +185,9 @@ export default {
       })[0]
     },
     checkable () {
+      if (!this.currentAction) {
+        return []
+      }
       let objs = this.objectsData.results
       let filter = this.currentAction.filterCheckable
       if (filter) {
diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue
index 1681d46e3b44466f022f18d22ffa3ba78e1643b7..9a4288b8ac46a8e584fa8dbf23bae3a96fa4e5cc 100644
--- a/front/src/components/library/Album.vue
+++ b/front/src/components/library/Album.vue
@@ -87,7 +87,7 @@ export default {
       if (!this.album.cover) {
         return ''
       }
-      return 'background-image: url(' + backend.absoluteUrl(this.album.cover) + ')'
+      return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover) + ')'
     }
   },
   watch: {
diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue
index 7d0a41d8988055316ecb4773103e129e970f92c7..171b80e8b7b1f9a2ad8c7a65e9dc3c227e6e1b5a 100644
--- a/front/src/components/library/Artist.vue
+++ b/front/src/components/library/Artist.vue
@@ -127,7 +127,7 @@ export default {
       if (!this.cover) {
         return ''
       }
-      return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
+      return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')'
     }
   },
   watch: {
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index 50337b2291776de826feedad054237957a4855b1..5360de16cd0d959a9dbda011dd5d408ac7f97115 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -6,13 +6,6 @@
       <router-link class="ui item" to="/library/radios" exact><i18next path="Radios"/></router-link>
       <router-link class="ui item" to="/library/playlists" exact><i18next path="Playlists"/></router-link>
       <div class="ui secondary right menu">
-        <router-link
-          v-if="$store.state.auth.authenticated"
-          class="ui item"
-          :to="{name: 'library.requests', query: {status: 'pending' }}"
-          exact>
-          <i18next path="Requests"/>
-        </router-link>
         <router-link v-if="showImports" class="ui item" to="/library/import/launch" exact>
           <i18next path="Import"/>
         </router-link>
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index 24acca75b809445ce711f9fb90e38a65f7c974dd..af364e94d6af39e4d459e1cbdc9ab105560d2f5f 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -108,7 +108,6 @@ import time from '@/utils/time'
 import axios from 'axios'
 import url from '@/utils/url'
 import logger from '@/logging'
-import backend from '@/audio/backend'
 import PlayButton from '@/components/audio/PlayButton'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
 import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
@@ -169,7 +168,7 @@ export default {
     },
     downloadUrl () {
       if (this.track.files.length > 0) {
-        let u = backend.absoluteUrl(this.track.files[0].path)
+        let u = this.$store.getters['instance/absoluteUrl'](this.track.files[0].path)
         if (this.$store.state.auth.authenticated) {
           u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token)
         }
@@ -191,7 +190,7 @@ export default {
       if (!this.cover) {
         return ''
       }
-      return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
+      return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')'
     }
   },
   watch: {
diff --git a/front/src/components/library/import/TrackImport.vue b/front/src/components/library/import/TrackImport.vue
index abb526eff0a8dbf69f81397d0136272637418429..7f07763bf3e260d69344efeef6bd2f4e5453453b 100644
--- a/front/src/components/library/import/TrackImport.vue
+++ b/front/src/components/library/import/TrackImport.vue
@@ -102,6 +102,7 @@ export default Vue.extend({
       importedUrl: '',
       warnings: [
         'live',
+        'tv',
         'full',
         'cover',
         'mix'
diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue
index b27c36077c113b801bf842f84ad868cfecc87542..0d268dc60faad8355650aed431ad4174d22ce464 100644
--- a/front/src/components/library/radios/Filter.vue
+++ b/front/src/components/library/radios/Filter.vue
@@ -63,7 +63,6 @@
 </template>
 <script>
 import axios from 'axios'
-import config from '@/config'
 import $ from 'jquery'
 import _ from 'lodash'
 
@@ -86,7 +85,7 @@ export default {
     return {
       checkResult: null,
       showCandidadesModal: false,
-      exclude: config.not
+      exclude: this.config.not
     }
   },
   mounted: function () {
diff --git a/front/src/components/manage/library/RequestsTable.vue b/front/src/components/manage/library/RequestsTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e51b911a762a46504f4783f376e63ac4180c4ce8
--- /dev/null
+++ b/front/src/components/manage/library/RequestsTable.vue
@@ -0,0 +1,229 @@
+<template>
+  <div>
+    <div class="ui inline form">
+      <div class="fields">
+        <div class="ui field">
+          <label>{{ $t('Search') }}</label>
+          <input type="text" v-model="search" placeholder="Search by artist, username, comment..." />
+        </div>
+        <div class="field">
+          <i18next tag="label" path="Ordering"/>
+          <select class="ui dropdown" v-model="ordering">
+            <option v-for="option in orderingOptions" :value="option[0]">
+              {{ option[1] }}
+            </option>
+          </select>
+        </div>
+        <div class="field">
+          <i18next tag="label" path="Ordering direction"/>
+          <select class="ui dropdown" v-model="orderingDirection">
+            <option value="+">Ascending</option>
+            <option value="-">Descending</option>
+          </select>
+        </div>
+        <div class="field">
+          <label>{{ $t("Status") }}</label>
+          <select class="ui dropdown" v-model="status">
+            <option :value="null">{{ $t('All') }}</option>
+            <option :value="'pending'">{{ $t('Pending') }}</option>
+            <option :value="'accepted'">{{ $t('Accepted') }}</option>
+            <option :value="'imported'">{{ $t('Imported') }}</option>
+            <option :value="'closed'">{{ $t('Closed') }}</option>
+          </select>
+        </div>
+      </div>
+      </div>
+    <div class="dimmable">
+      <div v-if="isLoading" class="ui active inverted dimmer">
+          <div class="ui loader"></div>
+      </div>
+      <action-table
+        v-if="result"
+        @action-launched="fetchData"
+        :objects-data="result"
+        :actions="actions"
+        :action-url="'manage/requests/import-requests/action/'"
+        :filters="actionFilters">
+        <template slot="header-cells">
+          <th>{{ $t('User') }}</th>
+          <th>{{ $t('Status') }}</th>
+          <th>{{ $t('Artist') }}</th>
+          <th>{{ $t('Albums') }}</th>
+          <th>{{ $t('Comment') }}</th>
+          <th>{{ $t('Creation date') }}</th>
+          <th>{{ $t('Import date') }}</th>
+          <th>{{ $t('Actions') }}</th>
+        </template>
+        <template slot="row-cells" slot-scope="scope">
+          <td>
+            {{ scope.obj.user.username }}
+          </td>
+          <td>
+            <span class="ui green basic label" v-if="scope.obj.status === 'imported'">{{ $t('Imported') }}</span>
+            <span class="ui pink basic label" v-else-if="scope.obj.status === 'accepted'">{{ $t('Accepted') }}</span>
+            <span class="ui yellow basic label" v-else-if="scope.obj.status === 'pending'">{{ $t('Pending') }}</span>
+            <span class="ui red basic label" v-else-if="scope.obj.status === 'closed'">{{ $t('Closed') }}</span>
+          </td>
+          <td>
+            <span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span>
+          </td>
+          <td>
+            <span v-if="scope.obj.albums" :title="scope.obj.albums">{{ scope.obj.albums|truncate(30) }}</span>
+            <template v-else>{{ $t('N/A') }}</template>
+          </td>
+          <td>
+            <span v-if="scope.obj.comment" :title="scope.obj.comment">{{ scope.obj.comment|truncate(30) }}</span>
+            <template v-else>{{ $t('N/A') }}</template>
+          </td>
+          <td>
+            <human-date :date="scope.obj.creation_date"></human-date>
+          </td>
+          <td>
+            <human-date v-if="scope.obj.imported_date" :date="scope.obj.creation_date"></human-date>
+            <template v-else>{{ $t('N/A') }}</template>
+          </td>
+          <td>
+            <router-link
+              class="ui tiny basic button"
+              :to="{name: 'library.import.launch', query: {request: scope.obj.id}}"
+              v-if="scope.obj.status === 'pending'">{{ $t('Create import') }}</router-link>
+          </td>
+        </template>
+      </action-table>
+    </div>
+    <div>
+      <pagination
+        v-if="result && result.results.length > 0"
+        @page-changed="selectPage"
+        :compact="true"
+        :current="page"
+        :paginate-by="paginateBy"
+        :total="result.count"
+        ></pagination>
+
+      <span v-if="result && result.results.length > 0">
+        {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import _ from 'lodash'
+import time from '@/utils/time'
+import Pagination from '@/components/Pagination'
+import ActionTable from '@/components/common/ActionTable'
+import OrderingMixin from '@/components/mixins/Ordering'
+
+export default {
+  mixins: [OrderingMixin],
+  props: {
+    filters: {type: Object, required: false}
+  },
+  components: {
+    Pagination,
+    ActionTable
+  },
+  data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
+    return {
+      time,
+      isLoading: false,
+      result: null,
+      page: 1,
+      paginateBy: 25,
+      search: '',
+      status: null,
+      orderingDirection: defaultOrdering.direction || '+',
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['creation_date', 'Creation date'],
+        ['imported_date', 'Imported date']
+      ]
+
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      let params = _.merge({
+        'page': this.page,
+        'page_size': this.paginateBy,
+        'q': this.search,
+        'status': this.status,
+        'ordering': this.getOrderingAsString()
+      }, this.filters)
+      let self = this
+      self.isLoading = true
+      self.checked = []
+      axios.get('/manage/requests/import-requests/', {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    selectPage: function (page) {
+      this.page = page
+    }
+  },
+  computed: {
+    actionFilters () {
+      var currentFilters = {
+        q: this.search
+      }
+      if (this.filters) {
+        return _.merge(currentFilters, this.filters)
+      } else {
+        return currentFilters
+      }
+    },
+    actions () {
+      return [
+        {
+          name: 'delete',
+          label: this.$t('Delete'),
+          isDangerous: true
+        },
+        {
+          name: 'mark_imported',
+          label: this.$t('Mark as imported'),
+          filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 },
+          isDangerous: true
+        },
+        {
+          name: 'mark_closed',
+          label: this.$t('Mark as closed'),
+          filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 },
+          isDangerous: true
+        }
+      ]
+    }
+  },
+  watch: {
+    search (newValue) {
+      this.page = 1
+      this.fetchData()
+    },
+    page () {
+      this.fetchData()
+    },
+    ordering () {
+      this.page = 1
+      this.fetchData()
+    },
+    status () {
+      this.page = 1
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.page = 1
+      this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d9f0969e67853c841cbb0a10ebd9735792723d97
--- /dev/null
+++ b/front/src/components/manage/users/InvitationForm.vue
@@ -0,0 +1,80 @@
+<template>
+  <div>
+    <form class="ui form" @submit.prevent="submit">
+      <div v-if="errors.length > 0" class="ui negative message">
+        <div class="header">{{ $t('Error while creating invitation') }}</div>
+        <ul class="list">
+          <li v-for="error in errors">{{ error }}</li>
+        </ul>
+      </div>
+      <div class="inline fields">
+        <div class="ui field">
+          <label>{{ $t('Invitation code')}}</label>
+          <input type="text" v-model="code" :placeholder="$t('Leave empty for a random code')" />
+        </div>
+        <div class="ui field">
+          <button :class="['ui', {loading: isLoading}, 'button']" :disabled="isLoading" type="submit">
+            {{ $t('Get a new invitation') }}
+          </button>
+        </div>
+      </div>
+    </form>
+    <div v-if="invitations.length > 0">
+      <div class="ui hidden divider"></div>
+      <table class="ui ui basic table">
+        <thead>
+          <tr>
+            <th>{{ $t('Code') }}</th>
+            <th>{{ $t('Share link') }}</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="invitation in invitations" :key="invitation.code">
+            <td>{{ invitation.code.toUpperCase() }}</td>
+            <td><a :href="getUrl(invitation.code)" target="_blank">{{ getUrl(invitation.code) }}</a></td>
+          </tr>
+        </tbody>
+      </table>
+      <button class="ui basic button" @click="invitations = []">{{ $t('Clear') }}</button>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+
+export default {
+  data () {
+    return {
+      isLoading: false,
+      code: null,
+      invitations: [],
+      errors: []
+    }
+  },
+  methods: {
+    submit () {
+      let self = this
+      this.isLoading = true
+      this.errors = []
+      let url = 'manage/users/invitations/'
+      let payload = {
+        code: this.code
+      }
+      axios.post(url, payload).then((response) => {
+        self.isLoading = false
+        self.invitations.unshift(response.data)
+      }, (error) => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    getUrl (code) {
+      return this.$store.getters['instance/absoluteUrl'](this.$router.resolve({name: 'signup', query: {invitation: code.toUpperCase()}}).href)
+    }
+  }
+}
+</script>
+
+<style scoped>
+</style>
diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e8d0a2406aaf465d7e30803b05ace126b621606d
--- /dev/null
+++ b/front/src/components/manage/users/InvitationsTable.vue
@@ -0,0 +1,191 @@
+<template>
+  <div>
+    <div class="ui inline form">
+      <div class="fields">
+        <div class="ui field">
+          <label>{{ $t('Search') }}</label>
+          <input type="text" v-model="search" placeholder="Search by username, email, code..." />
+        </div>
+        <div class="field">
+          <label>{{ $t("Ordering") }}</label>
+          <select class="ui dropdown" v-model="ordering">
+            <option v-for="option in orderingOptions" :value="option[0]">
+              {{ option[1] }}
+            </option>
+          </select>
+        </div>
+        <div class="field">
+          <label>{{ $t("Status") }}</label>
+          <select class="ui dropdown" v-model="isOpen">
+            <option :value="null">{{ $t('All') }}</option>
+            <option :value="true">{{ $t('Open') }}</option>
+            <option :value="false">{{ $t('Expired/used') }}</option>
+          </select>
+        </div>
+      </div>
+      </div>
+    <div class="dimmable">
+      <div v-if="isLoading" class="ui active inverted dimmer">
+          <div class="ui loader"></div>
+      </div>
+      <action-table
+        v-if="result"
+        @action-launched="fetchData"
+        :objects-data="result"
+        :actions="actions"
+        :action-url="'manage/users/invitations/action/'"
+        :filters="actionFilters">
+        <template slot="header-cells">
+          <th>{{ $t('Owner') }}</th>
+          <th>{{ $t('Status') }}</th>
+          <th>{{ $t('Creation date') }}</th>
+          <th>{{ $t('Expiration date') }}</th>
+          <th>{{ $t('Code') }}</th>
+        </template>
+        <template slot="row-cells" slot-scope="scope">
+          <td>
+            <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.owner.username }}</router-link>
+          </td>
+          <td>
+            <span v-if="scope.obj.users.length > 0" class="ui green basic label">{{ $t('Used') }}</span>
+            <span v-else-if="moment().isAfter(scope.obj.expiration_date)" class="ui red basic label">{{ $t('Expired') }}</span>
+            <span v-else class="ui basic label">{{ $t('Not used') }}</span>
+          </td>
+          <td>
+            <human-date :date="scope.obj.creation_date"></human-date>
+          </td>
+          <td>
+            <human-date :date="scope.obj.expiration_date"></human-date>
+          </td>
+          <td>
+            {{ scope.obj.code.toUpperCase() }}
+          </td>
+        </template>
+      </action-table>
+    </div>
+    <div>
+      <pagination
+        v-if="result && result.results.length > 0"
+        @page-changed="selectPage"
+        :compact="true"
+        :current="page"
+        :paginate-by="paginateBy"
+        :total="result.count"
+        ></pagination>
+
+      <span v-if="result && result.results.length > 0">
+        {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import moment from 'moment'
+import _ from 'lodash'
+import Pagination from '@/components/Pagination'
+import ActionTable from '@/components/common/ActionTable'
+import OrderingMixin from '@/components/mixins/Ordering'
+
+export default {
+  mixins: [OrderingMixin],
+  props: {
+    filters: {type: Object, required: false}
+  },
+  components: {
+    Pagination,
+    ActionTable
+  },
+  data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
+    return {
+      moment,
+      isLoading: false,
+      result: null,
+      page: 1,
+      paginateBy: 50,
+      search: '',
+      isOpen: null,
+      orderingDirection: defaultOrdering.direction || '+',
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['expiration_date', 'Expiration date'],
+        ['creation_date', 'Creation date']
+      ]
+
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      let params = _.merge({
+        'page': this.page,
+        'page_size': this.paginateBy,
+        'q': this.search,
+        'is_open': this.isOpen,
+        'ordering': this.getOrderingAsString()
+      }, this.filters)
+      let self = this
+      self.isLoading = true
+      self.checked = []
+      axios.get('/manage/users/invitations/', {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    selectPage: function (page) {
+      this.page = page
+    }
+  },
+  computed: {
+    actionFilters () {
+      var currentFilters = {
+        q: this.search
+      }
+      if (this.filters) {
+        return _.merge(currentFilters, this.filters)
+      } else {
+        return currentFilters
+      }
+    },
+    actions () {
+      return [
+        {
+          name: 'delete',
+          label: this.$t('Delete'),
+          filterCheckable: (obj) => {
+            return obj.users.length === 0 && moment().isBefore(obj.expiration_date)
+          }
+        }
+      ]
+    }
+  },
+  watch: {
+    search (newValue) {
+      this.page = 1
+      this.fetchData()
+    },
+    page () {
+      this.fetchData()
+    },
+    ordering () {
+      this.page = 1
+      this.fetchData()
+    },
+    isOpen () {
+      this.page = 1
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.page = 1
+      this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..855fbe2b5da53abf717dc819a5f2560c5e35bd54
--- /dev/null
+++ b/front/src/components/manage/users/UsersTable.vue
@@ -0,0 +1,216 @@
+<template>
+  <div>
+    <div class="ui inline form">
+      <div class="fields">
+        <div class="ui field">
+          <label>{{ $t('Search') }}</label>
+          <input type="text" v-model="search" placeholder="Search by username, email, name..." />
+        </div>
+        <div class="field">
+          <i18next tag="label" path="Ordering"/>
+          <select class="ui dropdown" v-model="ordering">
+            <option v-for="option in orderingOptions" :value="option[0]">
+              {{ option[1] }}
+            </option>
+          </select>
+        </div>
+        <div class="field">
+          <i18next tag="label" path="Ordering direction"/>
+          <select class="ui dropdown" v-model="orderingDirection">
+            <option value="+">{{ $t('Ascending') }}</option>
+            <option value="-">{{ $t('Descending') }}</option>
+          </select>
+        </div>
+      </div>
+      </div>
+    <div class="dimmable">
+      <div v-if="isLoading" class="ui active inverted dimmer">
+          <div class="ui loader"></div>
+      </div>
+      <action-table
+        v-if="result"
+        @action-launched="fetchData"
+        :objects-data="result"
+        :actions="actions"
+        :action-url="'manage/library/track-files/action/'"
+        :filters="actionFilters">
+        <template slot="header-cells">
+          <th>{{ $t('Username') }}</th>
+          <th>{{ $t('Email') }}</th>
+          <th>{{ $t('Account status') }}</th>
+          <th>{{ $t('Sign-up') }}</th>
+          <th>{{ $t('Last activity') }}</th>
+          <th>{{ $t('Permissions') }}</th>
+          <th>{{ $t('Status') }}</th>
+        </template>
+        <template slot="row-cells" slot-scope="scope">
+          <td>
+            <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
+          </td>
+          <td>
+            <span>{{ scope.obj.email }}</span>
+          </td>
+          <td>
+            <span v-if="scope.obj.is_active" class="ui basic green label">{{ $t('Active') }}</span>
+            <span v-else class="ui basic grey label">{{ $t('Inactive') }}</span>
+          </td>
+          <td>
+            <human-date :date="scope.obj.date_joined"></human-date>
+          </td>
+          <td>
+            <human-date v-if="scope.obj.last_activity" :date="scope.obj.last_activity"></human-date>
+            <template v-else>{{ $t('N/A') }}</template>
+          </td>
+          <td>
+            <template v-for="p in permissions">
+              <span class="ui basic tiny label" v-if="scope.obj.permissions[p.code]">{{ p.label }}</span>
+            </template>
+          </td>
+          <td>
+            <span v-if="scope.obj.is_superuser" class="ui pink label">{{ $t('Admin') }}</span>
+            <span v-else-if="scope.obj.is_staff" class="ui purple label">{{ $t('Staff member') }}</span>
+            <span v-else class="ui basic label">{{ $t('regular user') }}</span>
+          </td>
+        </template>
+      </action-table>
+    </div>
+    <div>
+      <pagination
+        v-if="result && result.results.length > 0"
+        @page-changed="selectPage"
+        :compact="true"
+        :current="page"
+        :paginate-by="paginateBy"
+        :total="result.count"
+        ></pagination>
+
+      <span v-if="result && result.results.length > 0">
+        {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import _ from 'lodash'
+import time from '@/utils/time'
+import Pagination from '@/components/Pagination'
+import ActionTable from '@/components/common/ActionTable'
+import OrderingMixin from '@/components/mixins/Ordering'
+
+export default {
+  mixins: [OrderingMixin],
+  props: {
+    filters: {type: Object, required: false}
+  },
+  components: {
+    Pagination,
+    ActionTable
+  },
+  data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-date_joined')
+    return {
+      time,
+      isLoading: false,
+      result: null,
+      page: 1,
+      paginateBy: 50,
+      search: '',
+      orderingDirection: defaultOrdering.direction || '+',
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['date_joined', 'Sign-up date'],
+        ['last_activity', 'Last activity'],
+        ['username', 'Username']
+      ]
+
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      let params = _.merge({
+        'page': this.page,
+        'page_size': this.paginateBy,
+        'q': this.search,
+        'ordering': this.getOrderingAsString()
+      }, this.filters)
+      let self = this
+      self.isLoading = true
+      self.checked = []
+      axios.get('/manage/users/users/', {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    selectPage: function (page) {
+      this.page = page
+    }
+  },
+  computed: {
+    privacyLevels () {
+      return {}
+    },
+    permissions () {
+      return [
+        {
+          'code': 'upload',
+          'label': this.$t('Upload')
+        },
+        {
+          'code': 'library',
+          'label': this.$t('Library')
+        },
+        {
+          'code': 'federation',
+          'label': this.$t('Federation')
+        },
+        {
+          'code': 'settings',
+          'label': this.$t('Settings')
+        }
+      ]
+    },
+    actionFilters () {
+      var currentFilters = {
+        q: this.search
+      }
+      if (this.filters) {
+        return _.merge(currentFilters, this.filters)
+      } else {
+        return currentFilters
+      }
+    },
+    actions () {
+      return [
+        // {
+        //   name: 'delete',
+        //   label: this.$t('Delete'),
+        //   isDangerous: true
+        // }
+      ]
+    }
+  },
+  watch: {
+    search (newValue) {
+      this.page = 1
+      this.fetchData()
+    },
+    page () {
+      this.fetchData()
+    },
+    ordering () {
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue
index 305aa7a3d6b3aa7e695c9613f566946c3e7be805..3fcd8484edbe20c830c1f0078a0491554b9bfef8 100644
--- a/front/src/components/metadata/Search.vue
+++ b/front/src/components/metadata/Search.vue
@@ -22,7 +22,6 @@
 
 <script>
 import jQuery from 'jquery'
-import config from '@/config'
 
 export default {
   props: {
@@ -117,7 +116,7 @@ export default {
       })[0]
     },
     searchUrl: function () {
-      return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}'
+      return this.$store.getters['instance/absoluteUrl']('api/v1/providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}')
     },
     types: function () {
       return [
diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue
index 62de6ec65dcd0c9c0a8aa2bd70f3a31392b21e15..bab1e2a1de26a284c09a5314bd8db9346775cc04 100644
--- a/front/src/components/radios/Card.vue
+++ b/front/src/components/radios/Card.vue
@@ -2,9 +2,12 @@
     <div class="ui card">
       <div class="content">
         <div class="header">
-          <router-link class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}">
+          <router-link v-if="radio.id" class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}">
             {{ radio.name }}
           </router-link>
+          <template v-else>
+            {{ radio.name }}
+          </template>
         </div>
         <div class="description">
           {{ radio.description }}
diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue
deleted file mode 100644
index 58b7f5fa9ca65561291588c58f92d7039b357c2a..0000000000000000000000000000000000000000
--- a/front/src/components/requests/RequestsList.vue
+++ /dev/null
@@ -1,198 +0,0 @@
-<template>
-  <div v-title="'Import Requests'">
-    <div class="ui vertical stripe segment">
-      <h2 class="ui header">{{ $t('Music requests') }}</h2>
-      <div :class="['ui', {'loading': isLoading}, 'form']">
-        <div class="fields">
-          <div class="field">
-            <label>{{ $t('Search') }}</label>
-            <input type="text" v-model="query" placeholder="Enter an artist name, a username..."/>
-          </div>
-          <div class="field">
-            <label>{{ $t('Status') }}</label>
-            <select class="ui dropdown" v-model="status">
-              <option :value="'any'">{{ $t('Any') }}</option>
-              <option :value="'pending'">{{ $t('Pending') }}</option>
-              <option :value="'accepted'">{{ $t('Accepted') }}</option>
-              <option :value="'imported'">{{ $t('Imported') }}</option>
-              <option :value="'closed'">{{ $t('Closed') }}</option>
-            </select>
-          </div>
-          <div class="field">
-            <label>{{ $t('Ordering') }}</label>
-            <select class="ui dropdown" v-model="ordering">
-              <option v-for="option in orderingOptions" :value="option[0]">
-                {{ option[1] }}
-              </option>
-            </select>
-          </div>
-          <div class="field">
-            <label>{{ $t('Ordering direction') }}</label>
-            <select class="ui dropdown" v-model="orderingDirection">
-              <option value="+">Ascending</option>
-              <option value="-">Descending</option>
-            </select>
-          </div>
-          <div class="field">
-            <label>{{ $t('Results per page') }}</label>
-            <select class="ui dropdown" v-model="paginateBy">
-              <option :value="parseInt(12)">12</option>
-              <option :value="parseInt(25)">25</option>
-              <option :value="parseInt(50)">50</option>
-            </select>
-          </div>
-        </div>
-      </div>
-      <div class="ui hidden divider"></div>
-      <div
-        v-if="result"
-        v-masonry
-        transition-duration="0"
-        item-selector=".column"
-        percent-position="true"
-        stagger="0"
-        class="ui stackable three column doubling grid">
-        <div
-          v-masonry-tile
-          v-if="result.results.length > 0"
-          v-for="request in result.results"
-          :key="request.id"
-          class="column">
-          <request-card class="fluid" :request="request"></request-card>
-        </div>
-      </div>
-      <div class="ui center aligned basic segment">
-        <pagination
-          v-if="result && result.results.length > 0"
-          @page-changed="selectPage"
-          :current="page"
-          :paginate-by="paginateBy"
-          :total="result.count"
-          ></pagination>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script>
-import axios from 'axios'
-import _ from 'lodash'
-import $ from 'jquery'
-
-import logger from '@/logging'
-
-import OrderingMixin from '@/components/mixins/Ordering'
-import PaginationMixin from '@/components/mixins/Pagination'
-import RequestCard from '@/components/requests/Card'
-import Pagination from '@/components/Pagination'
-
-const FETCH_URL = 'requests/import-requests/'
-
-export default {
-  mixins: [OrderingMixin, PaginationMixin],
-  props: {
-    defaultQuery: {type: String, required: false, default: ''},
-    defaultStatus: {required: false, default: 'any'}
-  },
-  components: {
-    RequestCard,
-    Pagination
-  },
-  data () {
-    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
-    return {
-      isLoading: true,
-      result: null,
-      page: parseInt(this.defaultPage),
-      query: this.defaultQuery,
-      paginateBy: parseInt(this.defaultPaginateBy || 12),
-      orderingDirection: defaultOrdering.direction || '+',
-      ordering: defaultOrdering.field,
-      status: this.defaultStatus || 'any'
-    }
-  },
-  created () {
-    this.fetchData()
-  },
-  mounted () {
-    $('.ui.dropdown').dropdown()
-  },
-  methods: {
-    updateQueryString: _.debounce(function () {
-      let query = {
-        query: {
-          query: this.query,
-          page: this.page,
-          paginateBy: this.paginateBy,
-          ordering: this.getOrderingAsString()
-        }
-      }
-      if (this.status !== 'any') {
-        query.query.status = this.status
-      }
-      this.$router.replace(query)
-    }, 500),
-    fetchData: _.debounce(function () {
-      var self = this
-      this.isLoading = true
-      let url = FETCH_URL
-      let params = {
-        page: this.page,
-        page_size: this.paginateBy,
-        q: this.query,
-        ordering: this.getOrderingAsString()
-      }
-      if (this.status !== 'any') {
-        params.status = this.status
-      }
-      logger.default.debug('Fetching request...')
-      axios.get(url, {params: params}).then((response) => {
-        self.result = response.data
-        self.isLoading = false
-      })
-    }, 500),
-    selectPage: function (page) {
-      this.page = page
-    }
-  },
-  computed: {
-    orderingOptions: function () {
-      return [
-        ['creation_date', this.$t('Creation date')],
-        ['artist_name', this.$t('Artist name')],
-        ['user__username', this.$t('User')]
-      ]
-    }
-  },
-  watch: {
-    page () {
-      this.updateQueryString()
-      this.fetchData()
-    },
-    paginateBy () {
-      this.updateQueryString()
-      this.fetchData()
-    },
-    ordering () {
-      this.updateQueryString()
-      this.fetchData()
-    },
-    orderingDirection () {
-      this.updateQueryString()
-      this.fetchData()
-    },
-    query () {
-      this.updateQueryString()
-      this.fetchData()
-    },
-    status () {
-      this.updateQueryString()
-      this.fetchData()
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-</style>
diff --git a/front/src/config.js b/front/src/config.js
deleted file mode 100644
index 47d9d7b8b2cdc83ded7330e2400c48d4b72b8e2e..0000000000000000000000000000000000000000
--- a/front/src/config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-class Config {
-  constructor () {
-    this.BACKEND_URL = process.env.BACKEND_URL
-    this.API_URL = this.BACKEND_URL + 'api/v1/'
-  }
-}
-
-export default new Config()
diff --git a/front/src/main.js b/front/src/main.js
index eb2e3a23d6ceed3bdb88ed59d49df5a9744e4828..181fd66b3b63197660794af819c24fa1751866fb 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -15,7 +15,6 @@ import i18next from 'i18next'
 import i18nextFetch from 'i18next-fetch-backend'
 import VueI18Next from '@panter/vue-i18next'
 import store from './store'
-import config from './config'
 import { sync } from 'vuex-router-sync'
 import filters from '@/filters' // eslint-disable-line
 import globals from '@/components/globals' // eslint-disable-line
@@ -56,8 +55,6 @@ Vue.directive('title', {
     document.title = parts.join(' - ')
   }
 })
-
-axios.defaults.baseURL = config.API_URL
 axios.interceptors.request.use(function (config) {
   // Do something before request is sent
   if (store.state.auth.token) {
@@ -86,11 +83,15 @@ axios.interceptors.response.use(function (response) {
   } else if (error.response.status === 500) {
     error.backendErrors.push('A server error occured')
   } else if (error.response.data) {
-    for (var field in error.response.data) {
-      if (error.response.data.hasOwnProperty(field)) {
-        error.response.data[field].forEach(e => {
-          error.backendErrors.push(e)
-        })
+    if (error.response.data.detail) {
+      error.backendErrors.push(error.response.data.detail)
+    } else {
+      for (var field in error.response.data) {
+        if (error.response.data.hasOwnProperty(field)) {
+          error.response.data[field].forEach(e => {
+            error.backendErrors.push(e)
+          })
+        }
       }
     }
   }
@@ -100,7 +101,6 @@ axios.interceptors.response.use(function (response) {
   // Do something with response error
   return Promise.reject(error)
 })
-store.dispatch('auth/check')
 
 // i18n
 i18next
diff --git a/front/src/router/index.js b/front/src/router/index.js
index a52070e35912b42813db85f3c8ac195f6e39e4d2..bb59b5348b5cdbc36178c91b81249d5f7f7e19ec 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -24,13 +24,17 @@ import RadioBuilder from '@/components/library/radios/Builder'
 import RadioDetail from '@/views/radios/Detail'
 import BatchList from '@/components/library/import/BatchList'
 import BatchDetail from '@/components/library/import/BatchDetail'
-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 AdminLibraryBase from '@/views/admin/library/Base'
 import AdminLibraryFilesList from '@/views/admin/library/FilesList'
+import AdminLibraryRequestsList from '@/views/admin/library/RequestsList'
+import AdminUsersBase from '@/views/admin/users/Base'
+import AdminUsersDetail from '@/views/admin/users/UsersDetail'
+import AdminUsersList from '@/views/admin/users/UsersList'
+import AdminInvitationsList from '@/views/admin/users/InvitationsList'
 import FederationBase from '@/views/federation/Base'
 import FederationScan from '@/views/federation/Scan'
 import FederationLibraryDetail from '@/views/federation/LibraryDetail'
@@ -93,7 +97,10 @@ export default new Router({
     {
       path: '/signup',
       name: 'signup',
-      component: Signup
+      component: Signup,
+      props: (route) => ({
+        invitation: route.query.invitation
+      })
     },
     {
       path: '/logout',
@@ -177,6 +184,33 @@ export default new Router({
           path: 'files',
           name: 'manage.library.files',
           component: AdminLibraryFilesList
+        },
+        {
+          path: 'requests',
+          name: 'manage.library.requests',
+          component: AdminLibraryRequestsList
+        }
+      ]
+    },
+    {
+      path: '/manage/users',
+      component: AdminUsersBase,
+      children: [
+        {
+          path: 'users',
+          name: 'manage.users.users.list',
+          component: AdminUsersList
+        },
+        {
+          path: 'users/:id',
+          name: 'manage.users.users.detail',
+          component: AdminUsersDetail,
+          props: true
+        },
+        {
+          path: 'invitations',
+          name: 'manage.users.invitations.list',
+          component: AdminInvitationsList
         }
       ]
     },
@@ -249,21 +283,7 @@ export default new Router({
           children: [
           ]
         },
-        { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true },
-        {
-          path: 'requests/',
-          name: 'library.requests',
-          component: RequestsList,
-          props: (route) => ({
-            defaultOrdering: route.query.ordering,
-            defaultQuery: route.query.query,
-            defaultPaginateBy: route.query.paginateBy,
-            defaultPage: route.query.page,
-            defaultStatus: route.query.status || 'any'
-          }),
-          children: [
-          ]
-        }
+        { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
       ]
     },
     { path: '*', component: PageNotFound }
diff --git a/front/src/store/index.js b/front/src/store/index.js
index 298fa04ec13166fead7a12955636d3bc4340948a..0c2908d83d5cceee72997111f099aeda555d2337 100644
--- a/front/src/store/index.js
+++ b/front/src/store/index.js
@@ -34,7 +34,7 @@ export default new Vuex.Store({
     }),
     createPersistedState({
       key: 'instance',
-      paths: ['instance.events']
+      paths: ['instance.events', 'instance.instanceUrl']
     }),
     createPersistedState({
       key: 'radios',
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index e78e804898c8c02a1f237297d3ab3dc653e62c0e..95de94171ece68fe69f57ffbe9b4101e42097e86 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -6,6 +6,7 @@ export default {
   namespaced: true,
   state: {
     maxEvents: 200,
+    instanceUrl: process.env.INSTANCE_URL,
     events: [],
     settings: {
       instance: {
@@ -51,9 +52,46 @@ export default {
     },
     events: (state, value) => {
       state.events = value
+    },
+    instanceUrl: (state, value) => {
+      if (value && !value.endsWith('/')) {
+        value = value + '/'
+      }
+      state.instanceUrl = value
+      if (!value) {
+        axios.defaults.baseURL = null
+        return
+      }
+      let suffix = 'api/v1/'
+      axios.defaults.baseURL = state.instanceUrl + suffix
+    }
+  },
+  getters: {
+    absoluteUrl: (state) => (relativeUrl) => {
+      if (relativeUrl.startsWith('http')) {
+        return relativeUrl
+      }
+      if (state.instanceUrl.endsWith('/') && relativeUrl.startsWith('/')) {
+        relativeUrl = relativeUrl.slice(1)
+      }
+      return state.instanceUrl + relativeUrl
     }
   },
   actions: {
+    setUrl ({commit, dispatch}, url) {
+      commit('instanceUrl', url)
+      let modules = [
+        'auth',
+        'favorites',
+        'player',
+        'playlists',
+        'queue',
+        'radios'
+      ]
+      modules.forEach(m => {
+        commit(`${m}/reset`, null, {root: true})
+      })
+    },
     // Send a request to the login URL and save the returned JWT
     fetchSettings ({commit}, payload) {
       return axios.get('instance/settings/').then(response => {
diff --git a/front/src/store/queue.js b/front/src/store/queue.js
index 2d6c667b29ba5e87623f3cc60e7be31e0d5d3fc4..0435c867ee1137c308f997bd2442a330fa0ffcf8 100644
--- a/front/src/store/queue.js
+++ b/front/src/store/queue.js
@@ -31,9 +31,10 @@ export default {
     insert (state, {track, index}) {
       state.tracks.splice(index, 0, track)
     },
-    reorder (state, {oldIndex, newIndex}) {
+    reorder (state, {tracks, oldIndex, newIndex}) {
       // called when the user uses drag / drop to reorder
       // tracks in queue
+      state.tracks = tracks
       if (oldIndex === state.currentIndex) {
         state.currentIndex = newIndex
         return
@@ -102,7 +103,7 @@ export default {
       }
       if (current) {
         // we play next track, which now have the same index
-        dispatch('currentIndex', index)
+        commit('currentIndex', index)
       }
       if (state.currentIndex + 1 === state.tracks.length) {
         dispatch('radios/populateQueue', null, {root: true})
@@ -156,7 +157,6 @@ export default {
       let toKeep = state.tracks.slice(0, state.currentIndex + 1)
       let toShuffle = state.tracks.slice(state.currentIndex + 1)
       let shuffled = toKeep.concat(_.shuffle(toShuffle))
-      commit('player/currentTime', 0, {root: true})
       commit('tracks', [])
       let params = {tracks: shuffled}
       if (callback) {
diff --git a/front/src/store/ui.js b/front/src/store/ui.js
index be744afe51ad954a4bae722f9442a9d71ad85730..c336803475c5c6c79776e501dd94a8884a9198c6 100644
--- a/front/src/store/ui.js
+++ b/front/src/store/ui.js
@@ -1,3 +1,4 @@
+import axios from 'axios'
 
 export default {
   namespaced: true,
@@ -5,7 +6,11 @@ export default {
     lastDate: new Date(),
     maxMessages: 100,
     messageDisplayDuration: 10000,
-    messages: []
+    messages: [],
+    notifications: {
+      federation: 0,
+      importRequests: 0
+    }
   },
   mutations: {
     computeLastDate: (state) => {
@@ -16,6 +21,27 @@ export default {
       if (state.messages.length > state.maxMessages) {
         state.messages.shift()
       }
+    },
+    notifications (state, {type, count}) {
+      state.notifications[type] = count
+    }
+  },
+  actions: {
+    fetchFederationNotificationsCount ({rootState, commit}) {
+      if (!rootState.auth.availablePermissions['federation']) {
+        return
+      }
+      axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
+        commit('notifications', {type: 'federation', count: response.data.count})
+      })
+    },
+    fetchImportRequestsCount ({rootState, commit}) {
+      if (!rootState.auth.availablePermissions['library']) {
+        return
+      }
+      axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
+        commit('notifications', {type: 'importRequests', count: response.data.count})
+      })
     }
   }
 }
diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue
index 834fca920f62f195cb83bbd37c4d65dd47ce946a..cc26c8d6be42f0fe30643ea13bb78bccc1e13838 100644
--- a/front/src/views/admin/library/Base.vue
+++ b/front/src/views/admin/library/Base.vue
@@ -4,6 +4,15 @@
       <router-link
         class="ui item"
         :to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link>
+      <router-link
+        class="ui item"
+        :to="{name: 'manage.library.requests'}">
+          {{ $t('Import requests') }}
+          <div
+            :class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']"
+            :title="$t('Pending import requests')">
+            {{ $store.state.ui.notifications.importRequests }}</div>
+          </router-link>
     </div>
     <router-view :key="$route.fullPath"></router-view>
   </div>
diff --git a/front/src/views/admin/library/RequestsList.vue b/front/src/views/admin/library/RequestsList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..160bf890b99dc42f0cdd07493e06e70f0e3fe93d
--- /dev/null
+++ b/front/src/views/admin/library/RequestsList.vue
@@ -0,0 +1,23 @@
+<template>
+  <div v-title="$t('Import requests')">
+    <div class="ui vertical stripe segment">
+      <h2 class="ui header">{{ $t('Import requests') }}</h2>
+      <div class="ui hidden divider"></div>
+      <library-requests-table></library-requests-table>
+    </div>
+  </div>
+</template>
+
+<script>
+import LibraryRequestsTable from '@/components/manage/library/RequestsTable'
+
+export default {
+  components: {
+    LibraryRequestsTable
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue
new file mode 100644
index 0000000000000000000000000000000000000000..505ca587fe2bf2726b2a3c30bfbf4b6c540a858f
--- /dev/null
+++ b/front/src/views/admin/users/Base.vue
@@ -0,0 +1,31 @@
+<template>
+  <div class="main pusher"  v-title="$t('Manage users')">
+    <div class="ui secondary pointing menu">
+      <router-link
+        class="ui item"
+        :to="{name: 'manage.users.users.list'}">{{ $t('Users') }}</router-link>
+      <router-link
+        class="ui item"
+        :to="{name: 'manage.users.invitations.list'}">{{ $t('Invitations') }}</router-link>
+    </div>
+    <router-view :key="$route.fullPath"></router-view>
+  </div>
+</template>
+
+<script>
+export default {}
+</script>
+
+<style lang="scss">
+@import '../../../style/vendor/media';
+
+.main.pusher > .ui.secondary.menu {
+  @include media(">tablet") {
+    margin: 0 2.5rem;
+  }
+  .item {
+    padding-top: 1.5em;
+    padding-bottom: 1.5em;
+  }
+}
+</style>
diff --git a/front/src/views/admin/users/InvitationsList.vue b/front/src/views/admin/users/InvitationsList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..230dad6c1971a9c86c08570c404530ecf405c7d4
--- /dev/null
+++ b/front/src/views/admin/users/InvitationsList.vue
@@ -0,0 +1,26 @@
+<template>
+  <div v-title="$t('Invitations')">
+    <div class="ui vertical stripe segment">
+      <h2 class="ui header">{{ $t('Invitations') }}</h2>
+      <invitation-form></invitation-form>
+      <div class="ui hidden divider"></div>
+      <invitations-table></invitations-table>
+    </div>
+  </div>
+</template>
+
+<script>
+import InvitationForm from '@/components/manage/users/InvitationForm'
+import InvitationsTable from '@/components/manage/users/InvitationsTable'
+
+export default {
+  components: {
+    InvitationForm,
+    InvitationsTable
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/admin/users/UsersDetail.vue b/front/src/views/admin/users/UsersDetail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ea92716ca943570d77e1cd4cd91cb1d9eaa97133
--- /dev/null
+++ b/front/src/views/admin/users/UsersDetail.vue
@@ -0,0 +1,177 @@
+<template>
+  <div>
+    <div v-if="isLoading" class="ui vertical segment">
+      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+    </div>
+    <template v-if="object">
+      <div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']" v-title="object.username">
+        <div class="segment-content">
+          <h2 class="ui center aligned icon header">
+            <i class="circular inverted user red icon"></i>
+            <div class="content">
+              @{{ object.username }}
+            </div>
+          </h2>
+        </div>
+        <div class="ui hidden divider"></div>
+        <div class="ui one column centered grid">
+          <table class="ui collapsing very basic table">
+            <tbody>
+              <tr>
+                <td>
+                  {{ $t('Name') }}
+                </td>
+                <td>
+                  {{ object.name }}
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  {{ $t('Email address') }}
+                </td>
+                <td>
+                  {{ object.email }}
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  {{ $t('Sign-up') }}
+                </td>
+                <td>
+                  <human-date :date="object.date_joined"></human-date>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  {{ $t('Last activity') }}
+                </td>
+                <td>
+                  <human-date v-if="object.last_activity" :date="object.last_activity"></human-date>
+                  <template v-else>{{ $t('N/A') }}</template>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  {{ $t('Account active') }}
+                  <span :data-tooltip="$t('Determine if the user account is active or not. Inactive users cannot login or user the service.')"><i class="question circle icon"></i></span>
+                </td>
+                <td>
+                  <div class="ui toggle checkbox">
+                    <input
+                      @change="update('is_active')"
+                      v-model="object.is_active" type="checkbox">
+                    <label></label>
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  {{ $t('Permissions') }}
+                </td>
+                <td>
+                  <select
+                    @change="update('permissions')"
+                    v-model="permissions"
+                    multiple
+                    class="ui search selection dropdown">
+                    <option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
+                  </select>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <div class="ui hidden divider"></div>
+        <button @click="fetchData" class="ui basic button">{{ $t('Refresh') }}</button>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+
+import $ from 'jquery'
+import axios from 'axios'
+import logger from '@/logging'
+
+export default {
+  props: ['id'],
+  data () {
+    return {
+      isLoading: true,
+      object: null,
+      permissions: []
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      var self = this
+      this.isLoading = true
+      let url = 'manage/users/users/' + this.id + '/'
+      axios.get(url).then((response) => {
+        self.object = response.data
+        self.permissions = []
+        self.allPermissions.forEach(p => {
+          if (self.object.permissions[p.code]) {
+            self.permissions.push(p.code)
+          }
+        })
+        self.isLoading = false
+      })
+    },
+    update (attr) {
+      let newValue = this.object[attr]
+      let params = {}
+      if (attr === 'permissions') {
+        params['permissions'] = {}
+        this.allPermissions.forEach(p => {
+          params['permissions'][p.code] = this.permissions.indexOf(p.code) > -1
+        })
+      } else {
+        params[attr] = newValue
+      }
+      axios.patch('manage/users/users/' + this.id + '/', params).then((response) => {
+        logger.default.info(`${attr} was updated succcessfully to ${newValue}`)
+      }, (error) => {
+        logger.default.error(`Error while setting ${attr} to ${newValue}`, error)
+      })
+    }
+  },
+  computed: {
+    allPermissions () {
+      return [
+        {
+          'code': 'upload',
+          'label': this.$t('Upload')
+        },
+        {
+          'code': 'library',
+          'label': this.$t('Library')
+        },
+        {
+          'code': 'federation',
+          'label': this.$t('Federation')
+        },
+        {
+          'code': 'settings',
+          'label': this.$t('Settings')
+        }
+      ]
+    }
+  },
+  watch: {
+    object () {
+      this.$nextTick(() => {
+        $('select.dropdown').dropdown()
+      })
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/admin/users/UsersList.vue b/front/src/views/admin/users/UsersList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b22d4aaf836f0b45be74d2a894c59963eac8a145
--- /dev/null
+++ b/front/src/views/admin/users/UsersList.vue
@@ -0,0 +1,23 @@
+<template>
+  <div v-title="$t('Users')">
+    <div class="ui vertical stripe segment">
+      <h2 class="ui header">{{ $t('Users') }}</h2>
+      <div class="ui hidden divider"></div>
+      <users-table></users-table>
+    </div>
+  </div>
+</template>
+
+<script>
+import UsersTable from '@/components/manage/users/UsersTable'
+
+export default {
+  components: {
+    UsersTable
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue
index 03bd5a53758373257239d054f6ffbead3569d9fe..a5647b7bf919759dc443d4f95d0def829ffb20d5 100644
--- a/front/src/views/instance/Timeline.vue
+++ b/front/src/views/instance/Timeline.vue
@@ -78,8 +78,11 @@ export default {
       // let token = 'test'
       const bridge = new WebSocketBridge()
       this.bridge = bridge
+      let url = this.$store.getters['instance/absoluteUrl'](`api/v1/instance/activity?token=${token}`)
+      url = url.replace('http://', 'ws://')
+      url = url.replace('https://', 'wss://')
       bridge.connect(
-        `/api/v1/instance/activity?token=${token}`,
+        url,
         null,
         {reconnectInterval: 5000})
       bridge.listen(function (event) {
diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue
index 61968c2e7e34683b03f9fa3bea2b2af437df3d18..7a378fa67c5bd6ed40417113b188bcc2c4d45ab9 100644
--- a/front/src/views/playlists/Detail.vue
+++ b/front/src/views/playlists/Detail.vue
@@ -93,7 +93,7 @@ export default {
       let url = 'playlists/' + this.id + '/'
       axios.get(url).then((response) => {
         self.playlist = response.data
-        axios.get(url + 'tracks').then((response) => {
+        axios.get(url + 'tracks/').then((response) => {
           self.updatePlts(response.data.results)
         }).then(() => {
           self.isLoading = false
diff --git a/front/test/unit/specs/store/queue.spec.js b/front/test/unit/specs/store/queue.spec.js
index 3a59117d54f8d833f0a35ffe63884981849030fd..cc2f04fa087714cd8a0fcb8afc03be87ab9688f9 100644
--- a/front/test/unit/specs/store/queue.spec.js
+++ b/front/test/unit/specs/store/queue.spec.js
@@ -169,11 +169,11 @@ describe('store/queue', () => {
         payload: 2,
         params: {state: {currentIndex: 2}},
         expectedMutations: [
-          { type: 'splice', payload: {start: 2, size: 1} }
+          { type: 'splice', payload: {start: 2, size: 1} },
+          { type: 'currentIndex', payload: 2 }
         ],
         expectedActions: [
-          { type: 'player/stop', payload: null, options: {root: true} },
-          { type: 'currentIndex', payload: 2 }
+          { type: 'player/stop', payload: null, options: {root: true} }
         ]
       }, done)
     })
@@ -324,7 +324,6 @@ describe('store/queue', () => {
         action: store.actions.shuffle,
         params: {state: {currentIndex: 1, tracks: tracks}},
         expectedMutations: [
-          { type: 'player/currentTime', payload: 0, options: {root: true} },
           { type: 'tracks', payload: [] }
         ],
         expectedActions: [
diff --git a/front/test/unit/specs/store/ui.spec.js b/front/test/unit/specs/store/ui.spec.js
index adcfa87d8f34bd600f113fc4100c0c6318bc730e..ddce055a571e887a3b7bf3bcefdff901e033ea40 100644
--- a/front/test/unit/specs/store/ui.spec.js
+++ b/front/test/unit/specs/store/ui.spec.js
@@ -1,7 +1,5 @@
 import store from '@/store/ui'
 
-import { testAction } from '../../utils'
-
 describe('store/ui', () => {
   describe('mutations', () => {
     it('addMessage', () => {