diff --git a/.env.dev b/.env.dev
index c09262509296ad91911a4bf75989e119117c9315..d9e2dd3ceb8c2625874cb4f7f902be5ab9c33320 100644
--- a/.env.dev
+++ b/.env.dev
@@ -1,4 +1,3 @@
-API_AUTHENTICATION_REQUIRED=True
 RAVEN_ENABLED=false
 RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
 DJANGO_ALLOWED_HOSTS=.funkwhale.test,localhost,nginx,0.0.0.0,127.0.0.1
diff --git a/.gitignore b/.gitignore
index 548cfd7b3753f07e48f7004df68f9138fa1e5976..25b088739964de23eb6ab5916132062e65d931fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,3 +89,4 @@ data/
 .env
 
 po/*.po
+docs/swagger
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6a0f4b9d89ef23ee59872c3f0ea7d19225e16182..5f65e60daa665d061cc7589f91ee3ab2755f056c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -92,12 +92,14 @@ build_front:
 
 pages:
   stage: test
-  image: python:3.6-alpine
+  image: python:3.6
+  variables:
+    BUILD_PATH: "../public"
   before_script:
     - cd docs
   script:
     - pip install sphinx
-    - python -m sphinx . ../public
+    - ./build_docs.sh
   artifacts:
     paths:
       - public
diff --git a/CHANGELOG b/CHANGELOG
index c56d58836179a84d0af30ad333764a55d800a136..82c867bf890d619bd9c10177a75f906a4445c56a 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,8 +1,120 @@
 Changelog
 =========
 
+You can subscribe to release announcements by:
+
+- Following `funkwhale@mastodon.eliotberriot.com <https://mastodon.eliotberriot.com/@funkwhale>`_ on Mastodon
+- Subscribing to the following Atom feed: https://code.eliotberriot.com/funkwhale/funkwhale/commits/develop?format=atom&search=tag
+
+This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.html.
+
 .. towncrier
 
+0.11 (unreleased)
+-----------------
+
+Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html
+
+Special thanks for this release go to @renon:matrix.org (@Hazmo on Gitlab)
+for bringing Apache2 support to Funkwhale and contributing on other issues.
+Thank you!
+
+Features:
+
+- Funkwhale now works behind an Apache2 reverse proxy (!165)
+  check out the brand new documentation at https://docs.funkwhale.audio/installation/index.html#apache2
+  if you want to try it!
+- Users can now request password reset by email, assuming a SMTP server was
+  correctly configured (#187)
+
+Enhancements:
+
+- Added a fix_track_files command to run checks and fixes against library
+  (#183)
+- Avoid fetching Actor object on every request authentication
+- Can now relaunch errored jobs and batches (#176)
+- List pending requests by default, added a status filter for requests (#109)
+- More structured menus in sidebar, added labels with notifications
+- Sample virtual-host file for Apache2 reverse-proxy (!165)
+- Store high-level settings (such as federation or auth-related ones) in
+  database (#186)
+
+
+Bugfixes:
+
+- Ensure in place imported files get a proper mimetype (#183)
+- Federation cache suppression is now simpler and also deletes orphaned files
+  (#189)
+- Fixed small UI glitches/bugs in federation tabs (#184)
+- X-sendfile not working with in place import (#182)
+
+
+Documentation:
+
+- Added a documentation area for third-party projects (#180)
+- Added documentation for optimizing Funkwhale and reduce its memory footprint.
+- Document that the database should use an utf-8 encoding (#185)
+- Foundations for API documentation with Swagger (#178)
+
+
+Database storage for high-level settings
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Due to the work done in #186, the following environment variables have been
+deprecated:
+
+- FEDERATION_ENABLED
+- FEDERATION_COLLECTION_PAGE_SIZE
+- FEDERATION_MUSIC_NEEDS_APPROVAL
+- FEDERATION_ACTOR_FETCH_DELAY
+- PLAYLISTS_MAX_TRACKS
+- API_AUTHENTICATION_REQUIRED
+
+Configuration for this settings has been moved to database, as it will provide
+a better user-experience, by allowing you to edit these values on-the-fly,
+without restarting Funkwhale processes.
+
+You can leave those environment variables in your .env file for now, as the
+values will be used to populate the database entries. We'll make a proper
+announcement when the variables won't be used anymore.
+
+Please browse https://docs.funkwhale.audio/configuration.html#instance-settings
+for more information about instance configuration using the web interface.
+
+
+System emails
+^^^^^^^^^^^^^
+
+Starting from this release, Funkwhale will send two types
+of emails:
+
+- Email confirmation emails, to ensure a user's email is valid
+- Password reset emails, enabling user to reset their password without an admin's intervention
+
+Email sending is disabled by default, as it requires additional configuration.
+In this mode, emails are simply outputed on stdout.
+
+If you want to actually send those emails to your users, you should edit your
+.env file and tweak the EMAIL_CONFIG variable. See :ref:`setting-EMAIL_CONFIG`
+for more details.
+
+.. note::
+
+  As a result of these changes, the DJANGO_EMAIL_BACKEND variable,
+  which was not documented, has no effect anymore. You can safely remove it from
+  your .env file if it is set.
+
+
+Proxy headers for non-docker deployments
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+For non-docker deployments, add ``--proxy-headers`` at the end of the ``daphne``
+command in :file:`/etc/systemd/system/funkwhale-server.service`.
+
+This will ensure the application receive the correct IP address from the client
+and not the proxy's one.
+
+
 0.10 (2018-04-23)
 -----------------
 
diff --git a/CONTRIBUTING b/CONTRIBUTING
new file mode 100644
index 0000000000000000000000000000000000000000..9f4ec885038eab47d8c27cdd12b6d7ab5cb461be
--- /dev/null
+++ b/CONTRIBUTING
@@ -0,0 +1,442 @@
+Contibute to Funkwhale development
+==================================
+
+First of all, thank you for your interest in the project! We really
+appreciate the fact that you're about to take some time to read this
+and hack on the project.
+
+This document will guide you through common operations such as:
+
+- Setup your development environment
+- Working on your first issue
+- Writing unit tests to validate your work
+- Submit your work
+
+
+Setup your development environment
+----------------------------------
+
+If you want to fix a bug or implement a feature, you'll need
+to run a local, development copy of funkwhale.
+
+We provide a docker based development environment, which should
+be both easy to setup and work similarly regardless of your
+development machine setup.
+
+Instructions for bare-metal setup will come in the future (Merge requests
+are welcome).
+
+Installing docker and docker-compose
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This is already cover in the relevant documentations:
+
+- https://docs.docker.com/install/
+- https://docs.docker.com/compose/install/
+
+Cloning the project
+^^^^^^^^^^^^^^^^^^^
+
+Visit https://code.eliotberriot.com/funkwhale/funkwhale and clone the repository using SSH or HTTPS. Exemple using SSH::
+
+    git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
+    cd funkwhale
+
+
+A note about branches
+^^^^^^^^^^^^^^^^^^^^^
+
+Next release development occurs on the "develop" branch, and releases are made on the "master" branch. Therefor, when submitting Merge Requests, ensure you are merging on the develop branch.
+
+
+Working with docker
+^^^^^^^^^^^^^^^^^^^
+
+In developpement, we use the docker-compose file named ``dev.yml``, and this is why all our docker-compose commands will look like this::
+
+    docker-compose -f dev.yml logs
+
+If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this command before starting your work::
+
+    export COMPOSE_FILE=dev.yml
+
+
+Building the containers
+^^^^^^^^^^^^^^^^^^^^^^^
+
+On your initial clone, or if there have been some changes in the
+app dependencies, you will have to rebuild your containers. This is done
+via the following command::
+
+    docker-compose -f dev.yml build
+
+
+Creating your env file
+^^^^^^^^^^^^^^^^^^^^^^
+
+We provide a working .env.dev configuration file that is suitable for
+development. However, to enable customization on your machine, you should
+also create a .env file that will hold your personal environment
+variables (those will not be commited to the project).
+
+Create it like this::
+
+    touch .env
+
+
+Database management
+^^^^^^^^^^^^^^^^^^^
+
+To setup funkwhale's database schema, run this::
+
+    docker-compose -f dev.yml run --rm api python manage.py migrate
+
+This will create all the tables needed for the API to run proprely.
+You will also need to run this whenever changes are made on the database
+schema.
+
+It is safe to run this command multiple times, so you can run it whenever
+you fetch develop.
+
+
+Development data
+^^^^^^^^^^^^^^^^
+
+You'll need at least an admin user and some artists/tracks/albums to work
+locally.
+
+Create an admin user with the following command::
+
+    docker-compose -f dev.yml run --rm api python manage.py createsuperuser
+
+Injecting fake data is done by running the fllowing script::
+
+    artists=25
+    command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)"
+    echo $command | docker-compose -f dev.yml run --rm api python manage.py shell -i python
+
+The previous command will create 25 artists with random albums, tracks
+and metadata.
+
+
+Launch all services
+^^^^^^^^^^^^^^^^^^^
+
+Then you can run everything with::
+
+    docker-compose -f dev.yml up
+
+This will launch all services, and output the logs in your current terminal window.
+If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``.
+
+Once everything is up, you can access the various funkwhale's components:
+
+- The Vue webapp, on http://localhost:8080
+- The API, on http://localhost:8080/api/v1/
+- The django admin, on http://localhost:8080/api/admin/
+
+Stopping everything
+^^^^^^^^^^^^^^^^^^^
+
+Once you're down with your work, you can stop running containers, if any, with::
+
+    docker-compose -f dev.yml stop
+
+
+Removing everything
+^^^^^^^^^^^^^^^^^^^
+
+If you want to wipe your development environment completely (e.g. if you want to start over from scratch), just run::
+
+    docker-compose -f dev.yml down -v
+
+This will wipe your containers and data, so please be careful before running it.
+
+You can keep your data by removing the ``-v`` flag.
+
+
+Working with federation locally
+-------------------------------
+
+This is not needed unless you need to work on federation-related features.
+
+To achieve that, you'll need:
+
+1. to update your dns resolver to resolve all your .dev hostnames locally
+2. a reverse proxy (such as traefik) to catch those .dev requests and
+   and with https certificate
+3. two instances (or more) running locally, following the regular dev setup
+
+Resolve .dev names locally
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+If you use dnsmasq, this is as simple as doing::
+
+    echo "address=/test/172.17.0.1" | sudo tee /etc/dnsmasq.d/test.conf
+    sudo systemctl restart dnsmasq
+
+If you use NetworkManager with dnsmasq integration, use this instead::
+
+    echo "address=/test/172.17.0.1" | sudo tee /etc/NetworkManager/dnsmasq.d/test.conf
+    sudo systemctl restart NetworkManager
+
+Add wildcard certificate to the trusted certificates
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Simply copy bundled certificates::
+
+    sudo cp docker/ssl/test.crt /usr/local/share/ca-certificates/
+    sudo update-ca-certificates
+
+This certificate is a wildcard for ``*.funkwhale.test``
+
+Run a reverse proxy for your instances
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+Create docker network
+^^^^^^^^^^^^^^^^^^^^
+
+Create the federation network::
+
+    docker network create federation
+
+Launch everything
+^^^^^^^^^^^^^^^^^
+
+Launch the traefik proxy::
+
+    docker-compose -f docker/traefik.yml up -d
+
+Then, in separate terminals, you can setup as many different instances as you
+need::
+
+    export COMPOSE_PROJECT_NAME=node2
+    docker-compose -f dev.yml run --rm api python manage.py migrate
+    docker-compose -f dev.yml run --rm api python manage.py createsuperuser
+    docker-compose -f dev.yml up nginx api front nginx api celeryworker
+
+Note that by default, if you don't export the COMPOSE_PROJECT_NAME,
+we will default to node1 as the name of your instance.
+
+Assuming your project name is ``node1``, your server will be reachable
+at ``https://node1.funkwhale.test/``. Not that you'll have to trust
+the SSL Certificate as it's self signed.
+
+When working on federation with traefik, ensure you have this in your ``env``::
+
+    # This will ensure we don't bind any port on the host, and thus enable
+    # multiple instances of funkwhale to be spawned concurrently.
+    WEBPACK_DEVSERVER_PORT_BINDING=
+    # This disable certificate verification
+    EXTERNAL_REQUESTS_VERIFY_SSL=false
+    # this ensure you don't have incorrect urls pointing to http resources
+    FUNKWHALE_PROTOCOL=https
+
+
+Typical workflow for a contribution
+-----------------------------------
+
+0. Fork the project if you did not already or if you do not have access to the main repository
+1. Checkout the development branch and pull most recent changes: ``git checkout develop && git pull``
+2. If working on an issue, assign yourself to the issue. Otherwise, consider open an issue before starting to work on something, especially for new features.
+3. Create a dedicated branch for your work ``42-awesome-fix``. It is good practice to prefix your branch name with the ID of the issue you are solving.
+4. Work on your stuff
+5. Commit small, atomic changes to make it easier to review your contribution
+6. Add a changelog fragment to summarize your changes: ``echo "Implemented awesome stuff (#42)" > changes/changelog.d/42.feature"``
+7. Push your branch
+8. Create your merge request
+9. Take a step back and enjoy, we're really grateful you did all of this and took the time to contribute!
+
+
+Internationalization
+--------------------
+
+When working on the front-end, any end-user string should be translated
+using either ``<i18next path="yourstring">`` or the ``$t('yourstring')``
+function.
+
+Extraction is done by calling ``yarn run i18n-extract``, which
+will pull all the strings from source files and put them in a PO file.
+
+Contributing to the API
+-----------------------
+
+Project structure
+^^^^^^^^^^^^^^^^^
+
+.. code-block:: shell
+
+    tree api -L 2 -d
+    api
+    ├── config          # configuration directory (settings, urls, wsgi server)
+    │   └── settings    # Django settings files
+    ├── funkwhale_api   # project directory, all funkwhale logic is here
+    ├── requirements    # python requirements files
+    └── tests           # test files, matches the structure of the funkwhale_api directory
+
+.. note::
+
+    Unless trivial, API contributions must include unittests to ensure
+    your fix or feature is working as expected and won't break in the future
+
+Running tests
+^^^^^^^^^^^^^
+
+To run the pytest test suite, use the following command::
+
+    docker-compose -f dev.yml run --rm api pytest
+
+This is regular pytest, so you can use any arguments/options that pytest usually accept::
+
+    # get some help
+    docker-compose -f dev.yml run --rm api pytest -h
+    # Stop on first failure
+    docker-compose -f dev.yml run --rm api pytest -x
+    # Run a specific test file
+    docker-compose -f dev.yml run --rm api pytest tests/test_acoustid.py
+
+Writing tests
+^^^^^^^^^^^^^
+
+Although teaching you how to write unit tests is outside of the scope of this
+document, you'll find below a collection of tips, snippets and resources
+you can use if you want to learn on that subject.
+
+Useful links:
+
+- `A quick introduction to unit test writing with pytest <https://semaphoreci.com/community/tutorials/testing-python-applications-with-pytest>`_
+- `A complete guide to Test-Driven Development (although not using Pytest) <https://www.obeythetestinggoat.com/>`_
+- `pytest <https://docs.pytest.org/en/latest/>`_: documentation of our testing engine and runner
+- `pytest-mock <https://pypi.org/project/pytest-mock/>`_: project page of our mocking engine
+- `factory-boy <http://factoryboy.readthedocs.io/>`_: documentation of factory-boy, which we use to easily generate fake objects and data
+
+Recommendations:
+
+- Test files must target a module and mimic ``funkwhale_api`` directory structure: if you're writing tests for ``funkwhale_api/myapp/views.py``, you should put thoses tests in ``tests/myapp/test_views.py``
+- Tests should be small and test one thing. If you need to test multiple things, write multiple tests.
+
+We provide a lot of utils and fixtures to make the process of writing tests as
+painless as possible. You'll find some usage examples below.
+
+Use factories to create arbitrary objects:
+
+.. code-block:: python
+
+    # funkwhale_api/myapp/users.py
+
+    def downgrade_user(user):
+        """
+        A simple function that remove superuser status from users
+        and return True if user was actually downgraded
+        """
+        downgraded = user.is_superuser
+        user.is_superuser = False
+        user.save()
+        return downgraded
+
+    # tests/myapp/test_users.py
+    from funkwhale_api.myapp import users
+
+    def test_downgrade_superuser(factories):
+        user = factories['users.User'](is_superuser=True)
+        downgraded = users.downgrade_user(user)
+
+        assert downgraded is True
+        assert user.is_superuser is False
+
+    def test_downgrade_normal_user_does_nothing(factories):
+        user = factories['users.User'](is_superuser=False)
+        downgraded = something.downgrade_user(user)
+
+        assert downgraded is False
+        assert user.is_superuser is False
+
+.. note::
+
+    We offer factories for almost if not all models. Factories are located
+    in a ``factories.py`` file inside each app.
+
+Mocking: mocking is the process of faking some logic in our code. This is
+useful when testing components that depend on each other:
+
+.. code-block:: python
+
+    # funkwhale_api/myapp/notifications.py
+
+    def notify(email, message):
+        """
+        A function that sends an email to the given recipient
+        with the given message
+        """
+
+        # our email sending logic here
+        # ...
+
+    # funkwhale_api/myapp/users.py
+    from . import notifications
+
+    def downgrade_user(user):
+        """
+        A simple function that remove superuser status from users
+        and return True if user was actually downgraded
+        """
+        downgraded = user.is_superuser
+        user.is_superuser = False
+        user.save()
+        if downgraded:
+            notifications.notify(user.email, 'You have been downgraded!')
+        return downgraded
+
+    # tests/myapp/test_users.py
+    def test_downgrade_superuser_sends_email(factories, mocker):
+        """
+        Your downgrade logic is already tested, however, we want to ensure
+        an email is sent when user is downgraded, but we don't have any email
+        server available in our testing environment. Thus, we need to mock
+        the email sending process.
+        """
+        mocked_notify = mocker.patch('funkwhale_api.myapp.notifications.notify')
+        user = factories['users.User'](is_superuser=True)
+        users.downgrade_user(user)
+
+        # here, we ensure our notify function was called with proper arguments
+        mocked_notify.assert_called_once_with(user.email, 'You have been downgraded')
+
+
+    def test_downgrade_not_superuser_skips_email(factories, mocker):
+        mocked_notify = mocker.patch('funkwhale_api.myapp.notifications.notify')
+        user = factories['users.User'](is_superuser=True)
+        users.downgrade_user(user)
+
+        # here, we ensure no email was sent
+        mocked_notify.assert_not_called()
+
+Views: you can find some readable views tests in :file:`tests/users/test_views.py`
+
+.. note::
+
+    A complete list of available-fixtures is available by running
+    ``docker-compose -f dev.yml run --rm api pytest --fixtures``
+
+
+Contributing to the front-end
+-----------------------------
+
+Running tests
+^^^^^^^^^^^^^
+
+To run the front-end test suite, use the following command::
+
+    docker-compose -f dev.yml run --rm front yarn run unit
+
+We also support a "watch and test" mode were we continually relaunch
+tests when changes are recorded on the file system::
+
+    docker-compose -f dev.yml run --rm front yarn run unit-watch
+
+The latter is especially useful when you are debugging failing tests.
+
+.. note::
+
+    The front-end test suite coverage is still pretty low
diff --git a/README.rst b/README.rst
index 8a0ea49320bb8f63115219c462bb49f3c4355d99..8646527ad63be971f9d51cf233b48232d766f381 100644
--- a/README.rst
+++ b/README.rst
@@ -1,6 +1,10 @@
 Funkwhale
 =============
 
+.. image:: ./front/src/assets/logo/logo-full-500.png
+  :alt: Funkwhale logo
+  :target: https://funkwhale.audio
+
 A self-hosted tribute to Grooveshark.com.
 
 LICENSE: BSD
@@ -8,289 +12,18 @@ LICENSE: BSD
 Getting help
 ------------
 
-We offer various Matrix.org rooms to discuss about funkwhale:
+We offer various Matrix.org rooms to discuss about Funkwhale:
 
 - `#funkwhale:matrix.org <https://riot.im/app/#/room/#funkwhale:matrix.org>`_ for general questions about funkwhale
 - `#funkwhale-dev:matrix.org <https://riot.im/app/#/room/#funkwhale-dev:matrix.org>`_ for development-focused discussion
 
 Please join those rooms if you have any questions!
 
-Running the development version
--------------------------------
-
-If you want to fix a bug or implement a feature, you'll need
-to run a local, development copy of funkwhale.
-
-We provide a docker based development environment, which should
-be both easy to setup and work similarly regardless of your
-development machine setup.
-
-Instructions for bare-metal setup will come in the future (Merge requests
-are welcome).
-
-Installing docker and docker-compose
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-This is already cover in the relevant documentations:
-
-- https://docs.docker.com/install/
-- https://docs.docker.com/compose/install/
-
-Cloning the project
-^^^^^^^^^^^^^^^^^^^
-
-Visit https://code.eliotberriot.com/funkwhale/funkwhale and clone the repository using SSH or HTTPS. Exemple using SSH::
-
-    git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
-    cd funkwhale
-
-
-A note about branches
-^^^^^^^^^^^^^^^^^^^^^
-
-Next release development occurs on the "develop" branch, and releases are made on the "master" branch. Therefor, when submitting Merge Requests, ensure you are merging on the develop branch.
-
-
-Working with docker
-^^^^^^^^^^^^^^^^^^^
-
-In developpement, we use the docker-compose file named ``dev.yml``, and this is why all our docker-compose commands will look like this::
-
-    docker-compose -f dev.yml logs
-
-If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this command before starting your work::
-
-    export COMPOSE_FILE=dev.yml
-
-
-Building the containers
-^^^^^^^^^^^^^^^^^^^^^^^
-
-On your initial clone, or if there have been some changes in the
-app dependencies, you will have to rebuild your containers. This is done
-via the following command::
-
-    docker-compose -f dev.yml build
-
-
-Creating your env file
-^^^^^^^^^^^^^^^^^^^^^^
-
-We provide a working .env.dev configuration file that is suitable for
-development. However, to enable customization on your machine, you should
-also create a .env file that will hold your personal environment
-variables (those will not be commited to the project).
-
-Create it like this::
-
-    touch .env
-
-
-Database management
-^^^^^^^^^^^^^^^^^^^
-
-To setup funkwhale's database schema, run this::
-
-    docker-compose -f dev.yml run --rm api python manage.py migrate
-
-This will create all the tables needed for the API to run proprely.
-You will also need to run this whenever changes are made on the database
-schema.
-
-It is safe to run this command multiple times, so you can run it whenever
-you fetch develop.
-
-
-Development data
-^^^^^^^^^^^^^^^^
-
-You'll need at least an admin user and some artists/tracks/albums to work
-locally.
-
-Create an admin user with the following command::
-
-    docker-compose -f dev.yml run --rm api python manage.py createsuperuser
-
-Injecting fake data is done by running the fllowing script::
-
-    artists=25
-    command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)"
-    echo $command | docker-compose -f dev.yml run --rm api python manage.py shell -i python
-
-The previous command will create 25 artists with random albums, tracks
-and metadata.
-
-
-Launch all services
-^^^^^^^^^^^^^^^^^^^
-
-Then you can run everything with::
-
-    docker-compose -f dev.yml up
-
-This will launch all services, and output the logs in your current terminal window.
-If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``.
-
-Once everything is up, you can access the various funkwhale's components:
-
-- The Vue webapp, on http://localhost:8080
-- The API, on http://localhost:8080/api/v1/
-- The django admin, on http://localhost:8080/api/admin/
-
-
-Running API tests
-^^^^^^^^^^^^^^^^^
-
-To run the pytest test suite, use the following command::
-
-    docker-compose -f dev.yml run --rm api pytest
-
-This is regular pytest, so you can use any arguments/options that pytest usually accept::
-
-    # get some help
-    docker-compose -f dev.yml run --rm api pytest -h
-    # Stop on first failure
-    docker-compose -f dev.yml run --rm api pytest -x
-    # Run a specific test file
-    docker-compose -f dev.yml run --rm api pytest tests/test_acoustid.py
-
-
-Running front-end tests
-^^^^^^^^^^^^^^^^^^^^^^^
-
-To run the front-end test suite, use the following command::
-
-    docker-compose -f dev.yml run --rm front yarn run unit
-
-We also support a "watch and test" mode were we continually relaunch
-tests when changes are recorded on the file system::
-
-    docker-compose -f dev.yml run --rm front yarn run unit-watch
-
-The latter is especially useful when you are debugging failing tests.
-
-.. note::
-
-    The front-end test suite coverage is still pretty low
-
-
-Stopping everything
-^^^^^^^^^^^^^^^^^^^
-
-Once you're down with your work, you can stop running containers, if any, with::
-
-    docker-compose -f dev.yml stop
-
-
-Removing everything
-^^^^^^^^^^^^^^^^^^^
-
-If you want to wipe your development environment completely (e.g. if you want to start over from scratch), just run::
-
-    docker-compose -f dev.yml down -v
-
-This will wipe your containers and data, so please be careful before running it.
-
-You can keep your data by removing the ``-v`` flag.
-
-
-Typical workflow for a merge request
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-0. Fork the project if you did not already or if you do not have access to the main repository
-1. Checkout the development branch and pull most recent changes: ``git checkout develop && git pull``
-2. Create a dedicated branch for your work ``42-awesome-fix``. It is good practice to prefix your branch name with the ID of the issue you are solving.
-3. Work on your stuff
-4. Commit small, atomic changes to make it easier to review your contribution
-5. Add a changelog fragment to summarize your changes: ``echo "Implemented awesome stuff (#42)" > changes/changelog.d/42.feature"``
-6. Push your branch
-7. Create your merge request
-8. Take a step back and enjoy, we're really grateful you did all of this and took the time to contribute!
-
-
-Internationalization
---------------------
-
-When working on the front-end, any end-user string should be translated
-using either ``<i18next path="yourstring">`` or the ``$t('yourstring')``
-function.
-
-Extraction is done by calling ``yarn run i18n-extract``, which
-will pull all the strings from source files and put them in a PO file.
-
-
-Working with federation locally
--------------------------------
-
-To achieve that, you'll need:
-
-1. to update your dns resolver to resolve all your .dev hostnames locally
-2. a reverse proxy (such as traefik) to catch those .dev requests and
-   and with https certificate
-3. two instances (or more) running locally, following the regular dev setup
-
-Resolve .dev names locally
-^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-If you use dnsmasq, this is as simple as doing::
-
-    echo "address=/test/172.17.0.1" | sudo tee /etc/dnsmasq.d/test.conf
-    sudo systemctl restart dnsmasq
-
-If you use NetworkManager with dnsmasq integration, use this instead::
-
-    echo "address=/test/172.17.0.1" | sudo tee /etc/NetworkManager/dnsmasq.d/test.conf
-    sudo systemctl restart NetworkManager
-
-Add wildcard certificate to the trusted certificates
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-Simply copy bundled certificates::
-
-    sudo cp docker/ssl/test.crt /usr/local/share/ca-certificates/
-    sudo update-ca-certificates
-
-This certificate is a wildcard for ``*.funkwhale.test``
-
-Run a reverse proxy for your instances
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-
-Create docker network
-^^^^^^^^^^^^^^^^^^^^
-
-Create the federation network::
-
-    docker network create federation
-
-Launch everything
-^^^^^^^^^^^^^^^^^
-
-Launch the traefik proxy::
-
-    docker-compose -f docker/traefik.yml up -d
-
-Then, in separate terminals, you can setup as many different instances as you
-need::
-
-    export COMPOSE_PROJECT_NAME=node2
-    docker-compose -f dev.yml run --rm api python manage.py migrate
-    docker-compose -f dev.yml run --rm api python manage.py createsuperuser
-    docker-compose -f dev.yml up nginx api front nginx api celeryworker
-
-Note that by default, if you don't export the COMPOSE_PROJECT_NAME,
-we will default to node1 as the name of your instance.
+You can also contact `@funkwhale@mastodon.eliotberriot.com <https://mastodon.eliotberriot.com/@funkwhale>`_ on the fediverse.
 
-Assuming your project name is ``node1``, your server will be reachable
-at ``https://node1.funkwhale.test/``. Not that you'll have to trust
-the SSL Certificate as it's self signed.
 
-When working on federation with traefik, ensure you have this in your ``env``::
+Contribute
+----------
 
-    # This will ensure we don't bind any port on the host, and thus enable
-    # multiple instances of funkwhale to be spawned concurrently.
-    WEBPACK_DEVSERVER_PORT_BINDING=
-    # This disable certificate verification
-    EXTERNAL_REQUESTS_VERIFY_SSL=false
-    # this ensure you don't have incorrect urls pointing to http resources
-    FUNKWHALE_PROTOCOL=https
+Contribution guidelines as well as development installation instructions
+are outlined in `CONTRIBUTING <CONTRIBUTING>`_
diff --git a/api/compose/django/daphne.sh b/api/compose/django/daphne.sh
index 4fa3041436e11e4bfb4a2740223b37060a018bb4..3ceb19e96edc67a2d744a749be605ebd04a2279a 100755
--- a/api/compose/django/daphne.sh
+++ b/api/compose/django/daphne.sh
@@ -1,3 +1,3 @@
 #!/bin/bash -eux
 python /app/manage.py collectstatic --noinput
-/usr/local/bin/daphne -b 0.0.0.0 -p 5000 config.asgi:application
+/usr/local/bin/daphne -b 0.0.0.0 -p 5000 config.asgi:application --proxy-headers
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index de1d653cb91fa3518273b8b103ed74fdee4b9259..f88aa5dd549fe09afd1c41999180e481dd2f151a 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -48,14 +48,20 @@ else:
 FUNKWHALE_URL = '{}://{}'.format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
 
 
+# XXX: deprecated, see #186
 FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
 FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
+# XXX: deprecated, see #186
 FEDERATION_COLLECTION_PAGE_SIZE = env.int(
     'FEDERATION_COLLECTION_PAGE_SIZE', default=50
 )
+# XXX: deprecated, see #186
 FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool(
     'FEDERATION_MUSIC_NEEDS_APPROVAL', default=True
 )
+# XXX: deprecated, see #186
+FEDERATION_ACTOR_FETCH_DELAY = env.int(
+    'FEDERATION_ACTOR_FETCH_DELAY', default=60 * 12)
 ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
 
 # APP CONFIGURATION
@@ -138,7 +144,6 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
 MIDDLEWARE = (
     # Make sure djangosecure.middleware.SecurityMiddleware is listed first
     'django.contrib.sessions.middleware.SessionMiddleware',
-    'funkwhale_api.users.middleware.AnonymousSessionMiddleware',
     'corsheaders.middleware.CorsMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
@@ -167,7 +172,22 @@ FIXTURE_DIRS = (
 
 # EMAIL CONFIGURATION
 # ------------------------------------------------------------------------------
-EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend')
+
+# EMAIL
+# ------------------------------------------------------------------------------
+DEFAULT_FROM_EMAIL = env(
+    'DEFAULT_FROM_EMAIL',
+    default='Funkwhale <noreply@{}>'.format(FUNKWHALE_HOSTNAME))
+
+EMAIL_SUBJECT_PREFIX = env(
+    "EMAIL_SUBJECT_PREFIX", default='[Funkwhale] ')
+SERVER_EMAIL = env('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
+
+
+EMAIL_CONFIG = env.email_url(
+    'EMAIL_CONFIG', default='consolemail://')
+
+vars().update(EMAIL_CONFIG)
 
 # DATABASE CONFIGURATION
 # ------------------------------------------------------------------------------
@@ -287,7 +307,7 @@ AUTHENTICATION_BACKENDS = (
     'django.contrib.auth.backends.ModelBackend',
     'allauth.account.auth_backends.AuthenticationBackend',
 )
-
+SESSION_COOKIE_HTTPONLY = False
 # Some really nice defaults
 ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
 ACCOUNT_EMAIL_REQUIRED = True
@@ -362,7 +382,7 @@ CORS_ORIGIN_ALLOW_ALL = True
 #     'funkwhale.localhost',
 # )
 CORS_ALLOW_CREDENTIALS = True
-API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
+
 REST_FRAMEWORK = {
     'DEFAULT_PERMISSION_CLASSES': (
         'rest_framework.permissions.IsAuthenticated',
@@ -377,6 +397,7 @@ REST_FRAMEWORK = {
     ),
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
+        'funkwhale_api.common.authentication.BearerTokenHeaderAuth',
         'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
         'rest_framework.authentication.SessionAuthentication',
         'rest_framework.authentication.BasicAuthentication',
@@ -386,6 +407,11 @@ REST_FRAMEWORK = {
         'django_filters.rest_framework.DjangoFilterBackend',
     )
 }
+REST_AUTH_SERIALIZERS = {
+    'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer'  # noqa
+}
+REST_SESSION_LOGIN = False
+REST_USE_JWT = True
 
 ATOMIC_REQUESTS = False
 USE_X_FORWARDED_HOST = True
@@ -428,6 +454,7 @@ ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
 CSRF_USE_SESSIONS = True
 
 # Playlist settings
+# XXX: deprecated, see #186
 PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
 
 ACCOUNT_USERNAME_BLACKLIST = [
@@ -447,6 +474,8 @@ EXTERNAL_REQUESTS_VERIFY_SSL = env.bool(
     'EXTERNAL_REQUESTS_VERIFY_SSL',
     default=True
 )
+# XXX: deprecated, see #186
+API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
 
 MUSIC_DIRECTORY_PATH = env('MUSIC_DIRECTORY_PATH', default=None)
 # on Docker setup, the music directory may not match the host path,
diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index dcbea66d26134664655d1ca0978e3121c5da5d96..59260062985cf1e3c554bfe4459d237f5d981e11 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -25,9 +25,6 @@ SECRET_KEY = env("DJANGO_SECRET_KEY", default='mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0
 # ------------------------------------------------------------------------------
 EMAIL_HOST = 'localhost'
 EMAIL_PORT = 1025
-EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
-                    default='django.core.mail.backends.console.EmailBackend')
-
 
 # django-debug-toolbar
 # ------------------------------------------------------------------------------
diff --git a/api/config/settings/production.py b/api/config/settings/production.py
index f238c2d20b9e2de5a2baf8136aa7eca1f6fe40d8..2866e91039a81e9446885437694437f1ef16e79f 100644
--- a/api/config/settings/production.py
+++ b/api/config/settings/production.py
@@ -68,16 +68,6 @@ DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
 # ------------------------
 STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
 
-
-# EMAIL
-# ------------------------------------------------------------------------------
-DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL',
-                         default='funkwhale_api <noreply@funkwhale.io>')
-
-EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX", default='[funkwhale_api] ')
-SERVER_EMAIL = env('DJANGO_SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
-
-
 # TEMPLATE CONFIGURATION
 # ------------------------------------------------------------------------------
 # See:
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index 596926919170b9efbf5914a51010f9da8f67751d..4f62dd9b5b08542e8fa55eaae0920bda4edd6296 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.10'
+__version__ = '0.11'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/common/auth.py b/api/funkwhale_api/common/auth.py
index 75839b93662bfb0eb19a78d3b108c5c3690e5e5d..faf13571d6cd73208530a479ed92364989e304ad 100644
--- a/api/funkwhale_api/common/auth.py
+++ b/api/funkwhale_api/common/auth.py
@@ -29,9 +29,6 @@ class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
 
 
 class TokenAuthMiddleware:
-    """
-    Custom middleware (insecure) that takes user IDs from the query string.
-    """
 
     def __init__(self, inner):
         # Store the ASGI application we were passed
diff --git a/api/funkwhale_api/common/authentication.py b/api/funkwhale_api/common/authentication.py
index b75f3b516d5d2505f40900f7314f1f70e557ecb2..c7566eac8bd0f3d5c115a3a5d6b16dd3aec5dd9e 100644
--- a/api/funkwhale_api/common/authentication.py
+++ b/api/funkwhale_api/common/authentication.py
@@ -1,3 +1,6 @@
+from django.utils.encoding import smart_text
+from django.utils.translation import ugettext as _
+
 from rest_framework import exceptions
 from rest_framework_jwt import authentication
 from rest_framework_jwt.settings import api_settings
@@ -18,3 +21,37 @@ class JSONWebTokenAuthenticationQS(
     def authenticate_header(self, request):
         return '{0} realm="{1}"'.format(
             api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm)
+
+
+class BearerTokenHeaderAuth(
+        authentication.BaseJSONWebTokenAuthentication):
+    """
+    For backward compatibility purpose, we used Authorization: JWT <token>
+    but Authorization: Bearer <token> is probably better.
+    """
+    www_authenticate_realm = 'api'
+
+    def get_jwt_value(self, request):
+        auth = authentication.get_authorization_header(request).split()
+        auth_header_prefix = 'bearer'
+
+        if not auth:
+            if api_settings.JWT_AUTH_COOKIE:
+                return request.COOKIES.get(api_settings.JWT_AUTH_COOKIE)
+            return None
+
+        if smart_text(auth[0].lower()) != auth_header_prefix:
+            return None
+
+        if len(auth) == 1:
+            msg = _('Invalid Authorization header. No credentials provided.')
+            raise exceptions.AuthenticationFailed(msg)
+        elif len(auth) > 2:
+            msg = _('Invalid Authorization header. Credentials string '
+                    'should not contain spaces.')
+            raise exceptions.AuthenticationFailed(msg)
+
+        return auth[1]
+
+    def authenticate_header(self, request):
+        return '{0} realm="{1}"'.format('Bearer', self.www_authenticate_realm)
diff --git a/api/funkwhale_api/common/dynamic_preferences_registry.py b/api/funkwhale_api/common/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..2374de7c79fe841ae63d8c18354335cf6fd022b2
--- /dev/null
+++ b/api/funkwhale_api/common/dynamic_preferences_registry.py
@@ -0,0 +1,20 @@
+from dynamic_preferences import types
+from dynamic_preferences.registries import global_preferences_registry
+
+from funkwhale_api.common import preferences
+
+common = types.Section('common')
+
+
+@global_preferences_registry.register
+class APIAutenticationRequired(
+        preferences.DefaultFromSettingMixin, types.BooleanPreference):
+    section = common
+    name = 'api_authentication_required'
+    verbose_name = 'API Requires authentication'
+    setting = 'API_AUTHENTICATION_REQUIRED'
+    help_text = (
+        'If disabled, anonymous users will be able to query the API'
+        'and access music data (as well as other data exposed in the API '
+        'without specific permissions)'
+    )
diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py
index c99c275c1f636b292c71ab1abb427daa658566fb..cab4b699d2a518cffc52aaf0cbad4473dcc28a6f 100644
--- a/api/funkwhale_api/common/permissions.py
+++ b/api/funkwhale_api/common/permissions.py
@@ -5,11 +5,13 @@ from django.http import Http404
 
 from rest_framework.permissions import BasePermission, DjangoModelPermissions
 
+from funkwhale_api.common import preferences
+
 
 class ConditionalAuthentication(BasePermission):
 
     def has_permission(self, request, view):
-        if settings.API_AUTHENTICATION_REQUIRED:
+        if preferences.get('common__api_authentication_required'):
             return request.user and request.user.is_authenticated
         return True
 
diff --git a/api/funkwhale_api/common/preferences.py b/api/funkwhale_api/common/preferences.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6eb8bedaaa140a6fc496258d839a242e7e9e213
--- /dev/null
+++ b/api/funkwhale_api/common/preferences.py
@@ -0,0 +1,12 @@
+from django.conf import settings
+from dynamic_preferences.registries import global_preferences_registry
+
+
+class DefaultFromSettingMixin(object):
+    def get_default(self):
+        return getattr(settings, self.setting)
+
+
+def get(pref):
+    manager = global_preferences_registry.manager()
+    return manager[pref]
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
index 380bb23c01e38e0651e3a3fc7ee0244025adcf34..7a209b1ff4cca48028a094685bff8eb258652f89 100644
--- a/api/funkwhale_api/federation/actors.py
+++ b/api/funkwhale_api/federation/actors.py
@@ -1,3 +1,4 @@
+import datetime
 import logging
 import uuid
 import xml
@@ -11,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
 
 from dynamic_preferences.registries import global_preferences_registry
 
+from funkwhale_api.common import preferences
 from funkwhale_api.common import session
 from funkwhale_api.common import utils as funkwhale_utils
 from funkwhale_api.music import models as music_models
@@ -49,11 +51,20 @@ def get_actor_data(actor_url):
 
 
 def get_actor(actor_url):
+    try:
+        actor = models.Actor.objects.get(url=actor_url)
+    except models.Actor.DoesNotExist:
+        actor = None
+    fetch_delta = datetime.timedelta(
+        minutes=preferences.get('federation__actor_fetch_delay'))
+    if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
+        # cache is hot, we can return as is
+        return actor
     data = get_actor_data(actor_url)
     serializer = serializers.ActorSerializer(data=data)
     serializer.is_valid(raise_exception=True)
 
-    return serializer.build()
+    return serializer.save(last_fetch_date=timezone.now())
 
 
 class SystemActor(object):
@@ -215,7 +226,7 @@ class LibraryActor(SystemActor):
 
     @property
     def manually_approves_followers(self):
-        return settings.FEDERATION_MUSIC_NEEDS_APPROVAL
+        return preferences.get('federation__music_needs_approval')
 
     @transaction.atomic
     def handle_create(self, ac, sender):
diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py
index 7f8ad6653995b4a0f3efd72dc82e4303da5d8760..bfd46084c0dbd2e7d4b8fd3dac67a837809586bf 100644
--- a/api/funkwhale_api/federation/authentication.py
+++ b/api/funkwhale_api/federation/authentication.py
@@ -25,28 +25,19 @@ class SignatureAuthentication(authentication.BaseAuthentication):
             raise exceptions.AuthenticationFailed(str(e))
 
         try:
-            actor_data = actors.get_actor_data(key_id)
+            actor = actors.get_actor(key_id.split('#')[0])
         except Exception as e:
             raise exceptions.AuthenticationFailed(str(e))
 
-        try:
-            public_key = actor_data['publicKey']['publicKeyPem']
-        except KeyError:
+        if not actor.public_key:
             raise exceptions.AuthenticationFailed('No public key found')
 
-        serializer = serializers.ActorSerializer(data=actor_data)
-        if not serializer.is_valid():
-            raise exceptions.AuthenticationFailed('Invalid actor payload: {}'.format(serializer.errors))
-
         try:
-            signing.verify_django(request, public_key.encode('utf-8'))
+            signing.verify_django(request, actor.public_key.encode('utf-8'))
         except cryptography.exceptions.InvalidSignature:
             raise exceptions.AuthenticationFailed('Invalid signature')
 
-        try:
-            return models.Actor.objects.get(url=actor_data['id'])
-        except models.Actor.DoesNotExist:
-            return serializer.save()
+        return actor
 
     def authenticate(self, request):
         setattr(request, 'actor', None)
diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py
index 43877c75c9717eda3c46c0f039366803aadd77c9..e86b9f6f2b8b30da1c82145ef288e591449aa9f3 100644
--- a/api/funkwhale_api/federation/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py
@@ -3,6 +3,7 @@ from django.forms import widgets
 from dynamic_preferences import types
 from dynamic_preferences.registries import global_preferences_registry
 
+from funkwhale_api.common import preferences
 federation = types.Section('federation')
 
 
@@ -18,3 +19,53 @@ class MusicCacheDuration(types.IntPreference):
         'locally? Federated files that were not listened in this interval '
         'will be erased and refetched from the remote on the next listening.'
     )
+
+
+@global_preferences_registry.register
+class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
+    section = federation
+    name = 'enabled'
+    setting = 'FEDERATION_ENABLED'
+    verbose_name = 'Federation enabled'
+    help_text = (
+        'Use this setting to enable or disable federation logic and API'
+        ' globally'
+    )
+
+
+@global_preferences_registry.register
+class CollectionPageSize(
+        preferences.DefaultFromSettingMixin, types.IntPreference):
+    section = federation
+    name = 'collection_page_size'
+    setting = 'FEDERATION_COLLECTION_PAGE_SIZE'
+    verbose_name = 'Federation collection page size'
+    help_text = (
+        'How much items to display in ActivityPub collections'
+    )
+
+
+@global_preferences_registry.register
+class ActorFetchDelay(
+        preferences.DefaultFromSettingMixin, types.IntPreference):
+    section = federation
+    name = 'actor_fetch_delay'
+    setting = 'FEDERATION_ACTOR_FETCH_DELAY'
+    verbose_name = 'Federation actor fetch delay'
+    help_text = (
+        'How much minutes to wait before refetching actors on '
+        'request authentication'
+    )
+
+
+@global_preferences_registry.register
+class MusicNeedsApproval(
+        preferences.DefaultFromSettingMixin, types.BooleanPreference):
+    section = federation
+    name = 'music_needs_approval'
+    setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL'
+    verbose_name = 'Federation music needs approval'
+    help_text = (
+        'When true, other federation actors will require your approval'
+        ' before being able to browse your library.'
+    )
diff --git a/api/funkwhale_api/federation/permissions.py b/api/funkwhale_api/federation/permissions.py
index c6f0660b198e88037e0480a0e88fa4cbadb7ad9d..438b675cb300a6ba1607e24c639189224488cced 100644
--- a/api/funkwhale_api/federation/permissions.py
+++ b/api/funkwhale_api/federation/permissions.py
@@ -2,13 +2,14 @@ from django.conf import settings
 
 from rest_framework.permissions import BasePermission
 
+from funkwhale_api.common import preferences
 from . import actors
 
 
 class LibraryFollower(BasePermission):
 
     def has_permission(self, request, view):
-        if not settings.FEDERATION_MUSIC_NEEDS_APPROVAL:
+        if not preferences.get('federation__music_needs_approval'):
             return True
 
         actor = getattr(request, 'actor', None)
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 00bb7d45b0b1b98176d74f58075866392a53898c..426aabd771b1e5caaaa683648a123ccbe00aa986 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -1,3 +1,4 @@
+import logging
 import urllib.parse
 
 from django.urls import reverse
@@ -21,6 +22,8 @@ AP_CONTEXT = [
     {},
 ]
 
+logger = logging.getLogger(__name__)
+
 
 class ActorSerializer(serializers.Serializer):
     id = serializers.URLField()
@@ -100,9 +103,10 @@ class ActorSerializer(serializers.Serializer):
     def save(self, **kwargs):
         d = self.prepare_missing_fields()
         d.update(kwargs)
-        return models.Actor.objects.create(
-            **d
-        )
+        return models.Actor.objects.update_or_create(
+            url=d['url'],
+            defaults=d,
+        )[0]
 
     def validate_summary(self, value):
         if value:
@@ -620,6 +624,8 @@ class CollectionPageSerializer(serializers.Serializer):
         for i in raw_items:
             if i.is_valid():
                 valid_items.append(i)
+            else:
+                logger.debug('Invalid item %s: %s', i.data, i.errors)
 
         return valid_items
 
diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py
index adc354c4fdaba7f1195e960246e6ff2f6161b107..8f931b0ed741ff13017475c1ac47411ff67e5df0 100644
--- a/api/funkwhale_api/federation/tasks.py
+++ b/api/funkwhale_api/federation/tasks.py
@@ -1,8 +1,10 @@
 import datetime
 import json
 import logging
+import os
 
 from django.conf import settings
+from django.db.models import Q
 from django.utils import timezone
 
 from requests.exceptions import RequestException
@@ -96,16 +98,38 @@ def clean_music_cache():
     delay = preferences['federation__music_cache_duration']
     if delay < 1:
         return  # cache clearing disabled
+    limit = timezone.now() - datetime.timedelta(minutes=delay)
 
     candidates = models.LibraryTrack.objects.filter(
-        audio_file__isnull=False
-    ).values_list('local_track_file__track', flat=True)
-    listenings = Listening.objects.filter(
-        creation_date__gte=timezone.now() - datetime.timedelta(minutes=delay),
-        track__pk__in=candidates).values_list('track', flat=True)
-    too_old = set(candidates) - set(listenings)
-
-    to_remove = models.LibraryTrack.objects.filter(
-        local_track_file__track__pk__in=too_old).only('audio_file')
-    for lt in to_remove:
+        Q(audio_file__isnull=False) & (
+            Q(local_track_file__accessed_date__lt=limit) |
+            Q(local_track_file__accessed_date=None)
+        )
+    ).exclude(audio_file='').only('audio_file', 'id')
+    for lt in candidates:
         lt.audio_file.delete()
+
+    # we also delete orphaned files, if any
+    storage = models.LibraryTrack._meta.get_field('audio_file').storage
+    files = get_files(storage, 'federation_cache')
+    existing = models.LibraryTrack.objects.filter(audio_file__in=files)
+    missing = set(files) - set(existing.values_list('audio_file', flat=True))
+    for m in missing:
+        storage.delete(m)
+
+
+def get_files(storage, *parts):
+    """
+    This is a recursive function that return all files available
+    in a given directory using django's storage.
+    """
+    if not parts:
+        raise ValueError('Missing path')
+
+    dirs, files = storage.listdir(os.path.join(*parts))
+    for dir in dirs:
+        files += get_files(storage, *(list(parts) + [dir]))
+    return [
+        os.path.join(parts[-1], path)
+        for path in files
+    ]
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 381f87eff2e90f4a64feade856756044393d4f4d..9b51a534df506169be39b66d75becca94bb5d90c 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -13,6 +13,7 @@ from rest_framework import viewsets
 from rest_framework.decorators import list_route, detail_route
 from rest_framework.serializers import ValidationError
 
+from funkwhale_api.common import preferences
 from funkwhale_api.common import utils as funkwhale_utils
 from funkwhale_api.common.permissions import HasModelPermission
 from funkwhale_api.music.models import TrackFile
@@ -33,7 +34,7 @@ from . import webfinger
 
 class FederationMixin(object):
     def dispatch(self, request, *args, **kwargs):
-        if not settings.FEDERATION_ENABLED:
+        if not preferences.get('federation__enabled'):
             return HttpResponse(status=405)
         return super().dispatch(request, *args, **kwargs)
 
@@ -136,7 +137,8 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
         if page is None:
             conf = {
                 'id': utils.full_url(reverse('federation:music:files-list')),
-                'page_size': settings.FEDERATION_COLLECTION_PAGE_SIZE,
+                'page_size': preferences.get(
+                    'federation__collection_page_size'),
                 'items': qs,
                 'item_serializer': serializers.AudioSerializer,
                 'actor': library,
@@ -150,7 +152,7 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
                 return response.Response(
                     {'page': ['Invalid page number']}, status=400)
             p = paginator.Paginator(
-                qs, settings.FEDERATION_COLLECTION_PAGE_SIZE)
+                qs, preferences.get('federation__collection_page_size'))
             try:
                 page = p.page(page_number)
                 conf = {
diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py
index 762d5bf7b2cf66bdd9a96325c630db65a53ddaae..480461d35ed60fc0629e417879b6f75e27a95418 100644
--- a/api/funkwhale_api/history/models.py
+++ b/api/funkwhale_api/history/models.py
@@ -21,13 +21,6 @@ class Listening(models.Model):
     class Meta:
         ordering = ('-creation_date',)
 
-    def save(self, **kwargs):
-        if not self.user and not self.session_key:
-            raise ValidationError('Cannot have both session_key and user empty for listening')
-
-        super().save(**kwargs)
-
-
     def get_activity_url(self):
         return '{}/listenings/tracks/{}'.format(
             self.user.get_activity_url(), self.pk)
diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py
index 8fe6fa6e01f07a395f2c337ea45591bd315a03d3..f7333f24349777a6a41b15e4de0d6a282502aacd 100644
--- a/api/funkwhale_api/history/serializers.py
+++ b/api/funkwhale_api/history/serializers.py
@@ -36,13 +36,9 @@ class ListeningSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = models.Listening
-        fields = ('id', 'user', 'session_key', 'track', 'creation_date')
-
+        fields = ('id', 'user', 'track', 'creation_date')
 
     def create(self, validated_data):
-        if self.context.get('user'):
-            validated_data['user'] = self.context.get('user')
-        else:
-            validated_data['session_key'] = self.context['session_key']
+        validated_data['user'] = self.context['user']
 
         return super().create(validated_data)
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index d5cbe316ba88b455755cdbaf843086f7c99c09f3..bea96a4187792421bf6827717eb1032e9dcaacf3 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -1,4 +1,5 @@
 from rest_framework import generics, mixins, viewsets
+from rest_framework import permissions
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.decorators import detail_route
@@ -10,31 +11,26 @@ from funkwhale_api.music.serializers import TrackSerializerNested
 from . import models
 from . import serializers
 
-class ListeningViewSet(mixins.CreateModelMixin,
-                          mixins.RetrieveModelMixin,
-                          viewsets.GenericViewSet):
+
+class ListeningViewSet(
+        mixins.CreateModelMixin,
+        mixins.RetrieveModelMixin,
+        viewsets.GenericViewSet):
 
     serializer_class = serializers.ListeningSerializer
     queryset = models.Listening.objects.all()
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [permissions.IsAuthenticated]
 
     def perform_create(self, serializer):
         r = super().perform_create(serializer)
-        if self.request.user.is_authenticated:
-            record.send(serializer.instance)
+        record.send(serializer.instance)
         return r
 
     def get_queryset(self):
         queryset = super().get_queryset()
-        if self.request.user.is_authenticated:
-            return queryset.filter(user=self.request.user)
-        else:
-            return queryset.filter(session_key=self.request.session.session_key)
+        return queryset.filter(user=self.request.user)
 
     def get_serializer_context(self):
         context = super().get_serializer_context()
-        if self.request.user.is_authenticated:
-            context['user'] = self.request.user
-        else:
-            context['session_key'] = self.request.session.session_key
+        context['user'] = self.request.user
         return context
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index 752422e75e64aae20f19bc46ab00cd4678c220d6..6da9cca63636fd51c562f066c790c473093cffe3 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -20,6 +20,9 @@ class ListenableMixin(filters.FilterSet):
 
 
 class ArtistFilter(ListenableMixin):
+    q = fields.SearchFilter(search_fields=[
+        'name',
+    ])
 
     class Meta:
         model = models.Artist
diff --git a/api/funkwhale_api/music/management/__init__.py b/api/funkwhale_api/music/management/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/music/management/commands/__init__.py b/api/funkwhale_api/music/management/commands/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/music/management/commands/fix_track_files.py b/api/funkwhale_api/music/management/commands/fix_track_files.py
new file mode 100644
index 0000000000000000000000000000000000000000..f68bcf1359d4661710a98ee443ff1578824f46f2
--- /dev/null
+++ b/api/funkwhale_api/music/management/commands/fix_track_files.py
@@ -0,0 +1,45 @@
+import cacheops
+import os
+
+from django.db import transaction
+from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError
+
+from funkwhale_api.music import models, utils
+
+
+class Command(BaseCommand):
+    help = 'Run common checks and fix against imported tracks'
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            '--dry-run',
+            action='store_true',
+            dest='dry_run',
+            default=False,
+            help='Do not execute anything'
+        )
+
+    def handle(self, *args, **options):
+        if options['dry_run']:
+            self.stdout.write('Dry-run on, will not commit anything')
+        self.fix_mimetypes(**options)
+        cacheops.invalidate_model(models.TrackFile)
+
+    @transaction.atomic
+    def fix_mimetypes(self, dry_run, **kwargs):
+        self.stdout.write('Fixing missing mimetypes...')
+        matching = models.TrackFile.objects.filter(
+            source__startswith='file://', mimetype=None)
+        self.stdout.write(
+            '[mimetypes] {} entries found with no mimetype'.format(
+                matching.count()))
+        for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items():
+            qs = matching.filter(source__endswith='.{}'.format(extension))
+            self.stdout.write(
+                '[mimetypes] setting {} {} files to {}'.format(
+                    qs.count(), extension, mimetype
+                ))
+            if not dry_run:
+                self.stdout.write('[mimetypes] commiting...')
+                qs.update(mimetype=mimetype)
diff --git a/api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py b/api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d5327d93969e821a6fa3e20e55e56964fc1f841
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.3 on 2018-05-06 12:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0025_auto_20180419_2023'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='trackfile',
+            name='accessed_date',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 98fc1965b51dff90a9aaae1717e5dc7ca5032538..655d38755e1deba8e4bd3e99942c0ea7b586af5d 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -415,6 +415,7 @@ class TrackFile(models.Model):
     source = models.URLField(null=True, blank=True, max_length=500)
     creation_date = models.DateTimeField(default=timezone.now)
     modification_date = models.DateTimeField(auto_now=True)
+    accessed_date = models.DateTimeField(null=True, blank=True)
     duration = models.IntegerField(null=True, blank=True)
     acoustid_track_id = models.UUIDField(null=True, blank=True)
     mimetype = models.CharField(null=True, blank=True, max_length=200)
@@ -463,26 +464,6 @@ class TrackFile(models.Model):
             self.mimetype = utils.guess_mimetype(self.audio_file)
         return super().save(**kwargs)
 
-    @property
-    def serve_from_source_path(self):
-        if not self.source or not self.source.startswith('file://'):
-            raise ValueError('Cannot serve this file from source')
-        serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
-        prefix = settings.MUSIC_DIRECTORY_PATH
-        if not serve_path or not prefix:
-            raise ValueError(
-                'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
-                'MUSIC_DIRECTORY_PATH to serve in-place imported files'
-            )
-        file_path = self.source.replace('file://', '', 1)
-        parts = os.path.split(file_path.replace(prefix, '', 1))
-        if parts[0] == '/':
-            parts = parts[1:]
-        return os.path.join(
-            serve_path,
-            *parts
-        )
-
 
 IMPORT_STATUS_CHOICES = (
     ('pending', 'Pending'),
diff --git a/api/funkwhale_api/music/permissions.py b/api/funkwhale_api/music/permissions.py
index 61fc65bebf523f916483e67e2c7a311588bfc6de..77f95c477eb47e5cfbbdc9a13b31b2e9393cf767 100644
--- a/api/funkwhale_api/music/permissions.py
+++ b/api/funkwhale_api/music/permissions.py
@@ -2,6 +2,7 @@ from django.conf import settings
 
 from rest_framework.permissions import BasePermission
 
+from funkwhale_api.common import preferences
 from funkwhale_api.federation import actors
 from funkwhale_api.federation import models
 
@@ -12,6 +13,9 @@ class Listen(BasePermission):
         if not settings.PROTECT_AUDIO_FILES:
             return True
 
+        if not preferences.get('common__api_authentication_required'):
+            return True
+
         user = getattr(request, 'user', None)
         if user and user.is_authenticated:
             return True
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index b9ecfc50dcd982e109e369cc611656beba08c4b6..9dfc9147872871b0f0ba500c213f812b79bad8d7 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -1,4 +1,5 @@
 from django.db import transaction
+from django.db.models import Q
 from rest_framework import serializers
 from taggit.models import Tag
 
@@ -9,6 +10,7 @@ from funkwhale_api.federation.serializers import AP_CONTEXT
 from funkwhale_api.users.serializers import UserBasicSerializer
 
 from . import models
+from . import tasks
 
 
 class TagSerializer(serializers.ModelSerializer):
@@ -204,3 +206,33 @@ class SubmitFederationTracksSerializer(serializers.Serializer):
                 source=lt.url,
             )
         return batch
+
+
+class ImportJobRunSerializer(serializers.Serializer):
+    jobs = serializers.PrimaryKeyRelatedField(
+        many=True,
+        queryset=models.ImportJob.objects.filter(
+            status__in=['pending', 'errored']
+        )
+    )
+    batches = serializers.PrimaryKeyRelatedField(
+        many=True,
+        queryset=models.ImportBatch.objects.all()
+    )
+
+    def validate(self, validated_data):
+        jobs = validated_data['jobs']
+        batches_ids = [b.pk for b in validated_data['batches']]
+        query = Q(batch__pk__in=batches_ids)
+        query |= Q(pk__in=[j.id for j in jobs])
+        queryset = models.ImportJob.objects.filter(query).filter(
+            status__in=['pending', 'errored']).distinct()
+        validated_data['_jobs'] = queryset
+        return validated_data
+
+    def create(self, validated_data):
+        ids = validated_data['_jobs'].values_list('id', flat=True)
+        validated_data['_jobs'].update(status='pending')
+        for id in ids:
+            tasks.import_job_run.delay(import_job_id=id)
+        return {'jobs': list(ids)}
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index f2244d78527c5feff7248b9a40c083ea71498891..4509c9a57e6faf11b63d21c98af189a940c30677 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -1,7 +1,8 @@
-from django.core.files.base import ContentFile
+import os
 
-from dynamic_preferences.registries import global_preferences_registry
+from django.core.files.base import ContentFile
 
+from funkwhale_api.common import preferences
 from funkwhale_api.federation import activity
 from funkwhale_api.federation import actors
 from funkwhale_api.federation import models as federation_models
@@ -13,6 +14,7 @@ from funkwhale_api.providers.audiofile.tasks import import_track_data_from_path
 from django.conf import settings
 from . import models
 from . import lyrics as lyrics_utils
+from . import utils as music_utils
 
 
 @celery.app.task(name='acoustid.set_on_track_file')
@@ -77,8 +79,7 @@ def _do_import(import_job, replace=False, use_acoustid=True):
     acoustid_track_id = None
     duration = None
     track = None
-    manager = global_preferences_registry.manager()
-    use_acoustid = use_acoustid and manager['providers_acoustid__api_key']
+    use_acoustid = use_acoustid and preferences.get('providers_acoustid__api_key')
     if not mbid and use_acoustid and from_file:
         # we try to deduce mbid from acoustid
         client = get_acoustid_client()
@@ -129,6 +130,10 @@ def _do_import(import_job, replace=False, use_acoustid=True):
     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
         track_file.download_file()
+    elif not import_job.audio_file and import_job.source.startswith('file://'):
+        # in place import, we set mimetype from extension
+        path, ext = os.path.splitext(import_job.source)
+        track_file.mimetype = music_utils.get_type_from_ext(ext)
     track_file.save()
     import_job.status = 'finished'
     import_job.track_file = track_file
@@ -178,7 +183,7 @@ def fetch_content(lyrics):
 @celery.require_instance(
     models.ImportBatch.objects.filter(status='finished'), 'import_batch')
 def import_batch_notify_followers(import_batch):
-    if not settings.FEDERATION_ENABLED:
+    if not preferences.get('federation__enabled'):
         return
 
     if import_batch.source == 'federation':
diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py
index 7a851f7cc35e17681293a9e3a1c24d6cc1e64998..49a63930349a081178bc36c87e73a702e4d9faac 100644
--- a/api/funkwhale_api/music/utils.py
+++ b/api/funkwhale_api/music/utils.py
@@ -63,8 +63,21 @@ def compute_status(jobs):
     return 'finished'
 
 
+AUDIO_EXTENSIONS_AND_MIMETYPE = [
+    ('ogg', 'audio/ogg'),
+    ('mp3', 'audio/mpeg'),
+]
+
+EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE}
+MIMETYPE_TO_EXTENSION = {mt: ext for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE}
+
+
 def get_ext_from_type(mimetype):
-    mapping = {
-        'audio/ogg': 'ogg',
-        'audio/mpeg': 'mp3',
-    }
+    return MIMETYPE_TO_EXTENSION.get(mimetype)
+
+
+def get_type_from_ext(extension):
+    if extension.startswith('.'):
+        # we remove leading dot
+        extension = extension[1:]
+    return EXTENSION_TO_MIMETYPE.get(extension)
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index af063da4679e7df8c4e295dc19dbd03812a1c810..76fc8bc3e7fb257962c3388cd904223ff0c5d9d3 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -14,6 +14,7 @@ from django.db.models.functions import Length
 from django.db.models import Count
 from django.http import StreamingHttpResponse
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.decorators import method_decorator
 
 from rest_framework import viewsets, views, mixins
@@ -145,6 +146,14 @@ class ImportJobViewSet(
         data['count'] = sum([v for v in data.values()])
         return Response(data)
 
+    @list_route(methods=['post'])
+    def run(self, request, *args, **kwargs):
+        serializer = serializers.ImportJobRunSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        payload = serializer.save()
+
+        return Response(payload)
+
     def perform_create(self, serializer):
         source = 'file://' + serializer.validated_data['audio_file'].name
         serializer.save(source=source)
@@ -206,6 +215,8 @@ class TrackViewSet(
 
 
 def get_file_path(audio_file):
+    serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
+    prefix = settings.MUSIC_DIRECTORY_PATH
     t = settings.REVERSE_PROXY_TYPE
     if t == 'nginx':
         # we have to use the internal locations
@@ -213,14 +224,24 @@ def get_file_path(audio_file):
             path = audio_file.url
         except AttributeError:
             # a path was given
-            path = '/music' + audio_file
+            if not serve_path or not prefix:
+                raise ValueError(
+                    'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
+                    'MUSIC_DIRECTORY_PATH to serve in-place imported files'
+                )
+            path = '/music' + audio_file.replace(prefix, '', 1)
         return settings.PROTECT_FILES_PATH + path
     if t == 'apache2':
         try:
             path = audio_file.path
         except AttributeError:
             # a path was given
-            path = audio_file
+            if not serve_path or not prefix:
+                raise ValueError(
+                    'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
+                    'MUSIC_DIRECTORY_PATH to serve in-place imported files'
+                )
+            path = audio_file.replace(prefix, serve_path, 1)
         return path
 
 
@@ -244,6 +265,10 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
         except models.TrackFile.DoesNotExist:
             return Response(status=404)
 
+        # we update the accessed_date
+        f.accessed_date = timezone.now()
+        f.save(update_fields=['accessed_date'])
+
         mt = f.mimetype
         audio_file = f.audio_file
         try:
@@ -267,7 +292,7 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
         elif audio_file:
             file_path = get_file_path(audio_file)
         elif f.source and f.source.startswith('file://'):
-            file_path = get_file_path(f.serve_from_source_path)
+            file_path = get_file_path(f.source.replace('file://', '', 1))
         response = Response()
         filename = f.filename
         mapping = {
diff --git a/api/funkwhale_api/playlists/dynamic_preferences_registry.py b/api/funkwhale_api/playlists/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..21140fa1495adb734aa64e41be4bae218ba74304
--- /dev/null
+++ b/api/funkwhale_api/playlists/dynamic_preferences_registry.py
@@ -0,0 +1,15 @@
+from dynamic_preferences import types
+from dynamic_preferences.registries import global_preferences_registry
+
+from funkwhale_api.common import preferences
+
+playlists = types.Section('playlists')
+
+
+@global_preferences_registry.register
+class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference):
+    show_in_api = True
+    section = playlists
+    name = 'max_tracks'
+    verbose_name = 'Max tracks per playlist'
+    setting = 'PLAYLISTS_MAX_TRACKS'
diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py
index 6bb8fe17820a20b82b2a4468c2c0421350cfa53d..a208a5fd05e82258ad887b8f7dac06facf6f1167 100644
--- a/api/funkwhale_api/playlists/models.py
+++ b/api/funkwhale_api/playlists/models.py
@@ -6,6 +6,7 @@ from django.utils import timezone
 from rest_framework import exceptions
 
 from funkwhale_api.common import fields
+from funkwhale_api.common import preferences
 
 
 class Playlist(models.Model):
@@ -81,10 +82,11 @@ class Playlist(models.Model):
         existing = self.playlist_tracks.select_for_update()
         now = timezone.now()
         total = existing.filter(index__isnull=False).count()
-        if existing.count() + len(tracks) > settings.PLAYLISTS_MAX_TRACKS:
+        max_tracks = preferences.get('playlists__max_tracks')
+        if existing.count() + len(tracks) > max_tracks:
             raise exceptions.ValidationError(
                 'Playlist would reach the maximum of {} tracks'.format(
-                    settings.PLAYLISTS_MAX_TRACKS))
+                    max_tracks))
         self.save(update_fields=['modification_date'])
         start = total
         plts = [
diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py
index 6caf9aa4aa13de423c0fd322afc0af6c02f2afd3..fcb2a412d49b6501eed5d880c5a93f14d87985d8 100644
--- a/api/funkwhale_api/playlists/serializers.py
+++ b/api/funkwhale_api/playlists/serializers.py
@@ -3,6 +3,7 @@ from django.db import transaction
 from rest_framework import serializers
 from taggit.models import Tag
 
+from funkwhale_api.common import preferences
 from funkwhale_api.music.models import Track
 from funkwhale_api.music.serializers import TrackSerializerNested
 from funkwhale_api.users.serializers import UserBasicSerializer
@@ -32,10 +33,11 @@ class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
                 raise serializers.ValidationError(
                     'You do not have the permission to edit this playlist')
         existing = value.playlist_tracks.count()
-        if existing >= settings.PLAYLISTS_MAX_TRACKS:
+        max_tracks = preferences.get('playlists__max_tracks')
+        if existing >= max_tracks:
             raise serializers.ValidationError(
                 'Playlist has reached the maximum of {} tracks'.format(
-                    settings.PLAYLISTS_MAX_TRACKS))
+                    max_tracks))
         return value
 
     @transaction.atomic
diff --git a/api/funkwhale_api/radios/models.py b/api/funkwhale_api/radios/models.py
index 0273b53871b4536a52b34870ef5ba2e1e93ae078..8758abc619d05c03b3fe9c9b0953c919ef005479 100644
--- a/api/funkwhale_api/radios/models.py
+++ b/api/funkwhale_api/radios/models.py
@@ -55,8 +55,6 @@ class RadioSession(models.Model):
     related_object = GenericForeignKey('related_object_content_type', 'related_object_id')
 
     def save(self, **kwargs):
-        if not self.user and not self.session_key:
-            raise ValidationError('Cannot have both session_key and user empty for radio session')
         self.radio.clean(self)
         super().save(**kwargs)
 
diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py
index 2e7e6a409fb4e4102f1d713faaeb94ca3b7e759a..195b382c99a4b32872b34923f958e1c8b682ac94 100644
--- a/api/funkwhale_api/radios/serializers.py
+++ b/api/funkwhale_api/radios/serializers.py
@@ -38,6 +38,7 @@ class RadioSerializer(serializers.ModelSerializer):
 
         return super().save(**kwargs)
 
+
 class RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
     class Meta:
         model = models.RadioSessionTrack
@@ -62,17 +63,14 @@ class RadioSessionSerializer(serializers.ModelSerializer):
             'user',
             'creation_date',
             'custom_radio',
-            'session_key')
+        )
 
     def validate(self, data):
         registry[data['radio_type']]().validate_session(data, **self.context)
         return data
 
     def create(self, validated_data):
-        if self.context.get('user'):
-            validated_data['user'] = self.context.get('user')
-        else:
-            validated_data['session_key'] = self.context['session_key']
+        validated_data['user'] = self.context['user']
         if validated_data.get('related_object_id'):
             radio = registry[validated_data['radio_type']]()
             validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id'])
diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py
index ffd1d16593ddc2648ec2748dc3b4174afe41f7ed..37c07c5e4cdf7c30799aa8b3af12fd94d1150274 100644
--- a/api/funkwhale_api/radios/views.py
+++ b/api/funkwhale_api/radios/views.py
@@ -2,6 +2,7 @@ from django.db.models import Q
 from django.http import Http404
 
 from rest_framework import generics, mixins, viewsets
+from rest_framework import permissions
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.decorators import detail_route, list_route
@@ -24,7 +25,7 @@ class RadioViewSet(
         viewsets.GenericViewSet):
 
     serializer_class = serializers.RadioSerializer
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [permissions.IsAuthenticated]
     filter_class = filtersets.RadioFilter
 
     def get_queryset(self):
@@ -84,21 +85,15 @@ class RadioSessionViewSet(mixins.CreateModelMixin,
 
     serializer_class = serializers.RadioSessionSerializer
     queryset = models.RadioSession.objects.all()
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [permissions.IsAuthenticated]
 
     def get_queryset(self):
         queryset = super().get_queryset()
-        if self.request.user.is_authenticated:
-            return queryset.filter(user=self.request.user)
-        else:
-            return queryset.filter(session_key=self.request.session.session_key)
+        return queryset.filter(user=self.request.user)
 
     def get_serializer_context(self):
         context = super().get_serializer_context()
-        if self.request.user.is_authenticated:
-            context['user'] = self.request.user
-        else:
-            context['session_key'] = self.request.session.session_key
+        context['user'] = self.request.user
         return context
 
 
@@ -106,17 +101,14 @@ class RadioSessionTrackViewSet(mixins.CreateModelMixin,
                                viewsets.GenericViewSet):
     serializer_class = serializers.RadioSessionTrackSerializer
     queryset = models.RadioSessionTrack.objects.all()
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [permissions.IsAuthenticated]
 
     def create(self, request, *args, **kwargs):
         serializer = self.get_serializer(data=request.data)
         serializer.is_valid(raise_exception=True)
         session = serializer.validated_data['session']
         try:
-            if request.user.is_authenticated:
-                assert request.user == session.user
-            else:
-                assert request.session.session_key == session.session_key
+            assert request.user == session.user
         except AssertionError:
             return Response(status=status.HTTP_403_FORBIDDEN)
         track = session.radio.pick()
diff --git a/api/funkwhale_api/requests/filters.py b/api/funkwhale_api/requests/filters.py
index bf353e8ad075abb76f3a4fdc846d9d25b0a105f4..7d06033629280bfd212608573b1dcea7ad04697c 100644
--- a/api/funkwhale_api/requests/filters.py
+++ b/api/funkwhale_api/requests/filters.py
@@ -1,10 +1,18 @@
 import django_filters
 
+from funkwhale_api.common import fields
 from . import models
 
 
 class ImportRequestFilter(django_filters.FilterSet):
 
+    q = fields.SearchFilter(search_fields=[
+        'artist_name',
+        'user__username',
+        'albums',
+        'comment',
+    ])
+
     class Meta:
         model = models.ImportRequest
         fields = {
diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py
index c298524306caa9bc7f4cd6e9eb1f4abbb2d4bde0..d08dd4004f9ea34e943ac7e61a5783f4a7e7fcd3 100644
--- a/api/funkwhale_api/requests/models.py
+++ b/api/funkwhale_api/requests/models.py
@@ -15,6 +15,7 @@ STATUS_CHOICES = [
     ('closed', 'closed'),
 ]
 
+
 class ImportRequest(models.Model):
     creation_date = models.DateTimeField(default=timezone.now)
     imported_date = models.DateTimeField(null=True, blank=True)
diff --git a/api/funkwhale_api/templates/account/email/email_confirmation_message.txt b/api/funkwhale_api/templates/account/email/email_confirmation_message.txt
new file mode 100644
index 0000000000000000000000000000000000000000..8aec540fe15c602e99505231e2292cb09839930c
--- /dev/null
+++ b/api/funkwhale_api/templates/account/email/email_confirmation_message.txt
@@ -0,0 +1,8 @@
+{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Hello from {{ site_name }}!
+
+You're receiving this e-mail because user {{ user_display }} at {{ site_domain }} has given yours as an e-mail address to connect their account.
+
+To confirm this is correct, go to {{ funkwhale_url }}/auth/email/confirm?key={{ key }}
+{% endblocktrans %}{% endautoescape %}
+{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you from {{ site_name }}!
+{{ site_domain }}{% endblocktrans %}
diff --git a/api/funkwhale_api/templates/registration/password_reset_email.html b/api/funkwhale_api/templates/registration/password_reset_email.html
new file mode 100644
index 0000000000000000000000000000000000000000..7a587d7204b1ae31eecc995bcca3d9a7e68a0e4d
--- /dev/null
+++ b/api/funkwhale_api/templates/registration/password_reset_email.html
@@ -0,0 +1,12 @@
+{% load i18n %}{% autoescape off %}
+{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}
+
+{% trans "Please go to the following page and choose a new password:" %}
+{{ funkwhale_url }}/auth/password/reset/confirm?uid={{ uid }}&token={{ token }}
+{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
+
+{% trans "Thanks for using our site!" %}
+
+{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
+
+{% endautoescape %}
diff --git a/api/funkwhale_api/users/adapters.py b/api/funkwhale_api/users/adapters.py
index 96d1b8b1d6b4aaa708bc3c09490beae84c10fa1a..7bd341d14e07062376dbc22a16559a6ff23d9321 100644
--- a/api/funkwhale_api/users/adapters.py
+++ b/api/funkwhale_api/users/adapters.py
@@ -1,5 +1,6 @@
-from allauth.account.adapter import DefaultAccountAdapter
+from django.conf import settings
 
+from allauth.account.adapter import DefaultAccountAdapter
 from dynamic_preferences.registries import global_preferences_registry
 
 
@@ -8,3 +9,7 @@ class FunkwhaleAccountAdapter(DefaultAccountAdapter):
     def is_open_for_signup(self, request):
         manager = global_preferences_registry.manager()
         return manager['users__registration_enabled']
+
+    def send_mail(self, template_prefix, email, context):
+        context['funkwhale_url'] = settings.FUNKWHALE_URL
+        return super().send_mail(template_prefix, email, context)
diff --git a/api/funkwhale_api/users/middleware.py b/api/funkwhale_api/users/middleware.py
deleted file mode 100644
index e3eba95f3ff5813b7eab16a633ccb1121e8851f3..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/users/middleware.py
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-class AnonymousSessionMiddleware:
-    def __init__(self, get_response):
-        self.get_response = get_response
-
-    def __call__(self, request):
-        if not request.session.session_key:
-            request.session.save()
-        response = self.get_response(request)
-        return response
diff --git a/api/funkwhale_api/users/rest_auth_urls.py b/api/funkwhale_api/users/rest_auth_urls.py
index 31f5384aa7f2a750bcaa4fc9063658876fbbd968..fa6c425cc5227f8f9d4078338ff436ccc8e883d4 100644
--- a/api/funkwhale_api/users/rest_auth_urls.py
+++ b/api/funkwhale_api/users/rest_auth_urls.py
@@ -1,16 +1,20 @@
 from django.views.generic import TemplateView
 from django.conf.urls import url
 
-from rest_auth.registration.views import VerifyEmailView
-from rest_auth.views import PasswordChangeView
+from rest_auth.registration import views as registration_views
+from rest_auth import views as rest_auth_views
 
-from .views import RegisterView
+from . import views
 
 
 urlpatterns = [
-    url(r'^$', RegisterView.as_view(), name='rest_register'),
-    url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'),
-    url(r'^change-password/$', PasswordChangeView.as_view(), name='change_password'),
+    url(r'^$', views.RegisterView.as_view(), name='rest_register'),
+    url(r'^verify-email/$',
+        registration_views.VerifyEmailView.as_view(),
+        name='rest_verify_email'),
+    url(r'^change-password/$',
+        rest_auth_views.PasswordChangeView.as_view(),
+        name='change_password'),
 
     # This url is used by django-allauth and empty TemplateView is
     # defined just to allow reverse() call inside app, for example when email
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index b21aa69355b2ca4acea883b52e9401055382b6b3..eadce6154fa12c385f0655d3667b1634442716ec 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -1,5 +1,7 @@
-from rest_framework import serializers
+from django.conf import settings
 
+from rest_framework import serializers
+from rest_auth.serializers import PasswordResetSerializer as PRS
 from funkwhale_api.activity import serializers as activity_serializers
 
 from . import models
@@ -63,3 +65,12 @@ class UserReadSerializer(serializers.ModelSerializer):
                 'status': o.has_perm(internal_codename)
             }
         return perms
+
+
+class PasswordResetSerializer(PRS):
+    def get_email_options(self):
+        return {
+            'extra_email_context': {
+                'funkwhale_url': settings.FUNKWHALE_URL
+            }
+        }
diff --git a/api/setup.cfg b/api/setup.cfg
index a2b8b92c682696a0ad4569cb6c9f9e25c01f9b9f..b1267c904cc94dc623dddf93c9af1293ec869122 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -11,7 +11,7 @@ python_files = tests.py test_*.py *_tests.py
 testpaths = tests
 env =
     SECRET_KEY=test
-    DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
+    EMAIL_CONFIG=consolemail://
     CELERY_BROKER_URL=memory://
     CELERY_TASK_ALWAYS_EAGER=True
     CACHEOPS_ENABLED=False
diff --git a/api/tests/activity/test_views.py b/api/tests/activity/test_views.py
index bdc3c6339ffe91981621c8f8272788347a01cc8e..9b24f3ad3a9359f53264d6b6d0be52f3490a19f6 100644
--- a/api/tests/activity/test_views.py
+++ b/api/tests/activity/test_views.py
@@ -4,8 +4,8 @@ from funkwhale_api.activity import serializers
 from funkwhale_api.activity import utils
 
 
-def test_activity_view(factories, api_client, settings, anonymous_user):
-    settings.API_AUTHENTICATION_REQUIRED = False
+def test_activity_view(factories, api_client, preferences, anonymous_user):
+    preferences['common__api_authentication_required'] = False
     favorite = factories['favorites.TrackFavorite'](
         user__privacy_level='everyone')
     listening = factories['history.Listening']()
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 64dc394e7eef40663bba19b26bd6896f5a5088b1..51a1bc4c7455c0be929ba503b971b3ec2d8ad244 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -27,12 +27,19 @@ def factories_autodiscover():
 
 @pytest.fixture(autouse=True)
 def cache():
+    """
+    Returns a django Cache instance for cache-related operations
+    """
     yield django_cache
     django_cache.clear()
 
 
 @pytest.fixture
 def factories(db):
+    """
+    Returns a dictionnary containing all registered factories with keys such as
+    users.User or music.Track
+    """
     from funkwhale_api import factories
     for v in factories.registry.values():
         try:
@@ -45,6 +52,10 @@ def factories(db):
 
 @pytest.fixture
 def nodb_factories():
+    """
+    Returns a dictionnary containing all registered factories with a build strategy
+    that does not require access to the database
+    """
     from funkwhale_api import factories
     for v in factories.registry.values():
         try:
@@ -57,6 +68,9 @@ def nodb_factories():
 
 @pytest.fixture
 def preferences(db, cache):
+    """
+    return a dynamic_preferences manager for global_preferences
+    """
     manager = global_preferences_registry.manager()
     manager.all()
     yield manager
@@ -64,6 +78,10 @@ def preferences(db, cache):
 
 @pytest.fixture
 def tmpdir():
+    """
+    Returns a temporary directory path where you can write things during your
+    test
+    """
     d = tempfile.mkdtemp()
     yield d
     shutil.rmtree(d)
@@ -71,11 +89,18 @@ def tmpdir():
 
 @pytest.fixture
 def tmpfile():
+    """
+    Returns a temporary file where you can write things during your test
+    """
     yield tempfile.NamedTemporaryFile()
 
 
 @pytest.fixture
 def logged_in_client(db, factories, client):
+    """
+    Returns a logged-in, non-API client with an authenticated ``User``
+    stored in the ``user`` attribute
+    """
     user = factories['users.User']()
     assert client.login(username=user.username, password='test')
     setattr(client, 'user', user)
@@ -85,16 +110,24 @@ def logged_in_client(db, factories, client):
 
 @pytest.fixture
 def anonymous_user():
+    """Returns a AnonymousUser() instance"""
     return AnonymousUser()
 
 
 @pytest.fixture
 def api_client(client):
+    """
+    Return an API client without any authentication
+    """
     return APIClient()
 
 
 @pytest.fixture
 def logged_in_api_client(db, factories, api_client):
+    """
+    Return a logged-in API client with an authenticated ``User``
+    stored in the ``user`` attribute
+    """
     user = factories['users.User']()
     assert api_client.login(username=user.username, password='test')
     setattr(api_client, 'user', user)
@@ -104,6 +137,10 @@ def logged_in_api_client(db, factories, api_client):
 
 @pytest.fixture
 def superuser_api_client(db, factories, api_client):
+    """
+    Return a logged-in API client with an authenticated superuser
+    stored in the ``user`` attribute
+    """
     user = factories['users.SuperUser']()
     assert api_client.login(username=user.username, password='test')
     setattr(api_client, 'user', user)
@@ -113,6 +150,10 @@ def superuser_api_client(db, factories, api_client):
 
 @pytest.fixture
 def superuser_client(db, factories, client):
+    """
+    Return a logged-in, non-API client with an authenticated ``User``
+    stored in the ``user`` attribute
+    """
     user = factories['users.SuperUser']()
     assert client.login(username=user.username, password='test')
     setattr(client, 'user', user)
@@ -122,11 +163,17 @@ def superuser_client(db, factories, client):
 
 @pytest.fixture
 def api_request():
+    """
+    Returns a dummy API request object you can pass to API views
+    """
     return APIRequestFactory()
 
 
 @pytest.fixture
 def fake_request():
+    """
+    Returns a dummy, non-API request object you can pass to regular views
+    """
     return client.RequestFactory()
 
 
@@ -140,16 +187,6 @@ def activity_registry():
         record.registry[key] = value
 
 
-@pytest.fixture
-def activity_registry():
-    r = record.registry
-    state = list(record.registry.items())
-    yield record.registry
-    record.registry.clear()
-    for key, value in state:
-        record.registry[key] = value
-
-
 @pytest.fixture
 def activity_muted(activity_registry, mocker):
     yield mocker.patch.object(record, 'send')
@@ -157,6 +194,9 @@ def activity_muted(activity_registry, mocker):
 
 @pytest.fixture(autouse=True)
 def media_root(settings):
+    """
+    Sets settings.MEDIA_ROOT to a temporary path and returns this path
+    """
     tmp_dir = tempfile.mkdtemp()
     settings.MEDIA_ROOT = tmp_dir
     yield settings.MEDIA_ROOT
@@ -165,12 +205,19 @@ def media_root(settings):
 
 @pytest.fixture
 def r_mock():
+    """
+    Returns a requests_mock.mock() object you can use to mock HTTP calls made
+    using python-requests
+    """
     with requests_mock.mock() as m:
         yield m
 
 
 @pytest.fixture
 def authenticated_actor(factories, mocker):
+    """
+    Returns an authenticated ActivityPub actor
+    """
     actor = factories['federation.Actor']()
     mocker.patch(
         'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py
index f4a045af825e9431f308903569ac16b07d609692..591fe7c9c8f36fbe8c3b625ee9bd26c4be67ad0d 100644
--- a/api/tests/favorites/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -99,8 +99,8 @@ def test_user_can_remove_favorite_via_api_using_track_id(
 @pytest.mark.parametrize('url,method', [
     ('api:v1:favorites:tracks-list', 'get'),
 ])
-def test_url_require_auth(url, method, db, settings, client):
-    settings.API_AUTHENTICATION_REQUIRED = True
+def test_url_require_auth(url, method, db, preferences, client):
+    preferences['common__api_authentication_required'] = True
     url = reverse(url)
     response = getattr(client, method)(url)
     assert response.status_code == 401
diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py
index 7281147a1b143a9d48adce68bf124b8a57009933..6f73a9b9b2fc2fae47933898ba6c518158fcafbd 100644
--- a/api/tests/federation/test_actors.py
+++ b/api/tests/federation/test_actors.py
@@ -29,6 +29,42 @@ def test_actor_fetching(r_mock):
     assert r == payload
 
 
+def test_get_actor(factories, r_mock):
+    actor = factories['federation.Actor'].build()
+    payload = serializers.ActorSerializer(actor).data
+    r_mock.get(actor.url, json=payload)
+    new_actor = actors.get_actor(actor.url)
+
+    assert new_actor.pk is not None
+    assert serializers.ActorSerializer(new_actor).data == payload
+
+
+def test_get_actor_use_existing(factories, preferences, mocker):
+    preferences['federation__actor_fetch_delay'] = 60
+    actor = factories['federation.Actor']()
+    get_data = mocker.patch('funkwhale_api.federation.actors.get_actor_data')
+    new_actor = actors.get_actor(actor.url)
+
+    assert new_actor == actor
+    get_data.assert_not_called()
+
+
+def test_get_actor_refresh(factories, preferences, mocker):
+    preferences['federation__actor_fetch_delay'] = 0
+    actor = factories['federation.Actor']()
+    payload = serializers.ActorSerializer(actor).data
+    # actor changed their username in the meantime
+    payload['preferredUsername'] = 'New me'
+    get_data = mocker.patch(
+        'funkwhale_api.federation.actors.get_actor_data',
+        return_value=payload)
+    new_actor = actors.get_actor(actor.url)
+
+    assert new_actor == actor
+    assert new_actor.last_fetch_date > actor.last_fetch_date
+    assert new_actor.preferred_username == 'New me'
+
+
 def test_get_library(db, settings, mocker):
     get_key_pair = mocker.patch(
         'funkwhale_api.federation.keys.get_key_pair',
@@ -238,9 +274,9 @@ def test_actor_is_system(
 
 
 @pytest.mark.parametrize('value', [False, True])
-def test_library_actor_manually_approves_based_on_setting(
-        value, settings):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = value
+def test_library_actor_manually_approves_based_on_preference(
+        value, preferences):
+    preferences['federation__music_needs_approval'] = value
     library_conf = actors.SYSTEM_ACTORS['library']
     assert library_conf.manually_approves_followers is value
 
@@ -320,8 +356,8 @@ def test_test_actor_handles_undo_follow(
 
 
 def test_library_actor_handles_follow_manual_approval(
-        settings, mocker, factories):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
+        preferences, mocker, factories):
+    preferences['federation__music_needs_approval'] = True
     actor = factories['federation.Actor']()
     now = timezone.now()
     mocker.patch('django.utils.timezone.now', return_value=now)
@@ -341,8 +377,8 @@ def test_library_actor_handles_follow_manual_approval(
 
 
 def test_library_actor_handles_follow_auto_approval(
-        settings, mocker, factories):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
+        preferences, mocker, factories):
+    preferences['federation__music_needs_approval'] = False
     actor = factories['federation.Actor']()
     accept_follow = mocker.patch(
         'funkwhale_api.federation.activity.accept_follow')
diff --git a/api/tests/federation/test_permissions.py b/api/tests/federation/test_permissions.py
index 9b86832108fdc36a708c22ea104af4561cf0c1d9..a87f26f1b910b77b63ba65061f319d9f8ab4ba50 100644
--- a/api/tests/federation/test_permissions.py
+++ b/api/tests/federation/test_permissions.py
@@ -5,8 +5,8 @@ from funkwhale_api.federation import permissions
 
 
 def test_library_follower(
-        factories, api_request, anonymous_user, settings):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
+        factories, api_request, anonymous_user, preferences):
+    preferences['federation__music_needs_approval'] = True
     view = APIView.as_view()
     permission = permissions.LibraryFollower()
     request = api_request.get('/')
@@ -17,8 +17,8 @@ def test_library_follower(
 
 
 def test_library_follower_actor_non_follower(
-        factories, api_request, anonymous_user, settings):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
+        factories, api_request, anonymous_user, preferences):
+    preferences['federation__music_needs_approval'] = True
     actor = factories['federation.Actor']()
     view = APIView.as_view()
     permission = permissions.LibraryFollower()
@@ -31,8 +31,8 @@ def test_library_follower_actor_non_follower(
 
 
 def test_library_follower_actor_follower_not_approved(
-        factories, api_request, anonymous_user, settings):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
+        factories, api_request, anonymous_user, preferences):
+    preferences['federation__music_needs_approval'] = True
     library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     follow = factories['federation.Follow'](target=library, approved=False)
     view = APIView.as_view()
@@ -46,8 +46,8 @@ def test_library_follower_actor_follower_not_approved(
 
 
 def test_library_follower_actor_follower(
-        factories, api_request, anonymous_user, settings):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
+        factories, api_request, anonymous_user, preferences):
+    preferences['federation__music_needs_approval'] = True
     library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     follow = factories['federation.Follow'](target=library, approved=True)
     view = APIView.as_view()
diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py
index 506fbc1fe95be93320161e28f626f450f7b4000f..3517e8feb2c317f319192991dd09917a3070e553 100644
--- a/api/tests/federation/test_tasks.py
+++ b/api/tests/federation/test_tasks.py
@@ -1,4 +1,7 @@
 import datetime
+import os
+import pathlib
+import pytest
 
 from django.core.paginator import Paginator
 from django.utils import timezone
@@ -117,17 +120,16 @@ def test_clean_federation_music_cache_if_no_listen(preferences, factories):
     lt1 = factories['federation.LibraryTrack'](with_audio_file=True)
     lt2 = factories['federation.LibraryTrack'](with_audio_file=True)
     lt3 = factories['federation.LibraryTrack'](with_audio_file=True)
-    tf1 = factories['music.TrackFile'](library_track=lt1)
-    tf2 = factories['music.TrackFile'](library_track=lt2)
-    tf3 = factories['music.TrackFile'](library_track=lt3)
-
-    # we listen to the first one, and the second one (but weeks ago)
-    listening1 = factories['history.Listening'](
-        track=tf1.track,
-        creation_date=timezone.now())
-    listening2 = factories['history.Listening'](
-        track=tf2.track,
-        creation_date=timezone.now() - datetime.timedelta(minutes=61))
+    tf1 = factories['music.TrackFile'](
+        accessed_date=timezone.now(), library_track=lt1)
+    tf2 = factories['music.TrackFile'](
+        accessed_date=timezone.now()-datetime.timedelta(minutes=61),
+        library_track=lt2)
+    tf3 = factories['music.TrackFile'](
+        accessed_date=None, library_track=lt3)
+    path1 = lt1.audio_file.path
+    path2 = lt2.audio_file.path
+    path3 = lt3.audio_file.path
 
     tasks.clean_music_cache()
 
@@ -138,3 +140,32 @@ def test_clean_federation_music_cache_if_no_listen(preferences, factories):
     assert bool(lt1.audio_file) is True
     assert bool(lt2.audio_file) is False
     assert bool(lt3.audio_file) is False
+    assert os.path.exists(path1) is True
+    assert os.path.exists(path2) is False
+    assert os.path.exists(path3) is False
+
+
+def test_clean_federation_music_cache_orphaned(
+        settings, preferences, factories):
+    preferences['federation__music_cache_duration'] = 60
+    path = os.path.join(settings.MEDIA_ROOT, 'federation_cache')
+    keep_path = os.path.join(os.path.join(path, '1a', 'b2'), 'keep.ogg')
+    remove_path = os.path.join(os.path.join(path, 'c3', 'd4'), 'remove.ogg')
+    os.makedirs(os.path.dirname(keep_path), exist_ok=True)
+    os.makedirs(os.path.dirname(remove_path), exist_ok=True)
+    pathlib.Path(keep_path).touch()
+    pathlib.Path(remove_path).touch()
+    lt = factories['federation.LibraryTrack'](
+        with_audio_file=True,
+        audio_file__path=keep_path)
+    tf = factories['music.TrackFile'](
+        library_track=lt,
+        accessed_date=timezone.now())
+
+    tasks.clean_music_cache()
+
+    lt.refresh_from_db()
+
+    assert bool(lt.audio_file) is True
+    assert os.path.exists(lt.audio_file.path) is True
+    assert os.path.exists(remove_path) is False
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index ae94bcdc02ab2e23704eb3622ec311128713e9ce..09ecfc8ff7f6d192808890b78eb9d3226ad8cc7e 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -13,7 +13,7 @@ from funkwhale_api.federation import webfinger
 
 
 @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
-def test_instance_actors(system_actor, db, settings, api_client):
+def test_instance_actors(system_actor, db, api_client):
     actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
     url = reverse(
         'federation:instance-actors-detail',
@@ -34,8 +34,8 @@ def test_instance_actors(system_actor, db, settings, api_client):
     ('well-known-webfinger', {}),
 ])
 def test_instance_endpoints_405_if_federation_disabled(
-        authenticated_actor, db, settings, api_client, route, kwargs):
-    settings.FEDERATION_ENABLED = False
+        authenticated_actor, db, preferences, api_client, route, kwargs):
+    preferences['federation__enabled'] = False
     url = reverse('federation:{}'.format(route), kwargs=kwargs)
     response = api_client.get(url)
 
@@ -71,8 +71,8 @@ def test_wellknown_webfinger_system(
 
 
 def test_audio_file_list_requires_authenticated_actor(
-        db, settings, api_client):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
+        db, preferences, api_client):
+    preferences['federation__music_needs_approval'] = True
     url = reverse('federation:music:files-list')
     response = api_client.get(url)
 
@@ -80,9 +80,9 @@ def test_audio_file_list_requires_authenticated_actor(
 
 
 def test_audio_file_list_actor_no_page(
-        db, settings, api_client, factories):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
-    settings.FEDERATION_COLLECTION_PAGE_SIZE = 2
+        db, preferences, api_client, factories):
+    preferences['federation__music_needs_approval'] = False
+    preferences['federation__collection_page_size'] = 2
     library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     tfs = factories['music.TrackFile'].create_batch(size=5)
     conf = {
@@ -101,9 +101,9 @@ def test_audio_file_list_actor_no_page(
 
 
 def test_audio_file_list_actor_page(
-        db, settings, api_client, factories):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
-    settings.FEDERATION_COLLECTION_PAGE_SIZE = 2
+        db, preferences, api_client, factories):
+    preferences['federation__music_needs_approval'] = False
+    preferences['federation__collection_page_size'] = 2
     library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     tfs = factories['music.TrackFile'].create_batch(size=5)
     conf = {
@@ -121,8 +121,8 @@ def test_audio_file_list_actor_page(
 
 
 def test_audio_file_list_actor_page_exclude_federated_files(
-        db, settings, api_client, factories):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
+        db, preferences, api_client, factories):
+    preferences['federation__music_needs_approval'] = False
     library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     tfs = factories['music.TrackFile'].create_batch(size=5, federation=True)
 
@@ -134,8 +134,8 @@ def test_audio_file_list_actor_page_exclude_federated_files(
 
 
 def test_audio_file_list_actor_page_error(
-        db, settings, api_client, factories):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
+        db, preferences, api_client, factories):
+    preferences['federation__music_needs_approval'] = False
     url = reverse('federation:music:files-list')
     response = api_client.get(url, data={'page': 'nope'})
 
@@ -143,15 +143,15 @@ def test_audio_file_list_actor_page_error(
 
 
 def test_audio_file_list_actor_page_error_too_far(
-        db, settings, api_client, factories):
-    settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
+        db, preferences, api_client, factories):
+    preferences['federation__music_needs_approval'] = False
     url = reverse('federation:music:files-list')
     response = api_client.get(url, data={'page': 5000})
 
     assert response.status_code == 404
 
 
-def test_library_actor_includes_library_link(db, settings, api_client):
+def test_library_actor_includes_library_link(db, preferences, api_client):
     actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
     url = reverse(
         'federation:instance-actors-detail',
diff --git a/api/tests/history/test_history.py b/api/tests/history/test_history.py
index ec8689e9637ca2686452e72d9628a581251e014f..20272559636119d51e066d7ebe0e97fd944a5fc2 100644
--- a/api/tests/history/test_history.py
+++ b/api/tests/history/test_history.py
@@ -14,20 +14,6 @@ def test_can_create_listening(factories):
     l = models.Listening.objects.create(user=user, track=track)
 
 
-def test_anonymous_user_can_create_listening_via_api(client, factories, settings):
-    settings.API_AUTHENTICATION_REQUIRED = False
-    track = factories['music.Track']()
-    url = reverse('api:v1:history:listenings-list')
-    response = client.post(url, {
-        'track': track.pk,
-    })
-
-    listening = models.Listening.objects.latest('id')
-
-    assert listening.track == track
-    assert listening.session_key == client.session.session_key
-
-
 def test_logged_in_user_can_create_listening_via_api(
         logged_in_client, factories, activity_muted):
     track = factories['music.Track']()
diff --git a/api/tests/music/conftest.py b/api/tests/music/conftest.py
index 1d0fa4e38627ad7f0711082a0edd2c726f02b2e6..4eea8effe173ee5b90bb18dd1400b5dfff39588a 100644
--- a/api/tests/music/conftest.py
+++ b/api/tests/music/conftest.py
@@ -508,21 +508,25 @@ _works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423d
 
 @pytest.fixture()
 def artists():
+    """Artists as they would be returned by the Musicbrainz API"""
     return _artists
 
 
 @pytest.fixture()
 def albums():
+    """Releases as they would be returned by the Musicbrainz API"""
     return _albums
 
 
 @pytest.fixture()
 def tracks():
+    """Recordings as they would be returned by the Musicbrainz API"""
     return _tracks
 
 
 @pytest.fixture()
 def works():
+    """Works as they would be returned by the Musicbrainz API"""
     return _works
 
 
@@ -563,4 +567,7 @@ def lyricswiki_content():
 
 @pytest.fixture()
 def binary_cover():
+    """
+    Return an album cover image in form of a binary string
+    """
     return b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xed\x08\xaePhotoshop 3.0\x008BIM\x03\xe9\x00\x00\x00\x00\x00x\x00\x03\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\xff\xe1\xff\xe2\x02\xf9\x02F\x03G\x05(\x03\xfc\x00\x02\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\x00\x01\x00\x00\x00d\x00\x00\x00\x01\x00\x03\x03\x03\x00\x00\x00\x01\'\x0f\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x08\x00\x19\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x008BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00H\x00\x00\x00\x01\x00\x01\x00H\x00\x00\x00\x01\x00\x018BIM\x03\xf3\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x008BIM\x04\n\x00\x00\x00\x00\x00\x01\x00\x008BIM\'\x10\x00\x00\x00\x00\x00\n\x00\x01\x00\x00\x00\x00\x00\x00\x00\x028BIM\x03\xf5\x00\x00\x00\x00\x00H\x00/ff\x00\x01\x00lff\x00\x06\x00\x00\x00\x00\x00\x01\x00/ff\x00\x01\x00\xa1\x99\x9a\x00\x06\x00\x00\x00\x00\x00\x01\x002\x00\x00\x00\x01\x00Z\x00\x00\x00\x06\x00\x00\x00\x00\x00\x01\x005\x00\x00\x00\x01\x00-\x00\x00\x00\x06\x00\x00\x00\x00\x00\x018BIM\x03\xf8\x00\x00\x00\x00\x00p\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x008BIM\x04\x00\x00\x00\x00\x00\x00\x02\x00\x018BIM\x04\x02\x00\x00\x00\x00\x00\x04\x00\x00\x00\x008BIM\x04\x08\x00\x00\x00\x00\x00\x10\x00\x00\x00\x01\x00\x00\x02@\x00\x00\x02@\x00\x00\x00\x008BIM\x04\t\x00\x00\x00\x00\x06\x9b\x00\x00\x00\x01\x00\x00\x00\x80\x00\x00\x00\x80\x00\x00\x01\x80\x00\x00\xc0\x00\x00\x00\x06\x7f\x00\x18\x00\x01\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x80\x00\x00\x00\x01\xff\xdb\x00\x84\x00\x0c\x08\x08\x08\t\x08\x0c\t\t\x0c\x11\x0b\n\x0b\x11\x15\x0f\x0c\x0c\x0f\x15\x18\x13\x13\x15\x13\x13\x18\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\r\x0b\x0b\r\x0e\r\x10\x0e\x0e\x10\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\x80\x00\x80\x03\x01"\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x08\xff\xc4\x01?\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x01\x02\x04\x05\x06\x07\x08\t\n\x0b\x01\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x01\x04\x01\x03\x02\x04\x02\x05\x07\x06\x08\x05\x03\x0c3\x01\x00\x02\x11\x03\x04!\x121\x05AQa\x13"q\x812\x06\x14\x91\xa1\xb1B#$\x15R\xc1b34r\x82\xd1C\x07%\x92S\xf0\xe1\xf1cs5\x16\xa2\xb2\x83&D\x93TdE\xc2\xa3t6\x17\xd2U\xe2e\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\'\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf7\x11\x00\x02\x02\x01\x02\x04\x04\x03\x04\x05\x06\x07\x07\x06\x055\x01\x00\x02\x11\x03!1\x12\x04AQaq"\x13\x052\x81\x91\x14\xa1\xb1B#\xc1R\xd1\xf03$b\xe1r\x82\x92CS\x15cs4\xf1%\x06\x16\xa2\xb2\x83\x07&5\xc2\xd2D\x93T\xa3\x17dEU6te\xe2\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6\'7GWgw\x87\x97\xa7\xb7\xc7\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd0\xf5T\x92I%)$\x92IJI%\xe7\xff\x00Z\x7f\xc6\xbf\xfc\xde\xeb\xb9]\x1f\xf6_\xda~\xcd\xe9\xfe\x9b\xed\x1e\x9e\xefR\xba\xef\xfeo\xec\xf6\xed\xdb\xea\xec\xfeq%>\x80\x92\xf2\xaf\xfc}?\xf3I\xff\x00\xb3_\xfb\xe8\x97\xfe>\x9f\xf9\xa4\xff\x00\xd9\xaf\xfd\xf4IO\xaa\xa4\xbc\xab\xff\x00\x1fO\xfc\xd2\x7f\xec\xd7\xfe\xfa%\xff\x00\x8f\xa7\xfei?\xf6k\xff\x00}\x12S\xea\xa9.+\xeaW\xf8\xc8\xff\x00\x9d}V\xde\x9d\xfb;\xec~\x96;\xb2=O[\xd5\x9d\xaf\xaa\xad\x9b=\n\x7f\xd3}-\xeb\xb5IJI$\x92R\x92I$\x94\xff\x00\xff\xd1\xf5T\x92I%)$\x97\x9f\xff\x00\x8d\x7f\xad=w\xea\xf7\xec\xbf\xd8\xf9_f\xfbO\xda=o\xd1\xd7f\xefO\xec\xfe\x9f\xf3\xf5\xdb\xb7o\xabg\xd0IO\xa0/\x9f\xff\x00\xc6\x97\xfe.\xfa\x9f\xfdc\xff\x00m\xf1\xd2\xff\x00\xc7K\xeb\xdf\xfeY\xff\x00\xe0\x18\xff\x00\xfb\xce\xb9\xfe\xa9\xd53\xfa\xbe}\xbdG\xa8\xdb\xeb\xe5\xdf\xb7\xd4\xb3kY;\x1a\xda\x99\xec\xa9\xac\xaf\xf9\xb63\xf3\x12SU$\x92IJI$\x92S\xdf\xff\x00\x89O\xfcUe\x7f\xe1\x0b?\xf3\xf6*\xf6\xb5\xf3/D\xeb\xfd[\xa0\xe5?3\xa4\xdf\xf6l\x8b+59\xfb\x18\xf9a-\xb1\xcd\xdb{-g\xd3\xa9\x8bk\xff\x00\x1d/\xaf\x7f\xf9g\xff\x00\x80c\xff\x00\xef:J~\x80Iq\xff\x00\xe2\xbf\xaf\xf5n\xbd\xd023:\xb5\xff\x00i\xc8\xaf-\xf55\xfb\x18\xc8`\xae\x8b\x1a\xdd\xb42\xa6};^\xbb\x04\x94\xa4\x92I%?\xff\xd2\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd3\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd4\xf5T\x92I%)q_\xe3#\xeaWU\xfa\xd7\xfb;\xf6u\xb8\xf5}\x8f\xd6\xf5>\xd0\xe7\xb6}_Cf\xcfJ\xab\xbf\xd0\xbfr\xedRIO\x8a\x7f\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*\xe4:\xff\x00D\xca\xe8=Z\xfe\x93\x98\xfa\xec\xc8\xc6\xd9\xbd\xd5\x12Xw\xb1\x97\xb7k\xacmO\xfa\x16\xfe\xe2\xfai|\xff\x00\xfe4\xbf\xf1w\xd4\xff\x00\xeb\x1f\xfbo\x8e\x92\x9eU$\x92IJI$\x92S\xb1\xf5_\xea\xbfP\xfa\xd1\xd4,\xc0\xc0\xb2\x9a\xad\xaa\x93{\x9dys[\xb5\xae\xae\xa2\x01\xaa\xbb\x9d\xbfu\xcd\xfc\xd5\xd3\xff\x00\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*_\xe2S\xff\x00\x15Y_\xf8B\xcf\xfc\xfd\x8a\xbd\xad%<\xbf\xf8\xbc\xfa\xaf\xd4>\xab\xf4[\xb03\xec\xa6\xdbm\xc9u\xedu\x05\xcen\xd7WM@\x13mt\xbb~\xea]\xf9\xab\xa8I$\x94\xa4\x92I%?\xff\xd5\xf5T\x92I%)$\x92IJ\\\x7f_\xff\x00\x15\xfd\x03\xafuk\xfa\xb6fF]y\x19;7\xb6\xa7\xd6\x1861\x947kl\xa2\xd7\xfd\n\xbf}v\t$\xa7\xcf\xff\x00\xf1\x94\xfa\xab\xff\x00r\xb3\xff\x00\xed\xca\x7f\xf7\x95/\xfce>\xaa\xff\x00\xdc\xac\xff\x00\xfbr\x9f\xfd\xe5^\x80\x92J|\xff\x00\xff\x00\x19O\xaa\xbf\xf7+?\xfe\xdc\xa7\xff\x00yR\xff\x00\xc6S\xea\xaf\xfd\xca\xcf\xff\x00\xb7)\xff\x00\xdeU\xe8\t$\xa7\x97\xfa\xaf\xfe/:/\xd5~\xa1f~\x05\xd96\xdbm&\x876\xf7V\xe6\xeds\xab\xb4\x90*\xa6\x97o\xddK\x7f9u\t$\x92\x94\x92I$\xa5$\x92I)\xff\xd6\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd9\x008BIM\x04\x06\x00\x00\x00\x00\x00\x07\x00\x03\x00\x00\x00\x01\x01\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x00\x00\x00\x00\x01\xff\xdb\x00\x84\x00\n\x07\x07\x07\x08\x07\n\x08\x08\n\x0f\n\x08\n\x0f\x12\r\n\n\r\x12\x14\x10\x10\x12\x10\x10\x14\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\x0b\x0c\x0c\x15\x13\x15"\x18\x18"\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\t\x00\t\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x02\xff\xc4\x01\xa2\x00\x00\x00\x07\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x03\x02\x06\x01\x00\x07\x08\t\n\x0b\x01\x00\x02\x02\x03\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x02\x01\x03\x03\x02\x04\x02\x06\x07\x03\x04\x02\x06\x02s\x01\x02\x03\x11\x04\x00\x05!\x121AQ\x06\x13a"q\x81\x142\x91\xa1\x07\x15\xb1B#\xc1R\xd1\xe13\x16b\xf0$r\x82\xf1%C4S\x92\xa2\xb2cs\xc25D\'\x93\xa3\xb36\x17Tdt\xc3\xd2\xe2\x08&\x83\t\n\x18\x19\x84\x94EF\xa4\xb4V\xd3U(\x1a\xf2\xe3\xf3\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5fv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf8)9IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\x11\x00\x02\x02\x01\x02\x03\x05\x05\x04\x05\x06\x04\x08\x03\x03m\x01\x00\x02\x11\x03\x04!\x121A\x05Q\x13a"\x06q\x81\x912\xa1\xb1\xf0\x14\xc1\xd1\xe1#B\x15Rbr\xf13$4C\x82\x16\x92S%\xa2c\xb2\xc2\x07s\xd25\xe2D\x83\x17T\x93\x08\t\n\x18\x19&6E\x1a\'dtU7\xf2\xa3\xb3\xc3()\xd3\xe3\xf3\x84\x94\xa4\xb4\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5FVfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf89IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x91\xea\xfa\xbf\xe6D_\x99\x16\x96\x16\x16\x8c\xdeWf\x84;\x88U\xa1hY\x7f\xd3\'\x9e\xf3\xedCq\x0bz\xfe\x94^\xbc?\xdc\xdb\xff\x00\xa3\xcd\xeb\x7f\xa4\xaa\xf4<U\xff\xd0\xec\xd8\xab\xb1W\xff\xd9'
diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py
index cc6fe644b6a4acb7a654453332021dfdc3803013..53ee29f3e909a9c4a78022d0669fdc3710412513 100644
--- a/api/tests/music/test_api.py
+++ b/api/tests/music/test_api.py
@@ -265,16 +265,16 @@ def test_can_search_tracks(factories, logged_in_client):
     ('api:v1:albums-list', 'get'),
 ])
 def test_can_restrict_api_views_to_authenticated_users(
-        db, route, method, settings, client):
+        db, route, method, preferences, client):
     url = reverse(route)
-    settings.API_AUTHENTICATION_REQUIRED = True
+    preferences['common__api_authentication_required'] = True
     response = getattr(client, method)(url)
     assert response.status_code == 401
 
 
 def test_track_file_url_is_restricted_to_authenticated_users(
-        api_client, factories, settings):
-    settings.API_AUTHENTICATION_REQUIRED = True
+        api_client, factories, preferences):
+    preferences['common__api_authentication_required'] = True
     f = factories['music.TrackFile']()
     assert f.audio_file is not None
     url = f.path
@@ -283,8 +283,8 @@ def test_track_file_url_is_restricted_to_authenticated_users(
 
 
 def test_track_file_url_is_accessible_to_authenticated_users(
-        logged_in_api_client, factories, settings):
-    settings.API_AUTHENTICATION_REQUIRED = True
+        logged_in_api_client, factories, preferences):
+    preferences['common__api_authentication_required'] = True
     f = factories['music.TrackFile']()
     assert f.audio_file is not None
     url = f.path
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
index 65e0242fb013785b0eb21e29ac9259512b281392..000e6a8b6265eca2178c98ff52b0807956314830 100644
--- a/api/tests/music/test_import.py
+++ b/api/tests/music/test_import.py
@@ -169,10 +169,10 @@ def test_import_job_run_triggers_notifies_followers(
 
 
 def test_import_batch_notifies_followers_skip_on_disabled_federation(
-        settings, factories, mocker):
+        preferences, factories, mocker):
     mocked_deliver = mocker.patch('funkwhale_api.federation.activity.deliver')
     batch = factories['music.ImportBatch'](finished=True)
-    settings.FEDERATION_ENABLED = False
+    preferences['federation__enabled'] = False
     tasks.import_batch_notify_followers(import_batch_id=batch.pk)
 
     mocked_deliver.assert_not_called()
@@ -243,3 +243,4 @@ def test__do_import_in_place_mbid(factories, tmpfile):
 
     assert bool(tf.audio_file) is False
     assert tf.source == 'file:///test.ogg'
+    assert tf.mimetype == 'audio/ogg'
diff --git a/api/tests/music/test_permissions.py b/api/tests/music/test_permissions.py
index d36f37886d9930883643126642ab278eee8acf93..a5f0c41091eb9b428bb6f87aa422eabc9f610434 100644
--- a/api/tests/music/test_permissions.py
+++ b/api/tests/music/test_permissions.py
@@ -13,7 +13,7 @@ def test_list_permission_no_protect(anonymous_user, api_request, settings):
 
 
 def test_list_permission_protect_anonymous(
-        anonymous_user, api_request, settings):
+        db, anonymous_user, api_request, settings):
     settings.PROTECT_AUDIO_FILES = True
     view = APIView.as_view()
     permission = permissions.Listen()
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 5d7589af08c16efe1b69b72d48b37e4c77500633..b22ab7fd504fe1afd54b713cec55d9912e9890d8 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -2,6 +2,7 @@ import io
 import pytest
 
 from django.urls import reverse
+from django.utils import timezone
 
 from funkwhale_api.music import views
 from funkwhale_api.federation import actors
@@ -76,29 +77,60 @@ def test_can_serve_track_file_as_remote_library_deny_not_following(
     assert response.status_code == 403
 
 
-def test_serve_file_apache(factories, api_client, settings):
+@pytest.mark.parametrize('proxy,serve_path,expected', [
+    ('apache2', '/host/music', '/host/music/hello/world.mp3'),
+    ('apache2', '/app/music', '/app/music/hello/world.mp3'),
+    ('nginx', '/host/music', '/_protected/music/hello/world.mp3'),
+    ('nginx', '/app/music', '/_protected/music/hello/world.mp3'),
+])
+def test_serve_file_in_place(
+        proxy, serve_path, expected, factories, api_client, settings):
+    headers = {
+        'apache2': 'X-Sendfile',
+        'nginx': 'X-Accel-Redirect',
+    }
     settings.PROTECT_AUDIO_FILES = False
-    settings.REVERSE_PROXY_TYPE = 'apache2'
-    tf = factories['music.TrackFile']()
+    settings.PROTECT_FILE_PATH = '/_protected/music'
+    settings.REVERSE_PROXY_TYPE = proxy
+    settings.MUSIC_DIRECTORY_PATH = '/app/music'
+    settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
+    tf = factories['music.TrackFile'](
+        in_place=True,
+        source='file:///app/music/hello/world.mp3'
+    )
     response = api_client.get(tf.path)
 
     assert response.status_code == 200
-    assert response['X-Sendfile'] == tf.audio_file.path
+    assert response[headers[proxy]] == expected
 
 
-def test_serve_file_apache_in_place(factories, api_client, settings):
+@pytest.mark.parametrize('proxy,serve_path,expected', [
+    ('apache2', '/host/music', '/host/media/tracks/hello/world.mp3'),
+    # apache with container not supported yet
+    # ('apache2', '/app/music', '/app/music/tracks/hello/world.mp3'),
+    ('nginx', '/host/music', '/_protected/media/tracks/hello/world.mp3'),
+    ('nginx', '/app/music', '/_protected/media/tracks/hello/world.mp3'),
+])
+def test_serve_file_media(
+        proxy, serve_path, expected, factories, api_client, settings):
+    headers = {
+        'apache2': 'X-Sendfile',
+        'nginx': 'X-Accel-Redirect',
+    }
     settings.PROTECT_AUDIO_FILES = False
-    settings.REVERSE_PROXY_TYPE = 'apache2'
-    settings.MUSIC_DIRECTORY_PATH = '/music'
-    settings.MUSIC_DIRECTORY_SERVE_PATH = '/host/music'
-    track_file = factories['music.TrackFile'](
-        in_place=True,
-        source='file:///music/test.ogg')
+    settings.MEDIA_ROOT = '/host/media'
+    settings.PROTECT_FILE_PATH = '/_protected/music'
+    settings.REVERSE_PROXY_TYPE = proxy
+    settings.MUSIC_DIRECTORY_PATH = '/app/music'
+    settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
 
-    response = api_client.get(track_file.path)
+    tf = factories['music.TrackFile']()
+    tf.__class__.objects.filter(pk=tf.pk).update(
+        audio_file='tracks/hello/world.mp3')
+    response = api_client.get(tf.path)
 
     assert response.status_code == 200
-    assert response['X-Sendfile'] == '/host/music/test.ogg'
+    assert response[headers[proxy]] == expected
 
 
 def test_can_proxy_remote_track(
@@ -118,23 +150,17 @@ def test_can_proxy_remote_track(
     assert library_track.audio_file.read() == b'test'
 
 
-def test_can_serve_in_place_imported_file(
-        factories, settings, api_client, r_mock):
+def test_serve_updates_access_date(factories, settings, api_client):
     settings.PROTECT_AUDIO_FILES = False
-    settings.MUSIC_DIRECTORY_SERVE_PATH = '/host/music'
-    settings.MUSIC_DIRECTORY_PATH = '/music'
-    settings.MUSIC_DIRECTORY_PATH = '/music'
-    track_file = factories['music.TrackFile'](
-        in_place=True,
-        source='file:///music/test.ogg')
+    track_file = factories['music.TrackFile']()
+    now = timezone.now()
+    assert track_file.accessed_date is None
 
     response = api_client.get(track_file.path)
+    track_file.refresh_from_db()
 
     assert response.status_code == 200
-    assert response['X-Accel-Redirect'] == '{}{}'.format(
-        settings.PROTECT_FILES_PATH,
-        '/music/host/music/test.ogg'
-    )
+    assert track_file.accessed_date > now
 
 
 def test_can_create_import_from_federation_tracks(
@@ -196,3 +222,64 @@ def test_import_job_stats_filter(factories, superuser_api_client):
     }
     assert response.status_code == 200
     assert response.data == expected
+
+
+def test_import_job_run_via_api(factories, superuser_api_client, mocker):
+    run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
+    job1 = factories['music.ImportJob'](status='errored')
+    job2 = factories['music.ImportJob'](status='pending')
+
+    url = reverse('api:v1:import-jobs-run')
+    response = superuser_api_client.post(url, {'jobs': [job2.pk, job1.pk]})
+
+    job1.refresh_from_db()
+    job2.refresh_from_db()
+    assert response.status_code == 200
+    assert response.data == {'jobs': [job1.pk, job2.pk]}
+    assert job1.status == 'pending'
+    assert job2.status == 'pending'
+
+    run.assert_any_call(import_job_id=job1.pk)
+    run.assert_any_call(import_job_id=job2.pk)
+
+
+def test_import_batch_run_via_api(factories, superuser_api_client, mocker):
+    run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
+
+    batch = factories['music.ImportBatch']()
+    job1 = factories['music.ImportJob'](batch=batch, status='errored')
+    job2 = factories['music.ImportJob'](batch=batch, status='pending')
+
+    url = reverse('api:v1:import-jobs-run')
+    response = superuser_api_client.post(url, {'batches': [batch.pk]})
+
+    job1.refresh_from_db()
+    job2.refresh_from_db()
+    assert response.status_code == 200
+    assert job1.status == 'pending'
+    assert job2.status == 'pending'
+
+    run.assert_any_call(import_job_id=job1.pk)
+    run.assert_any_call(import_job_id=job2.pk)
+
+
+def test_import_batch_and_job_run_via_api(
+        factories, superuser_api_client, mocker):
+    run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
+
+    batch = factories['music.ImportBatch']()
+    job1 = factories['music.ImportJob'](batch=batch, status='errored')
+    job2 = factories['music.ImportJob'](status='pending')
+
+    url = reverse('api:v1:import-jobs-run')
+    response = superuser_api_client.post(
+        url, {'batches': [batch.pk], 'jobs': [job2.pk]})
+
+    job1.refresh_from_db()
+    job2.refresh_from_db()
+    assert response.status_code == 200
+    assert job1.status == 'pending'
+    assert job2.status == 'pending'
+
+    run.assert_any_call(import_job_id=job1.pk)
+    run.assert_any_call(import_job_id=job2.pk)
diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py
index c9def4dab85c4798c337b8876387c2ff5a6f05ed..fe5dd40a8518aa6507f980fed06e2e7b45012d17 100644
--- a/api/tests/playlists/test_models.py
+++ b/api/tests/playlists/test_models.py
@@ -116,8 +116,8 @@ def test_can_insert_many(factories):
         assert plt.playlist == playlist
 
 
-def test_insert_many_honor_max_tracks(factories, settings):
-    settings.PLAYLISTS_MAX_TRACKS = 4
+def test_insert_many_honor_max_tracks(preferences, factories):
+    preferences['playlists__max_tracks'] = 4
     playlist = factories['playlists.Playlist']()
     plts = factories['playlists.PlaylistTrack'].create_batch(
         size=2, playlist=playlist)
diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py
index 8e30919e6eaab42e3d8111c59cff3a2118271abc..908c1c79644d0ec44bcef36fe986f160f7e06cd5 100644
--- a/api/tests/playlists/test_serializers.py
+++ b/api/tests/playlists/test_serializers.py
@@ -2,8 +2,8 @@ from funkwhale_api.playlists import models
 from funkwhale_api.playlists import serializers
 
 
-def test_cannot_max_500_tracks_per_playlist(factories, settings):
-    settings.PLAYLISTS_MAX_TRACKS = 2
+def test_cannot_max_500_tracks_per_playlist(factories, preferences):
+    preferences['playlists__max_tracks'] = 2
     playlist = factories['playlists.Playlist']()
     plts = factories['playlists.PlaylistTrack'].create_batch(
         size=2, playlist=playlist)
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
index f0fb6d0fdc19286b2ebbbc0b2ef5336d3bd642f5..44d0608210d170b9df6b1e830cc1400ef148b7b5 100644
--- a/api/tests/playlists/test_views.py
+++ b/api/tests/playlists/test_views.py
@@ -107,8 +107,8 @@ def test_deleting_plt_updates_indexes(
 
 @pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
 def test_playlist_privacy_respected_in_list_anon(
-        settings, level, factories, api_client):
-    settings.API_AUTHENTICATION_REQUIRED = False
+        preferences, level, factories, api_client):
+    preferences['common__api_authentication_required'] = False
     factories['playlists.Playlist'](privacy_level=level)
     url = reverse('api:v1:playlists-list')
     response = api_client.get(url)
@@ -137,8 +137,8 @@ def test_only_owner_can_edit_playlist_track(
 
 @pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
 def test_playlist_track_privacy_respected_in_list_anon(
-        level, factories, api_client, settings):
-    settings.API_AUTHENTICATION_REQUIRED = False
+        level, factories, api_client, preferences):
+    preferences['common__api_authentication_required'] = False
     factories['playlists.PlaylistTrack'](playlist__privacy_level=level)
     url = reverse('api:v1:playlist-tracks-list')
     response = api_client.get(url)
diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py
index c8038a4dbadcb4283d073492beb715e7092bab20..b166b648c574f8050f574151aa08e9f3282b49ab 100644
--- a/api/tests/radios/test_radios.py
+++ b/api/tests/radios/test_radios.py
@@ -151,20 +151,6 @@ def test_can_start_radio_for_logged_in_user(logged_in_client):
     assert session.user == logged_in_client.user
 
 
-def test_can_start_radio_for_anonymous_user(api_client, db, settings):
-    settings.API_AUTHENTICATION_REQUIRED = False
-    url = reverse('api:v1:radios:sessions-list')
-    response = api_client.post(url, {'radio_type': 'random'})
-
-    assert response.status_code == 201
-
-    session = models.RadioSession.objects.latest('id')
-
-    assert session.radio_type == 'random'
-    assert session.user is None
-    assert session.session_key == api_client.session.session_key
-
-
 def test_can_get_track_for_session_from_api(factories, logged_in_client):
     files = factories['music.TrackFile'].create_batch(1)
     tracks = [f.track for f in files]
@@ -227,25 +213,25 @@ def test_can_start_tag_radio(factories):
 
     radio = radios.TagRadio()
     session = radio.start_session(user, related_object=tag)
-    assert session.radio_type =='tag'
+    assert session.radio_type == 'tag'
     for i in range(5):
         assert radio.pick() in good_tracks
 
 
-def test_can_start_artist_radio_from_api(api_client, settings, factories):
-    settings.API_AUTHENTICATION_REQUIRED = False
+def test_can_start_artist_radio_from_api(
+        logged_in_api_client, preferences, factories):
     artist = factories['music.Artist']()
     url = reverse('api:v1:radios:sessions-list')
 
-    response = api_client.post(
+    response = logged_in_api_client.post(
         url, {'radio_type': 'artist', 'related_object_id': artist.id})
 
     assert response.status_code == 201
 
     session = models.RadioSession.objects.latest('id')
 
-    assert session.radio_type, 'artist'
-    assert session.related_object, artist
+    assert session.radio_type == 'artist'
+    assert session.related_object == artist
 
 
 def test_can_start_less_listened_radio(factories):
@@ -257,6 +243,6 @@ def test_can_start_less_listened_radio(factories):
     good_tracks = [f.track for f in good_files]
     radio = radios.LessListenedRadio()
     session = radio.start_session(user)
-    assert session.related_object == user
+
     for i in range(5):
         assert radio.pick() in good_tracks
diff --git a/api/tests/test_jwt_querystring.py b/api/tests/test_jwt_querystring.py
index bd07e1dc3212476f01dd7c60f341e8718c60351c..f18e6b7292839617cd61d6357725f6bd493462c4 100644
--- a/api/tests/test_jwt_querystring.py
+++ b/api/tests/test_jwt_querystring.py
@@ -5,9 +5,10 @@ jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
 jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
 
 
-def test_can_authenticate_using_token_param_in_url(factories, settings, client):
+def test_can_authenticate_using_token_param_in_url(
+        factories, preferences, client):
     user = factories['users.User']()
-    settings.API_AUTHENTICATION_REQUIRED = True
+    preferences['common__api_authentication_required'] = True
     url = reverse('api:v1:tracks-list')
     response = client.get(url)
 
diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py
index 441179095d98398697c60fb3fabc922dafbeab6e..7ab6256daf73352542acc2a8e62ca7860cd58df4 100644
--- a/api/tests/test_youtube.py
+++ b/api/tests/test_youtube.py
@@ -18,8 +18,8 @@ def test_can_get_search_results_from_youtube(mocker):
 
 
 def test_can_get_search_results_from_funkwhale(
-        settings, mocker, api_client, db):
-    settings.API_AUTHENTICATION_REQUIRED = False
+        preferences, mocker, api_client, db):
+    preferences['common__api_authentication_required'] = False
     mocker.patch(
         'funkwhale_api.providers.youtube.client._do_search',
         return_value=api_data.search['8 bit adventure'])
@@ -70,8 +70,8 @@ def test_can_send_multiple_queries_at_once(mocker):
 
 
 def test_can_send_multiple_queries_at_once_from_funwkhale(
-        settings, mocker, db, api_client):
-    settings.API_AUTHENTICATION_REQUIRED = False
+        preferences, mocker, db, api_client):
+    preferences['common__api_authentication_required'] = False
     mocker.patch(
         'funkwhale_api.providers.youtube.client._do_search',
         return_value=api_data.search['8 bit adventure'])
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 4be586965f8d5f02bac7cfc9d3c9b871e2d8fd31..985a78c8a65ed49853869ed18c2cb82a5b2a95db 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -136,6 +136,20 @@ def test_changing_password_updates_secret_key(logged_in_client):
     assert user.password != password
 
 
+def test_can_request_password_reset(
+        factories, api_client, mailoutbox):
+    user = factories['users.User']()
+    payload = {
+        'email': user.email,
+    }
+    emails = len(mailoutbox)
+    url = reverse('rest_password_reset')
+
+    response = api_client.post(url, payload)
+    assert response.status_code == 200
+    assert len(mailoutbox) > emails
+
+
 def test_user_can_patch_his_own_settings(logged_in_api_client):
     user = logged_in_api_client.user
     payload = {
diff --git a/changes/template.rst b/changes/template.rst
index f4d94dee8e3d9b0fd548b30c8649c8c1856a1bb3..24f0e87ebc16c474c7b44841114816ab0f6fcb2f 100644
--- a/changes/template.rst
+++ b/changes/template.rst
@@ -1,3 +1,6 @@
+
+Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html
+
 {% for section, _ in sections.items() %}
 {% if sections[section] %}
 {% for category, val in definitions.items() if category in sections[section]%}
diff --git a/deploy/apache.conf b/deploy/apache.conf
new file mode 100644
index 0000000000000000000000000000000000000000..8d5a5e1f7ee45c7a02f4f8654309744f841751c7
--- /dev/null
+++ b/deploy/apache.conf
@@ -0,0 +1,122 @@
+# Following variables MUST be modified according to your setup
+Define funkwhale-sn funkwhale.yourdomain.com
+
+# Following variables should be modified according to your setup and if you
+# use different configuration than what is described in our installation guide.
+Define funkwhale-api http://localhost:5000
+Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music
+# websockets are not working yet
+# Define funkwhale-api-ws ws://localhost:5000
+
+
+# HTTP request redirected to HTTPS
+<VirtualHost *:80>
+   ServerName ${funkwhale-sn}
+
+   # Default is to force https
+   RewriteEngine on
+   RewriteCond %{SERVER_NAME} =${funkwhale-sn}
+   RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,QSA,R=permanent]
+
+   <Location "/.well-known/acme-challenge/">
+      Options None
+      Require all granted
+   </Location>
+
+</VirtualHost>
+
+
+<IfModule mod_ssl.c>
+<VirtualHost *:443>
+   ServerName ${funkwhale-sn}
+
+   # Path to ErrorLog and access log
+   ErrorLog ${APACHE_LOG_DIR}/funkwhale/error.log
+   CustomLog ${APACHE_LOG_DIR}/funkwhale/access.log combined
+
+   # TLS
+   # Feel free to use your own configuration for SSL here or simply remove the
+   # lines and move the configuration to the previous server block if you
+   # don't want to run funkwhale behind https (this is not recommanded)
+   # have a look here for let's encrypt configuration:
+   # https://certbot.eff.org/all-instructions/#debian-9-stretch-nginx
+   SSLEngine on
+   SSLProxyEngine On
+   SSLCertificateFile /etc/letsencrypt/live/${funkwhale-sn}/fullchain.pem
+   SSLCertificateKeyFile /etc/letsencrypt/live/${funkwhale-sn}/privkey.pem
+   Include /etc/letsencrypt/options-ssl-apache.conf
+
+
+   DocumentRoot /srv/funkwhale/front/dist
+
+   FallbackResource /index.html
+
+   # Configure Proxy settings
+   # ProxyPreserveHost pass the original Host header to the backend server
+   ProxyVia On
+   ProxyPreserveHost On
+   <IfModule mod_remoteip.c>
+      RemoteIPHeader X-Forwarded-For
+   </IfModule>
+
+   # Turning ProxyRequests on and allowing proxying from all may allow
+   # spammers to use your proxy to send email.
+   ProxyRequests Off
+
+   <Proxy *>
+      AddDefaultCharset off
+      Order Allow,Deny
+      Allow from all
+   </Proxy>
+
+   # Activating WebSockets (not working)
+   # ProxyPass "/api/v1/instance/activity"  "ws://localhost:5000/api/v1/instance/activity"
+
+   <Location "/api">
+      # similar to nginx 'client_max_body_size 30M;'
+      LimitRequestBody 31457280
+
+      ProxyPass ${funkwhale-api}/api
+      ProxyPassReverse ${funkwhale-api}/api
+   </Location>
+   <Location "/federation">
+      ProxyPass ${funkwhale-api}/federation
+      ProxyPassReverse ${funkwhale-api}/federation
+   </Location>
+
+   <Location "/.well-known/webfinger">
+      ProxyPass ${funkwhale-api}/.well-known/webfinger
+      ProxyPassReverse ${funkwhale-api}/.well-known/webfinger
+   </Location>
+
+   Alias /media /srv/funkwhale/data/media
+
+   Alias /staticfiles /srv/funkwhale/data/static
+
+   # Setting appropriate access levels to serve frontend
+   <Directory "/srv/funkwhale/data/static">
+      Options FollowSymLinks
+      AllowOverride None
+      Require all granted
+   </Directory>
+
+   <Directory /srv/funkwhale/front/dist>
+      Options FollowSymLinks
+      AllowOverride None
+      Require all granted
+   </Directory>
+
+   # XSendFile is serving audio files
+   # WARNING : permissions on paths specified below overrides previous definition,
+   # everything under those paths is potentially exposed.
+   # Following directive may be needed to ensure xsendfile is loaded
+   #LoadModule xsendfile_module modules/mod_xsendfile.so
+   <IfModule mod_xsendfile.c>
+      XSendFile On
+      XSendFilePath /srv/funkwhale/data/media
+      XSendFilePath ${MUSIC_DIRECTORY_PATH}
+      SetEnv MOD_X_SENDFILE_ENABLED 1
+   </IfModule>
+
+</VirtualHost>
+</IfModule>
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index 54f2e1ef08192d5a181d04bb69ad69b0848f2faa..4b27595af62ae2975b5af853ce5acca406bae58a 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -6,6 +6,7 @@
 # - DJANGO_SECRET_KEY
 # - DJANGO_ALLOWED_HOSTS
 # - FUNKWHALE_URL
+# - EMAIL_CONFIG and DEFAULT_FROM_EMAIL if you plan to send emails)
 # On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
 # - DATABASE_URL
 # - CACHE_URL
@@ -41,6 +42,19 @@ FUNKWHALE_API_PORT=5000
 # your instance
 FUNKWHALE_URL=https://yourdomain.funwhale
 
+# Configure email sending using this variale
+# By default, funkwhale will output emails sent to stdout
+# here are a few examples for this setting
+# EMAIL_CONFIG=consolemail://         # output emails to console (the default)
+# EMAIL_CONFIG=dummymail://          # disable email sending completely
+# On a production instance, you'll usually want to use an external SMTP server:
+# EMAIL_CONFIG=smtp://user@:password@youremail.host:25'
+# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465'
+# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587'
+
+# The email address to use to send systme emails. By default, we will
+# DEFAULT_FROM_EMAIL=noreply@yourdomain
+
 # Depending on the reverse proxy used in front of your funkwhale instance,
 # the API will use different kind of headers to serve audio files
 # Allowed values: nginx, apache2
@@ -88,9 +102,6 @@ DJANGO_SECRET_KEY=
 # want to
 # DJANGO_ADMIN_URL=^api/admin/
 
-# If True, unauthenticated users won't be able to query the API
-API_AUTHENTICATION_REQUIRED=True
-
 # Sentry/Raven error reporting (server side)
 # Enable Raven if you want to help improve funkwhale by
 # automatically sending error reports our Sentry instance.
@@ -98,15 +109,6 @@ API_AUTHENTICATION_REQUIRED=True
 RAVEN_ENABLED=false
 RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
 
-# This settings enable/disable federation on the instance level
-FEDERATION_ENABLED=True
-# This setting decide wether music library is shared automatically
-# to followers or if it requires manual approval before.
-# FEDERATION_MUSIC_NEEDS_APPROVAL=False
-# means anyone can subscribe to your library and import your file,
-# use with caution.
-FEDERATION_MUSIC_NEEDS_APPROVAL=True
-
 # In-place import settings
 # You can safely leave those settings uncommented if you don't plan to use
 # in place imports.
diff --git a/deploy/funkwhale-server.service b/deploy/funkwhale-server.service
index 53d3a104bfaab54c4bb96569ac673f2540c62af7..88d70d338e3f5b6d7b276eebbcc96155e6df3c74 100644
--- a/deploy/funkwhale-server.service
+++ b/deploy/funkwhale-server.service
@@ -8,7 +8,7 @@ User=funkwhale
 # adapt this depending on the path of your funkwhale installation
 WorkingDirectory=/srv/funkwhale/api
 EnvironmentFile=/srv/funkwhale/config/.env
-ExecStart=/srv/funkwhale/virtualenv/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
+ExecStart=/srv/funkwhale/virtualenv/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application --proxy-headers
 
 [Install]
 WantedBy=multi-user.target
diff --git a/dev.yml b/dev.yml
index 264fc953483d1dec5b19b30d3ade754efb47691d..534d8f5b5d8bbde692acde76ba1a2f1b7cb9dfbc 100644
--- a/dev.yml
+++ b/dev.yml
@@ -123,6 +123,15 @@ services:
       - '35730:35730'
       - '8001:8001'
 
+  api-docs:
+    image: swaggerapi/swagger-ui
+    environment:
+      - "API_URL=/swagger.yml"
+    ports:
+      - '8002:8080'
+    volumes:
+      - "./api/docs/swagger.yml:/usr/share/nginx/html/swagger.yml"
+
 networks:
   internal:
   federation:
diff --git a/docs/api.rst b/docs/api.rst
new file mode 100644
index 0000000000000000000000000000000000000000..650b3885ea73c7192cc17edb5cb9169b5b06ca2f
--- /dev/null
+++ b/docs/api.rst
@@ -0,0 +1,6 @@
+Funkwhale API
+=============
+
+Funkwhale API is still a work in progress and should not be considered as
+stable. We offer an `interactive documentation using swagger </swagger/>`_
+were you can browse available endpoints and try the API.
diff --git a/docs/build_docs.sh b/docs/build_docs.sh
new file mode 100755
index 0000000000000000000000000000000000000000..fbf2036af5599f845fb769c32555eeba9e77d948
--- /dev/null
+++ b/docs/build_docs.sh
@@ -0,0 +1,5 @@
+#!/bin/bash -eux
+# Building sphinx and swagger docs
+
+python -m sphinx . $BUILD_PATH
+TARGET_PATH="$BUILD_PATH/swagger" ./build_swagger.sh
diff --git a/docs/build_swagger.sh b/docs/build_swagger.sh
new file mode 100755
index 0000000000000000000000000000000000000000..13ae21b065856e864f3798f9498afd897e3ef265
--- /dev/null
+++ b/docs/build_swagger.sh
@@ -0,0 +1,9 @@
+#!/bin/bash -eux
+
+SWAGGER_VERSION="3.13.6"
+TARGET_PATH=${TARGET_PATH-"swagger"}
+rm -rf $TARGET_PATH /tmp/swagger-ui
+git clone --branch="v$SWAGGER_VERSION" --depth=1 "https://github.com/swagger-api/swagger-ui.git" /tmp/swagger-ui
+mv /tmp/swagger-ui/dist $TARGET_PATH
+cp swagger.yml $TARGET_PATH
+sed -i "s,http://petstore.swagger.io/v2/swagger.json,swagger.yml,g" $TARGET_PATH/index.html
diff --git a/docs/configuration.rst b/docs/configuration.rst
index c0de76f56a1e9d7b1d8eaeb0d9da8dea0c3257a3..bbc658e087977e5eaa7c3f53598fdad99d68b4aa 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -18,6 +18,8 @@ and technical aspects of your instance, such as database credentials.
     on environment variables.
 
 
+.. _instance-settings:
+
 Instance settings
 -----------------
 
@@ -37,6 +39,38 @@ settings in this interface.
 Configuration reference
 -----------------------
 
+.. _setting-EMAIL_CONFIG:
+
+``EMAIL_CONFIG``
+^^^^^^^^^^^^^^^^
+
+Determine how emails are sent.
+
+Default: ``consolemail://``
+
+Possible values:
+
+- ``consolemail://``: Output sent emails to stdout
+- ``dummymail://``: Completely discard sent emails
+- ``smtp://user:password@youremail.host:25``: Send emails via SMTP via youremail.host on port 25, without encryption, authenticating as user "user" with password "password"
+- ``smtp+ssl://user:password@youremail.host:465``: Send emails via SMTP via youremail.host on port 465, using SSL encryption, authenticating as user "user" with password "password"
+- ``smtp+tls://user:password@youremail.host:587``: Send emails via SMTP via youremail.host on port 587, using TLS encryption, authenticating as user "user" with password "password"
+
+.. _setting-DEFAULT_FROM_EMAIL:
+
+``DEFAULT_FROM_EMAIL``
+^^^^^^^^^^^^^^^^^^^^^^
+
+The email address to use to send email.
+
+Default: ``Funkwhale <noreply@yourdomain>``
+
+.. note::
+
+    Both the forms ``Funkwhale <noreply@yourdomain>`` and
+    ``noreply@yourdomain`` work.
+
+
 .. _setting-MUSIC_DIRECTORY_PATH:
 
 ``MUSIC_DIRECTORY_PATH``
diff --git a/docs/contributing.rst b/docs/contributing.rst
new file mode 100644
index 0000000000000000000000000000000000000000..0e5a08ecf3ce63619bc358bebbc98a8af2ea6331
--- /dev/null
+++ b/docs/contributing.rst
@@ -0,0 +1 @@
+.. include:: ../CONTRIBUTING
diff --git a/docs/federation.rst b/docs/federation.rst
index 5b030074c04eb7e4a47c3372514e5482e649b437..0f016ada9acfb1dd2942ad8cfb53b55ae56531d1 100644
--- a/docs/federation.rst
+++ b/docs/federation.rst
@@ -12,8 +12,7 @@ Managing federation
 
 Federation management is only available to instance admins and users
 who have the proper permissions. You can disable federation completely
-at the instance level by setting the FEDERATION_ENABLED environment variable
-to False.
+at the instance level by editing the ``federation__enabled`` :ref:`setting <instance-settings>`.
 
 On the front end, assuming you have the proper permission, you will see
 a "Federation" link in the sidebar.
@@ -52,6 +51,6 @@ each other instance asking for access to library. This is by design,
 to ensure your library is not shared publicly without your consent.
 
 However, if you're confident about federating publicly without manual approval,
-you can set the FEDERATION_MUSIC_NEEDS_APPROVAL environment variable to false.
+you can set the ``federation__music_needs_approval`` :ref:`setting <instance-settings>` to false.
 Follow requests will be accepted automatically and followers
 given access to your library without manual intervention.
diff --git a/docs/index.rst b/docs/index.rst
index 82dcf8c88d475ddf53cea25133ca80c4c4feae2d..481690b708fd49d98d79bdf4b4354d1963339935 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -16,7 +16,10 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
    configuration
    importing-music
    federation
+   api
    upgrading
+   third-party
+   contributing
    changelog
 
 Indices and tables
diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst
index c4e54218d53bf85fc4aa647e0516be86956df319..eb0c3f0eaca309b0992bb876a5652e4a3f1011a5 100644
--- a/docs/installation/debian.rst
+++ b/docs/installation/debian.rst
@@ -31,7 +31,7 @@ Layout
 
 All funkwhale-related files will be located under ``/srv/funkwhale`` apart
 from database files and a few configuration files. We will also have a
-dedicated ``funwhale`` user to launch the processes we need and own those files.
+dedicated ``funkwhale`` user to launch the processes we need and own those files.
 
 You are free to use different values here, just remember to adapt those in the
 next steps.
diff --git a/docs/installation/external_dependencies.rst b/docs/installation/external_dependencies.rst
index 6641bef000081bbc639dd4113815c7e6d1814de2..39d32b38fdaf2c3e071665341164b12e77dc9309 100644
--- a/docs/installation/external_dependencies.rst
+++ b/docs/installation/external_dependencies.rst
@@ -18,7 +18,7 @@ On debian-like systems, you would install the database server like this:
 
 .. code-block:: shell
 
-    sudo apt-get install postgresql
+    sudo apt-get install postgresql postgresql-contrib
 
 The remaining steps are heavily inspired from `this Digital Ocean guide <https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04>`_.
 
@@ -32,13 +32,22 @@ Create the project database and user:
 
 .. code-block:: shell
 
-    CREATE DATABASE funkwhale;
+    CREATE DATABASE "scratch"
+      WITH ENCODING 'utf8'
+      LC_COLLATE = 'en_US.utf8'
+      LC_CTYPE = 'en_US.utf8';
     CREATE USER funkwhale;
     GRANT ALL PRIVILEGES ON DATABASE funkwhale TO funkwhale;
 
 Assuming you already have :ref:`created your funkwhale user <create-funkwhale-user>`,
 you should now be able to open a postgresql shell:
 
+.. warning::
+
+    It's importing that you use utf-8 encoding for your database,
+    otherwise you'll end up with errors and crashes later on when dealing
+    with music metedata that contains non-ascii chars.
+
 .. code-block:: shell
 
     sudo -u funkwhale -H psql
@@ -49,7 +58,7 @@ for funkwhale to work properly:
 
 .. code-block:: shell
 
-    sudo -u postgres psql -c 'CREATE EXTENSION "unaccent";''
+    sudo -u postgres psql -c 'CREATE EXTENSION "unaccent";'
 
 
 Cache setup (Redis)
diff --git a/docs/installation/index.rst b/docs/installation/index.rst
index 776c22424f15929324520e1d57076d4bd2a5656c..a3e11529b155d7148aa08840b71a4692ce035f00 100644
--- a/docs/installation/index.rst
+++ b/docs/installation/index.rst
@@ -28,10 +28,16 @@ On a dockerized instance with 2 CPUs and a few active users, the memory footprin
    funkwhale_postgres_1        22.73 MiB
    funkwhale_redis_1           1.496 MiB
 
+Some users have reported running Funkwhale on Raspberry Pis with a memory
+consumption of less than 350MiB.
+
 Thus, Funkwhale should run fine on commodity hardware, small hosting boxes and
 Raspberry Pi. We lack real-world exemples of such deployments, so don't hesitate
 do give us your feedback (either positive or negative).
 
+Check out :doc:`optimization` for advices on how to tune your instance on small
+configurations.
+
 Software requirements
 ---------------------
 
@@ -86,7 +92,7 @@ Files for the web frontend are purely static and can simply be downloaded, unzip
 Reverse proxy
 --------------
 
-In order to make funkwhale accessible from outside your server and to play nicely with other applications on your machine, you should configure a reverse proxy. At the moment, we only have documentation for nginx, if you know how to implement the same thing for apache, you're welcome.
+In order to make funkwhale accessible from outside your server and to play nicely with other applications on your machine, you should configure a reverse proxy.
 
 Nginx
 ^^^^^
@@ -103,9 +109,44 @@ Then, download our sample virtualhost file and proxy conf:
 .. parsed-literal::
 
     curl -L -o /etc/nginx/funkwhale_proxy.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/funkwhale_proxy.conf"
-    curl -L -o /etc/nginx/sites-enabled/funkwhale.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/nginx.conf"
+    curl -L -o /etc/nginx/sites-available/funkwhale.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/nginx.conf"
+    ln -s /etc/nginx/sites-available/funkwhale.conf /etc/nginx/sites-enabled/
+
+Ensure static assets and proxy pass match your configuration, and check the configuration is valid with ``nginx -t``.
+If everything is fine, you can restart your nginx server with ``service nginx restart``.
+
+Apache2
+^^^^^^^
+
+.. note::
+
+    Apache2 support is still very recent and the following features
+    are not working yet:
+
+    - Websocket (used for real-time updates on Instance timeline)
+    - Transcoding of audio files
+
+    Those features are not necessary to use your Funkwhale instance, and
+    transcoding in particular is still in alpha-state anyway.
+
+Ensure you have a recent version of apache2 installed on your server.
+You'll also need the following dependencies::
+
+   apt install libapache2-mod-xsendfile
+
+Then, download our sample virtualhost file:
+
+.. parsed-literal::
+
+    curl -L -o /etc/apache2/sites-available/funkwhale.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/apache.conf"
+    ln -s /etc/apache2/sites-available/funkwhale.conf /etc/apache2/sites-enabled/
+
+You can tweak the configuration file according to your setup, especially the
+TLS configuration. Otherwise, defaults, should work if you followed the
+installation guide.
 
-Ensure static assets and proxy pass match your configuration, and check the configuration is valid with ``nginx -t``. If everything is fine, you can restart your nginx server with ``service nginx restart``.
+Check the configuration is valid with ``apache2ctl configtest``, and once you're
+done, load the new configuration with ``service apache2 restart``.
 
 About internal locations
 ~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/installation/optimization.rst b/docs/installation/optimization.rst
new file mode 100644
index 0000000000000000000000000000000000000000..f873795e2693525878eb66ebb233ba38e4d5334d
--- /dev/null
+++ b/docs/installation/optimization.rst
@@ -0,0 +1,37 @@
+Optimizing your Funkwhale instance
+==================================
+
+Depending on your requirements, you may want to reduce as much as possible
+Funkwhale's footprint.
+
+Reduce workers concurrency
+--------------------------
+
+Asynchronous tasks are handled by a celery worker, which will by default
+spawn a worker process per CPU available. This can lead to a higher
+memory usage.
+
+You can control this behaviour using the ``--concurrency`` flag.
+For instance, setting ``--concurrency=1`` will spawn only one worker.
+
+This flag should be appended after the ``celery -A funkwhale_api.taskapp worker``
+command in your :file:`docker-compose.yml` file if your using Docker, or in your
+:file:`/etc/systemd/system/funkwhale-worker.service` otherwise.
+
+.. note::
+
+    Reducing concurrency comes at a cost: asynchronous tasks will be processed
+    more slowly. However, on small instances, this should not be an issue.
+
+
+Switch from prefork to solo pool
+--------------------------------
+
+Using a different pool implementation for Celery tasks may also help.
+
+Using the ``solo`` pool type should reduce your memory consumption.
+You can control this behaviour using the ``--pool=solo`` flag.
+
+This flag should be appended after the ``celery -A funkwhale_api.taskapp worker``
+command in your :file:`docker-compose.yml` file if your using Docker, or in your
+:file:`/etc/systemd/system/funkwhale-worker.service` otherwise.
diff --git a/docs/swagger.yml b/docs/swagger.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7735a8f20ab18053e771951846e9f16911b2d408
--- /dev/null
+++ b/docs/swagger.yml
@@ -0,0 +1,186 @@
+openapi: "3.0"
+info:
+  description: "Documentation for [Funkwhale](https://funkwhale.audio) API. The API is **not** stable yet."
+  version: "1.0.0"
+  title: "Funkwhale API"
+
+servers:
+  - url: https://demo.funkwhale.audio/api/v1
+    description: Demo server
+  - url: https://node1.funkwhale.test/api/v1
+    description: Node 1 (local)
+
+components:
+  securitySchemes:
+    jwt:
+      type: http
+      scheme: bearer
+      bearerFormat: JWT
+      description: "You can get a token by using the /token endpoint"
+
+security:
+  - jwt: []
+
+paths:
+  /token/:
+    post:
+      tags:
+        - "auth"
+      description:
+        Obtain a JWT token you can use for authenticating your next requests.
+      security: []
+      responses:
+        '200':
+          description: Successfull auth
+        '400':
+          description: Invalid credentials
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: "object"
+              properties:
+                username:
+                  type: "string"
+                  example: "demo"
+                password:
+                  type: "string"
+                  example: "demo"
+
+  /artists/:
+    get:
+      tags:
+        - "artists"
+      parameters:
+        - name: "q"
+          in: "query"
+          description: "Search query used to filter artists"
+          schema:
+            required: false
+            type: "string"
+            example: "carpenter"
+        - name: "listenable"
+          in: "query"
+          description: "Filter/exclude artists with listenable tracks"
+          schema:
+            required: false
+            type: "boolean"
+      responses:
+        200:
+          content:
+            application/json:
+              schema:
+                type: "object"
+                properties:
+                  count:
+                    $ref: "#/properties/resultsCount"
+                  results:
+                    type: "array"
+                    items:
+                      $ref: "#/definitions/ArtistNested"
+
+properties:
+  resultsCount:
+    type: "integer"
+    format: "int64"
+    description: "The total number of resources matching the request"
+  mbid:
+    type: "string"
+    formats: "uuid"
+    description: "A musicbrainz ID"
+definitions:
+  Artist:
+    type: "object"
+    properties:
+      mbid:
+        required: false
+        $ref: "#/properties/mbid"
+      id:
+        type: "integer"
+        format: "int64"
+        example: 42
+      name:
+        type: "string"
+        example: "System of a Down"
+      creation_date:
+        type: "string"
+        format: "date-time"
+  ArtistNested:
+    type: "object"
+    allOf:
+      - $ref: "#/definitions/Artist"
+      - type: "object"
+        properties:
+          albums:
+            type: "array"
+            items:
+              $ref: "#/definitions/AlbumNested"
+
+  Album:
+    type: "object"
+    properties:
+      mbid:
+        required: false
+        $ref: "#/properties/mbid"
+      id:
+        type: "integer"
+        format: "int64"
+        example: 16
+      artist:
+        type: "integer"
+        format: "int64"
+        example: 42
+      title:
+        type: "string"
+        example: "Toxicity"
+      creation_date:
+        type: "string"
+        format: "date-time"
+      release_date:
+        type: "string"
+        required: false
+        format: "date"
+        example: "2001-01-01"
+
+  AlbumNested:
+    type: "object"
+    allOf:
+      - $ref: "#/definitions/Album"
+      - type: "object"
+        properties:
+          tracks:
+            type: "array"
+            items:
+              $ref: "#/definitions/Track"
+
+  Track:
+    type: "object"
+    properties:
+      mbid:
+        required: false
+        $ref: "#/properties/mbid"
+      id:
+        type: "integer"
+        format: "int64"
+        example: 66
+      artist:
+        type: "integer"
+        format: "int64"
+        example: 42
+      album:
+        type: "integer"
+        format: "int64"
+        example: 16
+      title:
+        type: "string"
+        example: "Chop Suey!"
+      position:
+        required: false
+        description: "Position of the track in the album"
+        type: "number"
+        minimum: 1
+        example: 1
+      creation_date:
+        type: "string"
+        format: "date-time"
diff --git a/docs/third-party.rst b/docs/third-party.rst
new file mode 100644
index 0000000000000000000000000000000000000000..0335f8c71e754c317027e5ff10d1b50ccd76736c
--- /dev/null
+++ b/docs/third-party.rst
@@ -0,0 +1,17 @@
+Third party projects
+====================
+
+This page lists all known projects that are maintained by third-parties
+and integrate or relates to Funkwhale.
+
+.. note::
+
+    If you want your project to be added or removed from this page,
+    please open an issue on our issue tracker.
+
+
+API Clients
+-----------
+
+- `libfunkwhale <https://github.com/BaptisteGelez/libfunkwhale>`_: a Funkwhale API written in Vala
+- `Funkwhale-javalib <https://github.com/PhieF/FunkWhale-javalib>`_: a Funkwhale API client written in Java
diff --git a/front/src/App.vue b/front/src/App.vue
index e8cac74761c84e5d26ea354fb400b61f3543e0f4..a213374284fd072b22513fa63f15b52b56458259 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -97,6 +97,12 @@ html, body {
   }
 }
 
+.ellipsis {
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  overflow: hidden;
+}
+
 .ui.small.text.container {
   max-width: 500px !important;
 }
diff --git a/front/src/assets/logo/logo-full-500.png b/front/src/assets/logo/logo-full-500.png
new file mode 100644
index 0000000000000000000000000000000000000000..952f3803326c30ceb84c54913203015efc7fddb9
Binary files /dev/null and b/front/src/assets/logo/logo-full-500.png differ
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index fb4074d80703ff8c13685eb7ff1298551e3ff250..97c743bbe804a5ca64bfe39ce69ad5b725745365 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -30,24 +30,56 @@
   </div>
   <div class="tabs">
     <div class="ui bottom attached active tab" data-tab="library">
-      <div class="ui inverted vertical fluid menu">
-        <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</router-link>
-        <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> {{ $t('Logout') }}</router-link>
-        <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> {{ $t('Login') }}</router-link>
-        <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
-        <router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i> {{ $t('Favorites') }}</router-link>
-        <a
-          @click="$store.commit('playlists/chooseTrack', null)"
-          v-if="$store.state.auth.authenticated"
-          class="item">
-          <i class="list icon"></i> {{ $t('Playlists') }}
-        </a>
-        <router-link
-          v-if="$store.state.auth.authenticated"
-          class="item" :to="{path: '/activity'}"><i class="bell icon"></i> {{ $t('Activity') }}</router-link>
-        <router-link
-          class="item" v-if="$store.state.auth.availablePermissions['federation.manage']"
-          :to="{path: '/manage/federation/libraries'}"><i class="sitemap icon"></i> {{ $t('Federation') }}</router-link>
+      <div class="ui inverted vertical large fluid menu">
+        <div class="item">
+          <div class="header">{{ $t('My account') }}</div>
+          <div class="menu">
+            <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</router-link>
+            <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i>{{ $t('Logout') }}</router-link>
+            <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i>{{ $t('Login') }}</router-link>
+          </div>
+        </div>
+        <div class="item">
+          <div class="header">{{ $t('Music') }}</div>
+          <div class="menu">
+            <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
+            <router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i>{{ $t('Favorites') }}</router-link>
+            <a
+              @click="$store.commit('playlists/chooseTrack', null)"
+              v-if="$store.state.auth.authenticated"
+              class="item">
+              <i class="list icon"></i>{{ $t('Playlists') }}
+            </a>
+            <router-link
+              v-if="$store.state.auth.authenticated"
+              class="item" :to="{path: '/activity'}"><i class="bell icon"></i>{{ $t('Activity') }}</router-link>
+          </div>
+        </div>
+        <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['import.launch']"
+              :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['federation.manage']"
+              :to="{path: '/manage/federation/libraries'}">
+              <i class="sitemap icon"></i>{{ $t('Federation') }}
+              <div
+                :class="['ui', {'teal': notifications.federation > 0}, 'label']"
+                :title="$t('Pending follow requests')">
+                {{ notifications.federation }}</div>
+            </router-link>
+          </div>
+        </div>
       </div>
     </div>
     <div v-if="queue.previousQueue " class="ui black icon message">
@@ -104,6 +136,7 @@
 
 <script>
 import {mapState, mapActions} from 'vuex'
+import axios from 'axios'
 
 import Player from '@/components/audio/Player'
 import Logo from '@/components/Logo'
@@ -125,22 +158,69 @@ export default {
     return {
       selectedTab: 'library',
       backend: backend,
-      isCollapsed: true
+      isCollapsed: true,
+      fetchInterval: null,
+      notifications: {
+        federation: 0,
+        importRequests: 0
+      }
     }
   },
   mounted () {
     $(this.$el).find('.menu .item').tab()
   },
+  created () {
+    this.fetchNotificationsCount()
+    this.fetchInterval = setInterval(
+        this.fetchNotificationsCount, 1000 * 60 * 15)
+  },
+  destroy () {
+    if (this.fetchInterval) {
+      clearInterval(this.fetchInterval)
+    }
+  },
   computed: {
     ...mapState({
       queue: state => state.queue,
       url: state => state.route.path
-    })
+    }),
+    showAdmin () {
+      let adminPermissions = [
+        this.$store.state.auth.availablePermissions['federation.manage'],
+        this.$store.state.auth.availablePermissions['import.launch']
+      ]
+      return adminPermissions.filter(e => {
+        return e
+      }).length > 0
+    }
   },
   methods: {
     ...mapActions({
       cleanTrack: 'queue/cleanTrack'
     }),
+    fetchNotificationsCount () {
+      this.fetchFederationNotificationsCount()
+      this.fetchFederationImportRequestsCount()
+    },
+    fetchFederationNotificationsCount () {
+      if (!this.$store.state.auth.availablePermissions['federation.manage']) {
+        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['import.launch']) {
+        return
+      }
+      let self = this
+      axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
+        console.log('YOLo')
+        self.notifications.importRequests = response.data.count
+      })
+    },
     reorder: function (event) {
       this.$store.commit('queue/reorder', {
         oldIndex: event.oldIndex, newIndex: event.newIndex})
@@ -173,6 +253,13 @@ export default {
       if (this.selectedTab !== 'queue') {
         this.scrollToCurrent()
       }
+    },
+    '$store.state.availablePermissions': {
+      handler () {
+        console.log('YOLO')
+        this.fetchNotificationsCount()
+      },
+      deep: true
     }
   }
 }
@@ -182,7 +269,7 @@ export default {
 <style scoped lang="scss">
 @import '../style/vendor/media';
 
-$sidebar-color: #3D3E3F;
+$sidebar-color: #3d3e3f;
 
 .sidebar {
 	background: $sidebar-color;
@@ -231,6 +318,18 @@ $sidebar-color: #3D3E3F;
     }
   }
 }
+.vertical.menu {
+  .item .item {
+    font-size: 1em;
+    > i.icon {
+      float: none;
+      margin: 0 0.5em 0 0;
+    }
+    &:not(.active) {
+      color: rgba(255, 255, 255, 0.75);
+    }
+  }
+}
 .tabs {
   flex: 1;
   display: flex;
diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue
index b06ce89f0da850457c10acaee1d894df98368750..f3add57b1cccd736c82eb4f749764a9c0229ab2f 100644
--- a/front/src/components/auth/Login.vue
+++ b/front/src/components/auth/Login.vue
@@ -12,9 +12,15 @@
             </ul>
           </div>
           <div class="field">
-            <i18next tag="label" path="Username or email"/>
+            <label>
+              {{ $t('Username or email') }} |
+              <router-link :to="{path: '/signup'}">
+                {{ $t('Create an account') }}
+              </router-link>
+            </label>
             <input
             ref="username"
+            tabindex="1"
             required
             type="text"
             autofocus
@@ -23,18 +29,16 @@
             >
           </div>
           <div class="field">
-            <i18next tag="label" path="Password"/>            
-            <input
-            required
-            type="password"
-            placeholder="Enter your password"
-            v-model="credentials.password"
-            >
+            <label>
+              {{ $t('Password') }} |
+              <router-link :to="{name: 'auth.password-reset', query: {email: credentials.username}}">
+                {{ $t('Reset your password') }}
+              </router-link>
+            </label>
+            <password-input :index="2" required v-model="credentials.password" />
+
           </div>
-          <button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Login"/></button>
-          <router-link class="ui right floated basic button" :to="{path: '/signup'}">
-            <i18next path="Create an account"/>
-          </router-link>
+          <button tabindex="3" :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit"><i18next path="Login"/></button>
         </form>
       </div>
     </div>
@@ -42,12 +46,15 @@
 </template>
 
 <script>
+import PasswordInput from '@/components/forms/PasswordInput'
 
 export default {
-  name: 'login',
   props: {
     next: {type: String, default: '/'}
   },
+  components: {
+    PasswordInput
+  },
   data () {
     return {
       // We need to initialize the component with any
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index c847bde888efb2a0b3bca619dd5cc17e6c3b62d4..8eeae85a94a0831f2008428d92b4712547b0533a 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -35,21 +35,13 @@
           </div>
           <div class="field">
             <label><i18next path="Old password"/></label>
-            <input
-            required
-            type="password"
-            autofocus
-            placeholder="Enter your old password"
-            v-model="old_password">
+            <password-input required v-model="old_password" />
+
           </div>
           <div class="field">
             <label><i18next path="New password"/></label>
-            <input
-            required
-            type="password"
-            autofocus
-            placeholder="Enter your new password"
-            v-model="new_password">
+            <password-input required v-model="new_password" />
+
           </div>
           <button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button>
         </form>
@@ -62,8 +54,12 @@
 import $ from 'jquery'
 import axios from 'axios'
 import logger from '@/logging'
+import PasswordInput from '@/components/forms/PasswordInput'
 
 export default {
+  components: {
+    PasswordInput
+  },
   data () {
     let d = {
       // We need to initialize the component with any
diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue
index 57966264f99f0aa732f5ecd306bc6d875f9ca9fc..89f4cb1f1266c956283142cfc4f470e3b0c5d031 100644
--- a/front/src/components/auth/Signup.vue
+++ b/front/src/components/auth/Signup.vue
@@ -34,16 +34,7 @@
           </div>
           <div class="field">
             <i18next tag="label" path="Password"/>
-            <div class="ui action input">
-              <input
-              required
-              :type="passwordInputType"
-              placeholder="Enter your password"
-              v-model="password">
-              <span @click="showPassword = !showPassword" title="Show/hide password" class="ui icon button">
-                <i class="eye icon"></i>
-              </span>
-            </div>
+            <password-input v-model="password" />
           </div>
           <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button>
         </form>
@@ -57,8 +48,13 @@
 import axios from 'axios'
 import logger from '@/logging'
 
+import PasswordInput from '@/components/forms/PasswordInput'
+
 export default {
   name: 'login',
+  components: {
+    PasswordInput
+  },
   props: {
     next: {type: String, default: '/'}
   },
@@ -69,8 +65,7 @@ export default {
       password: '',
       isLoadingInstanceSetting: true,
       errors: [],
-      isLoading: false,
-      showPassword: false
+      isLoading: false
     }
   },
   created () {
@@ -104,16 +99,7 @@ export default {
         self.isLoading = false
       })
     }
-  },
-  computed: {
-    passwordInputType () {
-      if (this.showPassword) {
-        return 'text'
-      }
-      return 'password'
-    }
   }
-
 }
 </script>
 
diff --git a/front/src/components/federation/LibraryCard.vue b/front/src/components/federation/LibraryCard.vue
index 757561fb352be6300ae376780239f6e5fbbda612..e7ef7a516e1eab553583f965b3443d134c7169d4 100644
--- a/front/src/components/federation/LibraryCard.vue
+++ b/front/src/components/federation/LibraryCard.vue
@@ -1,8 +1,14 @@
 <template>
   <div class="ui card">
     <div class="content">
-      <div class="header">
-        {{ displayName }}
+      <div class="header ellipsis">
+        <router-link
+          v-if="library"
+          :title="displayName"
+          :to="{name: 'federation.libraries.detail', params: {id: library.uuid }}">
+          {{ displayName }}
+        </router-link>
+        <span :title="displayName" v-else>{{ displayName }}</span>
       </div>
     </div>
     <div class="content">
diff --git a/front/src/components/federation/LibraryForm.vue b/front/src/components/federation/LibraryForm.vue
index 7e1d5c49f4a70c67fa64db3fefeae91b75dc28fb..7547c3718faebaecee8187bb2314d09ca62718a6 100644
--- a/front/src/components/federation/LibraryForm.vue
+++ b/front/src/components/federation/LibraryForm.vue
@@ -72,7 +72,7 @@ export default {
       this.isLoading = true
       self.errors = []
       self.result = null
-      axios.get('/federation/libraries/fetch/', {params: {account: this.libraryUsername}}).then((response) => {
+      axios.get('/federation/libraries/fetch/', {params: {account: this.libraryUsername.trim()}}).then((response) => {
         self.result = response.data
         self.result.display_name = self.libraryUsername
         self.isLoading = false
diff --git a/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue
index 925ef3889668d5fbae39cf434fa8db390d0f4d06..d8ee48bf2b8e93f0ab9c22494ecee2883f5e859f 100644
--- a/front/src/components/federation/LibraryTrackTable.vue
+++ b/front/src/components/federation/LibraryTrackTable.vue
@@ -89,7 +89,7 @@
             <router-link
               v-if="importBatch"
               :to="{name: 'library.import.batches.detail', params: {id: importBatch.id }}">
-              <i18next path="Import #{%id%} launched" :id="importBatch.id"/>
+              {{ $t('Import #{% id %} launched', {id: importBatch.id}) }}
             </router-link>
           </th>
           <th></th>
diff --git a/front/src/components/forms/PasswordInput.vue b/front/src/components/forms/PasswordInput.vue
new file mode 100644
index 0000000000000000000000000000000000000000..624a92d87c8d204ec9b8e40b247e1e7835ed5951
--- /dev/null
+++ b/front/src/components/forms/PasswordInput.vue
@@ -0,0 +1,31 @@
+<template>
+  <div class="ui action input">
+    <input
+    required
+    :tabindex="index"
+    :type="passwordInputType"
+    @input="$emit('input', $event.target.value)"
+    :value="value">
+    <span @click="showPassword = !showPassword" :title="$t('Show/hide password')" class="ui icon button">
+      <i class="eye icon"></i>
+    </span>
+  </div>
+</template>
+<script>
+export default {
+  props: ['value', 'index'],
+  data () {
+    return {
+      showPassword: false
+    }
+  },
+  computed: {
+    passwordInputType () {
+      if (this.showPassword) {
+        return 'text'
+      }
+      return 'password'
+    }
+  }
+}
+</script>
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index 507ecd269cadb7897299ee7ab015bf9f90d0a2b4..820d60a5438545f1af49bd9bcf597b8c227534b9 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -6,9 +6,12 @@
       <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="/library/requests/" exact>
+        <router-link
+          v-if="$store.state.auth.authenticated"
+          class="ui item"
+          :to="{name: 'library.requests', query: {status: 'pending' }}"
+          exact>
           <i18next path="Requests"/>
-          <div class="ui teal label">{{ requestsCount }}</div>
         </router-link>
         <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>
           <i18next path="Import"/>
@@ -23,28 +26,8 @@
 </template>
 
 <script>
-import axios from 'axios'
 export default {
-  name: 'library',
-  data () {
-    return {
-      requestsCount: 0
-    }
-  },
-  created () {
-    this.fetchRequestsCount()
-  },
-  methods: {
-    fetchRequestsCount () {
-      if (!this.$store.state.authenticated) {
-        return
-      }
-      let self = this
-      axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
-        self.requestsCount = response.data.count
-      })
-    }
-  }
+  name: 'library'
 }
 </script>
 
diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue
index b73c8cf8257599e87cc21aea05ce669f58aaac45..f0e6502f02dfbb4cde87a5ea87e6e5925d1400bf 100644
--- a/front/src/components/library/import/BatchDetail.vue
+++ b/front/src/components/library/import/BatchDetail.vue
@@ -40,7 +40,16 @@
           </tr>
           <tr v-if="stats">
             <td><strong>{{ $t('Errored') }}</strong></td>
-            <td>{{ stats.errored }}</td>
+            <td>
+              {{ stats.errored }}
+              <button
+                @click="rerun({batches: [batch.id], jobs: []})"
+                v-if="stats.errored > 0"
+                class="ui tiny basic icon button">
+                <i class="redo icon" />
+                {{ $t('Rerun errored jobs')}}
+              </button>
+            </td>
           </tr>
           <tr v-if="stats">
             <td><strong>{{ $t('Finished') }}</strong></td>
@@ -83,11 +92,21 @@
               <a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
             </td>
             <td>
-              <a :href="job.source" target="_blank">{{ job.source }}</a>
+              <a :title="job.source" :href="job.source" target="_blank">
+                {{ job.source|truncate(50) }}
+              </a>
             </td>
             <td>
               <span
-                :class="['ui', {'yellow': job.status === 'pending'}, {'red': job.status === 'errored'}, {'green': job.status === 'finished'}, 'label']">{{ job.status }}</span>
+                :class="['ui', {'yellow': job.status === 'pending'}, {'red': job.status === 'errored'}, {'green': job.status === 'finished'}, 'label']">
+                {{ job.status }}</span>
+                <button
+                  @click="rerun({batches: [], jobs: [job.id]})"
+                  v-if="job.status === 'errored'"
+                  :title="$t('Rerun job')"
+                  class="ui tiny basic icon button">
+                  <i class="redo icon" />
+                </button>
             </td>
             <td>
               <router-link v-if="job.track_file" :to="{name: 'library.tracks.detail', params: {id: job.track_file.track }}">{{ job.track_file.track }}</router-link>
@@ -167,12 +186,6 @@ export default {
       return axios.get(url).then((response) => {
         self.batch = response.data
         self.isLoading = false
-        if (self.batch.status === 'pending') {
-          self.timeout = setTimeout(
-            self.fetchData,
-            5000
-          )
-        }
       })
     },
     fetchStats () {
@@ -186,7 +199,7 @@ export default {
           self.fetchJobs()
           self.fetchData()
         }
-        if (self.batch.status === 'pending') {
+        if (self.stats.pending > 0) {
           self.timeout = setTimeout(
             self.fetchStats,
             5000
@@ -194,6 +207,15 @@ export default {
         }
       })
     },
+    rerun ({jobs, batches}) {
+      let payload = {
+        jobs, batches
+      }
+      let self = this
+      axios.post('import-jobs/run/', payload).then((response) => {
+        self.fetchStats()
+      })
+    },
     fetchJobs () {
       let params = {
         batch: this.id,
diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue
index 4464031c5e6918d0b9222922687fdc8e2fc59abc..130214c3a530d2f19b274576e2e58a65ec70ee07 100644
--- a/front/src/components/requests/RequestsList.vue
+++ b/front/src/components/requests/RequestsList.vue
@@ -8,6 +8,16 @@
             <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">
@@ -81,7 +91,8 @@ const FETCH_URL = 'requests/import-requests/'
 export default {
   mixins: [OrderingMixin, PaginationMixin],
   props: {
-    defaultQuery: {type: String, required: false, default: ''}
+    defaultQuery: {type: String, required: false, default: ''},
+    defaultStatus: {required: false, default: 'any'}
   },
   components: {
     RequestCard,
@@ -96,7 +107,8 @@ export default {
       query: this.defaultQuery,
       paginateBy: parseInt(this.defaultPaginateBy || 12),
       orderingDirection: defaultOrdering.direction,
-      ordering: defaultOrdering.field
+      ordering: defaultOrdering.field,
+      status: this.defaultStatus || 'any'
     }
   },
   created () {
@@ -107,14 +119,18 @@ export default {
   },
   methods: {
     updateQueryString: _.debounce(function () {
-      this.$router.replace({
+      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
@@ -123,9 +139,12 @@ export default {
       let params = {
         page: this.page,
         page_size: this.paginateBy,
-        search: this.query,
+        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
@@ -165,6 +184,10 @@ export default {
     query () {
       this.updateQueryString()
       this.fetchData()
+    },
+    status () {
+      this.updateQueryString()
+      this.fetchData()
     }
   }
 }
diff --git a/front/src/router/index.js b/front/src/router/index.js
index a2bf781956a3350d514d108f5a8812be2e5f6156..b1e208023335945f42c7b255aa05ebaffeac5e2b 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -9,6 +9,9 @@ import Signup from '@/components/auth/Signup'
 import Profile from '@/components/auth/Profile'
 import Settings from '@/components/auth/Settings'
 import Logout from '@/components/auth/Logout'
+import PasswordReset from '@/views/auth/PasswordReset'
+import PasswordResetConfirm from '@/views/auth/PasswordResetConfirm'
+import EmailConfirm from '@/views/auth/EmailConfirm'
 import Library from '@/components/library/Library'
 import LibraryHome from '@/components/library/Home'
 import LibraryArtist from '@/components/library/Artist'
@@ -59,6 +62,31 @@ export default new Router({
       component: Login,
       props: (route) => ({ next: route.query.next || '/library' })
     },
+    {
+      path: '/auth/password/reset',
+      name: 'auth.password-reset',
+      component: PasswordReset,
+      props: (route) => ({
+        defaultEmail: route.query.email
+      })
+    },
+    {
+      path: '/auth/email/confirm',
+      name: 'auth.email-confirm',
+      component: EmailConfirm,
+      props: (route) => ({
+        defaultKey: route.query.key
+      })
+    },
+    {
+      path: '/auth/password/reset/confirm',
+      name: 'auth.password-reset-confirm',
+      component: PasswordResetConfirm,
+      props: (route) => ({
+        defaultUid: route.query.uid,
+        defaultToken: route.query.token
+      })
+    },
     {
       path: '/signup',
       name: 'signup',
@@ -212,7 +240,7 @@ export default new Router({
             defaultQuery: route.query.query,
             defaultPaginateBy: route.query.paginateBy,
             defaultPage: route.query.page,
-            defaultStatus: route.query.status || 'pending'
+            defaultStatus: route.query.status || 'any'
           }),
           children: [
           ]
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index b1753404f9be65c2d5fe2a067607d83ef45d4d6a..68a15090b5c289d2825563743f4cad7f2d3cdbf0 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -97,6 +97,11 @@ export default {
       }
     },
     fetchProfile ({commit, dispatch, state}) {
+      if (document) {
+        // this is to ensure we do not have any leaking cookie set by django
+        document.cookie = 'sessionid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'
+      }
+
       return axios.get('users/users/me/').then((response) => {
         logger.default.info('Successfully fetched user profile')
         let data = response.data
diff --git a/front/src/store/player.js b/front/src/store/player.js
index ed437c3f0220d84ed26693c94a8036c38c756b64..2149b51ffc63587a60df9eaa655d6652deda964f 100644
--- a/front/src/store/player.js
+++ b/front/src/store/player.js
@@ -85,7 +85,10 @@ export default {
     togglePlay ({commit, state}) {
       commit('playing', !state.playing)
     },
-    trackListened ({commit}, track) {
+    trackListened ({commit, rootState}, track) {
+      if (!rootState.auth.authenticated) {
+        return
+      }
       return axios.post('history/listenings/', {'track': track.id}).then((response) => {}, (response) => {
         logger.default.error('Could not record track in history')
       })
diff --git a/front/src/views/auth/EmailConfirm.vue b/front/src/views/auth/EmailConfirm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7ffa3c8d1bb96073f34985000bd624a22fe0523e
--- /dev/null
+++ b/front/src/views/auth/EmailConfirm.vue
@@ -0,0 +1,71 @@
+<template>
+  <div class="main pusher" v-title="$t('Confirm your email')">
+    <div class="ui vertical stripe segment">
+      <div class="ui small text container">
+        <h2>{{ $t('Confirm your email') }}</h2>
+        <form v-if="!success" class="ui form" @submit.prevent="submit()">
+          <div v-if="errors.length > 0" class="ui negative message">
+            <div class="header">{{ $t('Error while confirming your email') }}</div>
+            <ul class="list">
+              <li v-for="error in errors">{{ error }}</li>
+            </ul>
+          </div>
+          <div class="field">
+            <label>{{ $t('Confirmation code') }}</label>
+            <input type="text" required v-model="key" />
+          </div>
+          <router-link :to="{path: '/login'}">
+            {{ $t('Back to login') }}
+          </router-link>
+          <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit">
+            {{ $t('Confirm your email') }}</button>
+        </form>
+        <div v-else class="ui positive message">
+          <div class="header">{{ $t('Email confirmed') }}</div>
+          <p>{{ $t('Your email address was confirmed, you can now use the service without limitations.') }}</p>
+          <router-link :to="{name: 'login'}">
+            {{ $t('Proceed to login') }}
+          </router-link>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+
+export default {
+  props: ['defaultKey'],
+  data () {
+    return {
+      isLoading: false,
+      errors: [],
+      key: this.defaultKey,
+      success: false
+    }
+  },
+  methods: {
+    submit () {
+      let self = this
+      self.isLoading = true
+      self.errors = []
+      let payload = {
+        key: this.key
+      }
+      return axios.post('auth/registration/verify-email/', payload).then(response => {
+        self.isLoading = false
+        self.success = true
+      }, error => {
+        self.errors = error.backendErrors
+        self.isLoading = false
+      })
+    }
+  }
+
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/auth/PasswordReset.vue b/front/src/views/auth/PasswordReset.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f6b445e00fa4e5f38e852b0f5c9c50c407ffd820
--- /dev/null
+++ b/front/src/views/auth/PasswordReset.vue
@@ -0,0 +1,75 @@
+<template>
+  <div class="main pusher" v-title="$t('Reset your password')">
+    <div class="ui vertical stripe segment">
+      <div class="ui small text container">
+        <h2>{{ $t('Reset your password') }}</h2>
+        <form class="ui form" @submit.prevent="submit()">
+          <div v-if="errors.length > 0" class="ui negative message">
+            <div class="header">{{ $t('Error while asking for a password reset') }}</div>
+            <ul class="list">
+              <li v-for="error in errors">{{ error }}</li>
+            </ul>
+          </div>
+          <p>{{ $t('Use this form to request a password reset. We will send an email to the given address with instructions to reset your password.') }}</p>
+          <div class="field">
+            <label>{{ $t('Account\'s email') }}</label>
+            <input
+              required
+              ref="email"
+              type="email"
+              autofocus
+              :placeholder="$t('Input the email address binded to your account')"
+              v-model="email">
+          </div>
+          <router-link :to="{path: '/login'}">
+            {{ $t('Back to login') }}
+          </router-link>
+          <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit">
+            {{ $t('Ask for a password reset') }}</button>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+
+export default {
+  props: ['defaultEmail'],
+  data () {
+    return {
+      email: this.defaultEmail,
+      isLoading: false,
+      errors: []
+    }
+  },
+  mounted () {
+    this.$refs.email.focus()
+  },
+  methods: {
+    submit () {
+      let self = this
+      self.isLoading = true
+      self.errors = []
+      let payload = {
+        email: this.email
+      }
+      return axios.post('auth/password/reset/', payload).then(response => {
+        self.isLoading = false
+        self.$router.push({
+          name: 'auth.password-reset-confirm'
+        })
+      }, error => {
+        self.errors = error.backendErrors
+        self.isLoading = false
+      })
+    }
+  }
+
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/auth/PasswordResetConfirm.vue b/front/src/views/auth/PasswordResetConfirm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..102ed6126d1275cc4cbfd9789dff54a0ba40784c
--- /dev/null
+++ b/front/src/views/auth/PasswordResetConfirm.vue
@@ -0,0 +1,85 @@
+<template>
+  <div class="main pusher" v-title="$t('Change your password')">
+    <div class="ui vertical stripe segment">
+      <div class="ui small text container">
+        <h2>{{ $t('Change your password') }}</h2>
+        <form v-if="!success" class="ui form" @submit.prevent="submit()">
+          <div v-if="errors.length > 0" class="ui negative message">
+            <div class="header">{{ $t('Error while changing your password') }}</div>
+            <ul class="list">
+              <li v-for="error in errors">{{ error }}</li>
+            </ul>
+          </div>
+          <template v-if="token && uid">
+            <div class="field">
+              <label>{{ $t('New password') }}</label>
+              <password-input v-model="newPassword" />
+            </div>
+            <router-link :to="{path: '/login'}">
+              {{ $t('Back to login') }}
+            </router-link>
+            <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit">
+              {{ $t('Update your password') }}</button>
+          </template>
+          <template v-else>
+            <p>{{ $t('If the email address provided in the previous step is valid and binded to a user account, you should receive an email with reset instructions in the next couple of minutes.') }}</p>
+          </template>
+        </form>
+        <div v-else class="ui positive message">
+          <div class="header">{{ $t('Password updated successfully') }}</div>
+          <p>{{ $t('Your password has been updated successfully.') }}</p>
+          <router-link :to="{name: 'login'}">
+            {{ $t('Proceed to login') }}
+          </router-link>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import PasswordInput from '@/components/forms/PasswordInput'
+
+export default {
+  props: ['defaultToken', 'defaultUid'],
+  components: {
+    PasswordInput
+  },
+  data () {
+    return {
+      newPassword: '',
+      isLoading: false,
+      errors: [],
+      token: this.defaultToken,
+      uid: this.defaultUid,
+      success: false
+    }
+  },
+  methods: {
+    submit () {
+      let self = this
+      self.isLoading = true
+      self.errors = []
+      let payload = {
+        uid: this.uid,
+        token: this.token,
+        new_password1: this.newPassword,
+        new_password2: this.newPassword
+      }
+      return axios.post('auth/password/reset/confirm/', payload).then(response => {
+        self.isLoading = false
+        self.success = true
+      }, error => {
+        self.errors = error.backendErrors
+        self.isLoading = false
+      })
+    }
+  }
+
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/federation/LibraryDetail.vue b/front/src/views/federation/LibraryDetail.vue
index bd2e63c4d9c4644c66d625bd8218dcdbf864ef85..7a842b679a967ed288f9805d1dbf684f2256d859 100644
--- a/front/src/views/federation/LibraryDetail.vue
+++ b/front/src/views/federation/LibraryDetail.vue
@@ -84,7 +84,12 @@
               <tr>
                 <td>{{ $t('Library size') }}</td>
                 <td>
-                  {{ $t('{%count%} tracks', { count: object.tracks_count }) }}
+                  <template v-if="object.tracks_count">
+                    {{ $t('{%count%} tracks', { count: object.tracks_count }) }}
+                  </template>
+                  <template v-else>
+                    {{ $t('Unkwnown') }}
+                  </template>
                 </td>
                 <td></td>
               </tr>
@@ -145,6 +150,7 @@ export default {
   methods: {
     fetchData () {
       var self = this
+      this.scanTrigerred = false
       this.isLoading = true
       let url = 'federation/libraries/' + this.id + '/'
       logger.default.debug('Fetching library "' + this.id + '"')
diff --git a/front/src/views/federation/LibraryList.vue b/front/src/views/federation/LibraryList.vue
index 7b0b259412a3665bb4c4c04f1ce62444872439dc..cc833d3a3d226d4c1e78b667860ff20423f5964d 100644
--- a/front/src/views/federation/LibraryList.vue
+++ b/front/src/views/federation/LibraryList.vue
@@ -13,7 +13,7 @@
         <div class="fields">
           <div class="field">
             <label>{{ $t('Search') }}</label>
-            <input type="text" v-model="query" placeholder="Enter an library domain name..."/>
+            <input class="search" type="text" v-model="query" placeholder="Enter an library domain name..."/>
           </div>
           <div class="field">
             <label>{{ $t('Ordering') }}</label>
@@ -115,6 +115,7 @@ export default {
   },
   mounted () {
     $('.ui.dropdown').dropdown()
+    $(this.$el).find('.field .search').focus()
   },
   methods: {
     updateQueryString: _.debounce(function () {