diff --git a/.env.dev b/.env.dev
index 9923c31487bd305a1f9fa516ba93c4a4895ceb1e..2e883414365a1c83a982887c70bb272d04398760 100644
--- a/.env.dev
+++ b/.env.dev
@@ -1,3 +1,9 @@
 API_AUTHENTICATION_REQUIRED=True
 RAVEN_ENABLED=false
 RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
+DJANGO_ALLOWED_HOSTS=localhost,nginx
+DJANGO_SETTINGS_MODULE=config.settings.local
+DJANGO_SECRET_KEY=dev
+C_FORCE_ROOT=true
+FUNKWHALE_URL=http://localhost
+PYTHONDONTWRITEBYTECODE=true
diff --git a/.gitignore b/.gitignore
index 1e1017c8d1309f6d805dd8dfef53def18929b1c8..8b511703444291a612645e398d5b568f6e717a6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,3 +86,4 @@ front/selenium-debug.log
 docs/_build
 
 data/
+.env
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 38492f61454b3e4503bbe98b7b284c0182d0a1d0..94b40bed3b7ca63cd52ae3d492b19cd211fef6d6 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -13,6 +13,7 @@ stages:
 test_api:
   services:
     - postgres:9.4
+    - redis:3
   stage: test
   image: funkwhale/funkwhale:latest
   cache:
@@ -24,6 +25,7 @@ test_api:
     DATABASE_URL: "postgresql://postgres@postgres/postgres"
     FUNKWHALE_URL: "https://funkwhale.ci"
     CACHEOPS_ENABLED: "false"
+    DJANGO_SETTINGS_MODULE: config.settings.local
 
   before_script:
     - cd api
@@ -31,7 +33,7 @@ test_api:
     - pip install -r requirements/local.txt
     - pip install -r requirements/test.txt
   script:
-    - pytest
+    - pytest --cov=funkwhale_api tests/
   tags:
     - docker
 
diff --git a/CHANGELOG b/CHANGELOG
index 0b91987235f620475b00f26af87787c43670913b..0f01b5825169c961c95a0b44ffd3787071ecec0f 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,6 +3,100 @@ Changelog
 
 .. towncrier
 
+0.8 (2018-04-02)
+----------------
+
+Features:
+
+- Add a detail page for radios (#64)
+- Implemented page title binding (#1)
+- Previous Track button restart playback after 3 seconds (#146)
+
+
+Enhancements:
+
+- Added credits to Francis Gading for the logotype (#101)
+- API endpoint for fetching instance activity and updated timeline to use this
+  new endpoint (#141)
+- Better error messages in case of missing environment variables (#140)
+- Implemented a @test@yourfunkwhaledomain bot to ensure federation works
+  properly. Send it "/ping" and it will answer back :)
+- Queue shuffle now apply only to tracks after the current one (#97)
+- Removed player from queue tab and consistently show current track in queue
+  (#131)
+- We now restrict some usernames from being used during signup (#139)
+
+
+Bugfixes:
+
+- Better error handling during file import (#120)
+- Better handling of utf-8 filenames during file import (#138)
+- Converted favicon from .ico to .png (#130)
+- Upgraded to Python 3.6 to fix weird but harmless weakref error on django task
+  (#121)
+
+
+Documentation:
+
+- Documented the upgrade process (#127)
+
+
+Preparing for federation
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+Federation of music libraries is one of the most asked feature.
+While there is still a lot of work to do, this version includes
+the foundation that will enable funkwhale servers to communicate
+between each others, and with other federated software, such as
+Mastodon.
+
+Funkwhale will use ActivityPub as it's federation protocol.
+
+In order to prepare for federation (see #136 and #137), new API endpoints
+have been added under /federation and /.well-known/webfinger.
+
+For these endpoints to work, you will need to update your nginx configuration,
+and add the following snippets::
+
+    location /federation/ {
+        include /etc/nginx/funkwhale_proxy.conf;
+        proxy_pass   http://funkwhale-api/federation/;
+    }
+
+    location /.well-known/webfinger {
+        include /etc/nginx/funkwhale_proxy.conf;
+        proxy_pass   http://funkwhale-api/.well-known/webfinger;
+    }
+
+This will ensure federation endpoints will be reachable in the future.
+You can of course skip this part if you know you will not federate your instance.
+
+A new ``FEDERATION_ENABLED`` env var have also been added to control wether
+federation is enabled or not on the application side. This settings defaults
+to True, which should have no consequencies at the moment, since actual
+federation is not implemented and the only available endpoints are for
+testing purposes.
+
+Add ``FEDERATION_ENABLED=false`` to your .env file to disable federation
+on the application side.
+
+The last step involves generating RSA private and public keys for signing
+your instance requests on the federation. This can be done via::
+
+    # on docker setups
+    docker-compose run --rm api python manage.py generate_keys --no-input
+
+    # on non-docker setups
+    source /srv/funkwhale/virtualenv/bin/activate
+    source /srv/funkwhale/load_env
+    python manage.py generate_keys --no-input
+
+To test and troobleshoot federation, we've added a bot account. This bot is available at @test@yourinstancedomain,
+and sending it "/ping", for example, via Mastodon, should trigger
+a response.
+
+
+
 0.7 (2018-03-21)
 ----------------
 
diff --git a/README.rst b/README.rst
index 93281d26fb6abd65d0590183a416236a585b68e9..2d5d2011d2f368332458bd567284e2121a4dfca8 100644
--- a/README.rst
+++ b/README.rst
@@ -73,6 +73,19 @@ 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
 ^^^^^^^^^^^^^^^^^^^
 
diff --git a/api/Dockerfile b/api/Dockerfile
index 9296785eef25d9b5a14cd9e140b8461ba188eeff..6acdaac56a6fa024917f60ac0629217d93813dc4 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.5
+FROM python:3.6
 
 ENV PYTHONUNBUFFERED 1
 
diff --git a/api/compose/django/dev-entrypoint.sh b/api/compose/django/dev-entrypoint.sh
index 416207b43d66d6ec7d05b12dd508c33577b8afda..6deeebb0085ede8bd696d59fb78af1d6d778a41e 100755
--- a/api/compose/django/dev-entrypoint.sh
+++ b/api/compose/django/dev-entrypoint.sh
@@ -1,7 +1,3 @@
 #!/bin/bash
 set -e
-if [ $1 = "pytest" ]; then
-    # let pytest.ini handle it
-    unset DJANGO_SETTINGS_MODULE
-fi
 exec "$@"
diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index ff6db0d069395c316d207a640e0187ccf92b12df..cab6805b67e394838ec942ecf8b162edb08a88cf 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -1,5 +1,6 @@
 from rest_framework import routers
 from django.conf.urls import include, url
+from funkwhale_api.activity import views as activity_views
 from funkwhale_api.instance import views as instance_views
 from funkwhale_api.music import views
 from funkwhale_api.playlists import views as playlists_views
@@ -10,6 +11,7 @@ from dynamic_preferences.users.viewsets import UserPreferencesViewSet
 
 router = routers.SimpleRouter()
 router.register(r'settings', GlobalPreferencesViewSet, base_name='settings')
+router.register(r'activity', activity_views.ActivityViewSet, 'activity')
 router.register(r'tags', views.TagViewSet, 'tags')
 router.register(r'tracks', views.TrackViewSet, 'tracks')
 router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 077566d1c6a82e329f334a7fe94764cafbd92a70..fbe3b7045e24c67d87c2ee90441103ccc27ac57a 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/
 """
 from __future__ import absolute_import, unicode_literals
 
+from urllib.parse import urlsplit
 import os
 import environ
 from funkwhale_api import __version__
@@ -24,8 +25,13 @@ try:
 except FileNotFoundError:
     pass
 
-ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
 FUNKWHALE_URL = env('FUNKWHALE_URL')
+FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc
+
+FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
+FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
+
+ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
 
 # APP CONFIGURATION
 # ------------------------------------------------------------------------------
@@ -89,6 +95,7 @@ LOCAL_APPS = (
     'funkwhale_api.music',
     'funkwhale_api.requests',
     'funkwhale_api.favorites',
+    'funkwhale_api.federation',
     'funkwhale_api.radios',
     'funkwhale_api.history',
     'funkwhale_api.playlists',
@@ -231,6 +238,7 @@ STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR('staticfiles')))
 
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
 STATIC_URL = env("STATIC_URL", default='/staticfiles/')
+DEFAULT_FILE_STORAGE = 'funkwhale_api.common.storage.ASCIIFileSystemStorage'
 
 # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
 STATICFILES_DIRS = (
@@ -336,7 +344,12 @@ REST_FRAMEWORK = {
     ),
     'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
     'PAGE_SIZE': 25,
-
+    'DEFAULT_PARSER_CLASSES': (
+        'rest_framework.parsers.JSONParser',
+        'rest_framework.parsers.FormParser',
+        'rest_framework.parsers.MultiPartParser',
+        'funkwhale_api.federation.parsers.ActivityParser',
+    ),
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
         'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
@@ -385,3 +398,16 @@ CSRF_USE_SESSIONS = True
 
 # Playlist settings
 PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
+
+ACCOUNT_USERNAME_BLACKLIST = [
+    'funkwhale',
+    'library',
+    'test',
+    'status',
+    'root',
+    'admin',
+    'owner',
+    'superuser',
+    'staff',
+    'service',
+] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index 24ad871f75ae59f98f73a91a71fed87611afa942..dcbea66d26134664655d1ca0978e3121c5da5d96 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -72,6 +72,10 @@ LOGGING = {
             'handlers':['console'],
             'propagate': True,
             'level':'DEBUG',
-        }
+        },
+        '': {
+            'level': 'DEBUG',
+            'handlers': ['console'],
+        },
     },
 }
diff --git a/api/config/settings/test.py b/api/config/settings/test.py
deleted file mode 100644
index aff29c6571252d1f0d401c550cedf09998a5c512..0000000000000000000000000000000000000000
--- a/api/config/settings/test.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from .common import *  # noqa
-SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
-
-# Mail settings
-# ------------------------------------------------------------------------------
-EMAIL_HOST = 'localhost'
-EMAIL_PORT = 1025
-EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
-                    default='django.core.mail.backends.console.EmailBackend')
-
-# CACHING
-# ------------------------------------------------------------------------------
-CACHES = {
-    'default': {
-        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
-        'LOCATION': ''
-    }
-}
-
-CELERY_BROKER_URL = 'memory://'
-
-########## CELERY
-# In development, all tasks will be executed locally by blocking until the task returns
-CELERY_TASK_ALWAYS_EAGER = True
-########## END CELERY
-
-# Your local stuff: Below this line define 3rd party library settings
-API_AUTHENTICATION_REQUIRED = False
-CACHEOPS_ENABLED = False
diff --git a/api/config/urls.py b/api/config/urls.py
index 8f7e37bc26ae56ba9967682f4ec3f19f04cc71f4..90598ea841f474e5b887fda7fe42f23975cd4c00 100644
--- a/api/config/urls.py
+++ b/api/config/urls.py
@@ -13,6 +13,9 @@ urlpatterns = [
     url(settings.ADMIN_URL, admin.site.urls),
 
     url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
+    url(r'^', include(
+        ('funkwhale_api.federation.urls', 'federation'),
+        namespace="federation")),
     url(r'^api/v1/auth/', include('rest_auth.urls')),
     url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
     url(r'^accounts/', include('allauth.urls')),
diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test
index 0990efa512e6a1084249e331e4146f8a223332f5..963e3ab20e4f114a96a176b4a9a1cb5c91f6023c 100644
--- a/api/docker/Dockerfile.test
+++ b/api/docker/Dockerfile.test
@@ -1,4 +1,4 @@
-FROM python:3.5
+FROM python:3.6
 
 ENV PYTHONUNBUFFERED 1
 
diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py
index 325d1e820db5699abca69b57b3421b0e0ca1d68b..fd9b185cf9a6d3891f0356208f62b2bb54e8686f 100644
--- a/api/funkwhale_api/activity/serializers.py
+++ b/api/funkwhale_api/activity/serializers.py
@@ -1,5 +1,7 @@
 from rest_framework import serializers
 
+from funkwhale_api.activity import record
+
 
 class ModelSerializer(serializers.ModelSerializer):
     id = serializers.CharField(source='get_activity_url')
@@ -8,3 +10,15 @@ class ModelSerializer(serializers.ModelSerializer):
 
     def get_url(self, obj):
         return self.get_id(obj)
+
+
+class AutoSerializer(serializers.Serializer):
+    """
+    A serializer that will automatically use registered activity serializers
+    to serialize an henerogeneous list of objects (favorites, listenings, etc.)
+    """
+    def to_representation(self, instance):
+        serializer = record.registry[instance._meta.label]['serializer'](
+            instance
+        )
+        return serializer.data
diff --git a/api/funkwhale_api/activity/utils.py b/api/funkwhale_api/activity/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..46336930ef693e29d9d5696ee3ccf12446c2bdad
--- /dev/null
+++ b/api/funkwhale_api/activity/utils.py
@@ -0,0 +1,64 @@
+from django.db import models
+
+from funkwhale_api.common import fields
+from funkwhale_api.favorites.models import TrackFavorite
+from funkwhale_api.history.models import Listening
+
+
+def combined_recent(limit, **kwargs):
+    datetime_field = kwargs.pop('datetime_field', 'creation_date')
+    source_querysets = {
+        qs.model._meta.label: qs for qs in kwargs.pop('querysets')
+    }
+    querysets = {
+        k: qs.annotate(
+            __type=models.Value(
+                qs.model._meta.label, output_field=models.CharField()
+            )
+        ).values('pk', datetime_field, '__type')
+        for k, qs in source_querysets.items()
+    }
+    _qs_list = list(querysets.values())
+    union_qs = _qs_list[0].union(*_qs_list[1:])
+    records = []
+    for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]:
+        records.append({
+            'type': row['__type'],
+            'when': row[datetime_field],
+            'pk': row['pk']
+        })
+    # Now we bulk-load each object type in turn
+    to_load = {}
+    for record in records:
+        to_load.setdefault(record['type'], []).append(record['pk'])
+    fetched = {}
+
+    for key, pks in to_load.items():
+        for item in source_querysets[key].filter(pk__in=pks):
+            fetched[(key, item.pk)] = item
+
+    # Annotate 'records' with loaded objects
+    for record in records:
+        record['object'] = fetched[(record['type'], record['pk'])]
+    return records
+
+
+def get_activity(user, limit=20):
+    query = fields.privacy_level_query(
+        user, lookup_field='user__privacy_level')
+    querysets = [
+        Listening.objects.filter(query).select_related(
+            'track',
+            'user',
+            'track__artist',
+            'track__album__artist',
+        ),
+        TrackFavorite.objects.filter(query).select_related(
+            'track',
+            'user',
+            'track__artist',
+            'track__album__artist',
+        ),
+    ]
+    records = combined_recent(limit=limit, querysets=querysets)
+    return [r['object'] for r in records]
diff --git a/api/funkwhale_api/activity/views.py b/api/funkwhale_api/activity/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..e66de1ccfdc94f51cd823fa5c6b104488a4aad7f
--- /dev/null
+++ b/api/funkwhale_api/activity/views.py
@@ -0,0 +1,20 @@
+from rest_framework import viewsets
+from rest_framework.response import Response
+
+from funkwhale_api.common.permissions import ConditionalAuthentication
+from funkwhale_api.favorites.models import TrackFavorite
+
+from . import serializers
+from . import utils
+
+
+class ActivityViewSet(viewsets.GenericViewSet):
+
+    serializer_class = serializers.AutoSerializer
+    permission_classes = [ConditionalAuthentication]
+    queryset = TrackFavorite.objects.none()
+
+    def list(self, request, *args, **kwargs):
+        activity = utils.get_activity(user=request.user)
+        serializer = self.serializer_class(activity, many=True)
+        return Response({'results': serializer.data}, status=200)
diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
index ef9f840dc763409c8a1555d693d1939030877fd5..1a18b5f27d1e1839ef32722499de6b4365d6ac55 100644
--- a/api/funkwhale_api/common/fields.py
+++ b/api/funkwhale_api/common/fields.py
@@ -22,6 +22,6 @@ def privacy_level_query(user, lookup_field='privacy_level'):
 
     return models.Q(**{
         '{}__in'.format(lookup_field): [
-            'me', 'followers', 'instance', 'everyone'
+            'followers', 'instance', 'everyone'
         ]
     })
diff --git a/api/funkwhale_api/common/storage.py b/api/funkwhale_api/common/storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..658ce795a4bad7290b1aa8d07766207d2da5a2b3
--- /dev/null
+++ b/api/funkwhale_api/common/storage.py
@@ -0,0 +1,12 @@
+import unicodedata
+
+from django.core.files.storage import FileSystemStorage
+
+
+class ASCIIFileSystemStorage(FileSystemStorage):
+    """
+    Convert unicode characters in name to ASCII characters.
+    """
+    def get_valid_name(self, name):
+        name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore')
+        return super().get_valid_name(name)
diff --git a/api/funkwhale_api/federation/__init__.py b/api/funkwhale_api/federation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..4eeb193b183007ad6c7c093cd20b881a3a06b89b
--- /dev/null
+++ b/api/funkwhale_api/federation/activity.py
@@ -0,0 +1,85 @@
+import logging
+import json
+import requests
+import requests_http_signature
+
+from . import signing
+
+logger = logging.getLogger(__name__)
+
+ACTIVITY_TYPES = [
+    'Accept',
+    'Add',
+    'Announce',
+    'Arrive',
+    'Block',
+    'Create',
+    'Delete',
+    'Dislike',
+    'Flag',
+    'Follow',
+    'Ignore',
+    'Invite',
+    'Join',
+    'Leave',
+    'Like',
+    'Listen',
+    'Move',
+    'Offer',
+    'Question',
+    'Reject',
+    'Read',
+    'Remove',
+    'TentativeReject',
+    'TentativeAccept',
+    'Travel',
+    'Undo',
+    'Update',
+    'View',
+]
+
+
+OBJECT_TYPES = [
+    'Article',
+    'Audio',
+    'Document',
+    'Event',
+    'Image',
+    'Note',
+    'Page',
+    'Place',
+    'Profile',
+    'Relationship',
+    'Tombstone',
+    'Video',
+]
+
+def deliver(activity, on_behalf_of, to=[]):
+    from . import actors
+    logger.info('Preparing activity delivery to %s', to)
+    auth = requests_http_signature.HTTPSignatureAuth(
+        use_auth_header=False,
+        headers=[
+            '(request-target)',
+            'user-agent',
+            'host',
+            'date',
+            'content-type',],
+        algorithm='rsa-sha256',
+        key=on_behalf_of.private_key.encode('utf-8'),
+        key_id=on_behalf_of.private_key_id,
+    )
+    for url in to:
+        recipient_actor = actors.get_actor(url)
+        logger.debug('delivering to %s', recipient_actor.inbox_url)
+        logger.debug('activity content: %s', json.dumps(activity))
+        response = requests.post(
+            auth=auth,
+            json=activity,
+            url=recipient_actor.inbox_url,
+            headers={
+                'Content-Type': 'application/activity+json'
+            }
+        )
+        response.raise_for_status()
+        logger.debug('Remote answered with %s', response.status_code)
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
new file mode 100644
index 0000000000000000000000000000000000000000..69033f5ca890cb094f63d5dbc5bf7f39afe3de44
--- /dev/null
+++ b/api/funkwhale_api/federation/actors.py
@@ -0,0 +1,220 @@
+import logging
+import requests
+import xml
+
+from django.conf import settings
+from django.urls import reverse
+from django.utils import timezone
+
+from rest_framework.exceptions import PermissionDenied
+
+from dynamic_preferences.registries import global_preferences_registry
+
+from . import activity
+from . import models
+from . import serializers
+from . import utils
+
+logger = logging.getLogger(__name__)
+
+
+def remove_tags(text):
+    logger.debug('Removing tags from %s', text)
+    return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext())
+
+
+def get_actor_data(actor_url):
+    response = requests.get(
+        actor_url,
+        headers={
+            'Accept': 'application/activity+json',
+        }
+    )
+    response.raise_for_status()
+    try:
+        return response.json()
+    except:
+        raise ValueError(
+            'Invalid actor payload: {}'.format(response.text))
+
+def get_actor(actor_url):
+    data = get_actor_data(actor_url)
+    serializer = serializers.ActorSerializer(data=data)
+    serializer.is_valid(raise_exception=True)
+
+    return serializer.build()
+
+
+class SystemActor(object):
+    additional_attributes = {}
+
+    def get_actor_instance(self):
+        a = models.Actor(
+            **self.get_instance_argument(
+                self.id,
+                name=self.name,
+                summary=self.summary,
+                **self.additional_attributes
+            )
+        )
+        a.pk = self.id
+        return a
+
+    def get_instance_argument(self, id, name, summary, **kwargs):
+        preferences = global_preferences_registry.manager()
+        p = {
+            'preferred_username': id,
+            'domain': settings.FEDERATION_HOSTNAME,
+            'type': 'Person',
+            'name': name.format(host=settings.FEDERATION_HOSTNAME),
+            'manually_approves_followers': True,
+            'url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-detail',
+                    kwargs={'actor': id})),
+            'shared_inbox_url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-inbox',
+                    kwargs={'actor': id})),
+            'inbox_url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-inbox',
+                    kwargs={'actor': id})),
+            'outbox_url': utils.full_url(
+                reverse(
+                    'federation:instance-actors-outbox',
+                    kwargs={'actor': id})),
+            'public_key': preferences['federation__public_key'],
+            'private_key': preferences['federation__private_key'],
+            'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
+        }
+        p.update(kwargs)
+        return p
+
+    def get_inbox(self, data, actor=None):
+        raise NotImplementedError
+
+    def post_inbox(self, data, actor=None):
+        raise NotImplementedError
+
+    def get_outbox(self, data, actor=None):
+        raise NotImplementedError
+
+    def post_outbox(self, data, actor=None):
+        raise NotImplementedError
+
+
+class LibraryActor(SystemActor):
+    id = 'library'
+    name = '{host}\'s library'
+    summary = 'Bot account to federate with {host}\'s library'
+    additional_attributes = {
+        'manually_approves_followers': True
+    }
+
+
+class TestActor(SystemActor):
+    id = 'test'
+    name = '{host}\'s test account'
+    summary = (
+        'Bot account to test federation with {host}. '
+        'Send me /ping and I\'ll answer you.'
+    )
+    additional_attributes = {
+        'manually_approves_followers': False
+    }
+
+    def get_outbox(self, data, actor=None):
+        return {
+        	"@context": [
+        		"https://www.w3.org/ns/activitystreams",
+        		"https://w3id.org/security/v1",
+        		{}
+        	],
+        	"id": utils.full_url(
+                reverse(
+                    'federation:instance-actors-outbox',
+                    kwargs={'actor': self.id})),
+        	"type": "OrderedCollection",
+        	"totalItems": 0,
+        	"orderedItems": []
+        }
+
+    def post_inbox(self, data, actor=None):
+        if actor is None:
+            raise PermissionDenied('Actor not authenticated')
+
+        serializer = serializers.ActivitySerializer(
+            data=data, context={'actor': actor})
+        serializer.is_valid(raise_exception=True)
+
+        ac = serializer.validated_data
+        logger.info('Received activity on %s inbox', self.id)
+        if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
+            # we received a toot \o/
+            command = self.parse_command(ac['object']['content'])
+            logger.debug('Parsed command: %s', command)
+            if command == 'ping':
+                self.handle_ping(ac, actor)
+
+    def parse_command(self, message):
+        """
+        Remove any links or fancy markup to extract /command from
+        a note message.
+        """
+        raw = remove_tags(message)
+        try:
+            return raw.split('/')[1]
+        except IndexError:
+            return
+
+    def handle_ping(self, ac, sender):
+        now = timezone.now()
+        test_actor = self.get_actor_instance()
+        reply_url = 'https://{}/activities/note/{}'.format(
+            settings.FEDERATION_HOSTNAME, now.timestamp()
+        )
+        reply_content = '{} Pong!'.format(
+            sender.mention_username
+        )
+        reply_activity = {
+            "@context": [
+        		"https://www.w3.org/ns/activitystreams",
+        		"https://w3id.org/security/v1",
+        		{}
+        	],
+            'type': 'Create',
+            'actor': test_actor.url,
+            'id': '{}/activity'.format(reply_url),
+            'published': now.isoformat(),
+            'to': ac['actor'],
+            'cc': [],
+            'object': {
+                'type': 'Note',
+                'content': 'Pong!',
+                'summary': None,
+                'published': now.isoformat(),
+                'id': reply_url,
+                'inReplyTo': ac['object']['id'],
+                'sensitive': False,
+                'url': reply_url,
+                'to': [ac['actor']],
+                'attributedTo': test_actor.url,
+                'cc': [],
+                'attachment': [],
+                'tag': [{
+                    "type": "Mention",
+                    "href": ac['actor'],
+                    "name": sender.mention_username
+                }]
+            }
+        }
+        activity.deliver(
+            reply_activity,
+            to=[ac['actor']],
+            on_behalf_of=test_actor)
+
+SYSTEM_ACTORS = {
+    'library': LibraryActor(),
+    'test': TestActor(),
+}
diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py
new file mode 100644
index 0000000000000000000000000000000000000000..e199ef134d03e0d7026ecffbbaaa1f38e8254e02
--- /dev/null
+++ b/api/funkwhale_api/federation/authentication.py
@@ -0,0 +1,52 @@
+import cryptography
+
+from django.contrib.auth.models import AnonymousUser
+
+from rest_framework import authentication
+from rest_framework import exceptions
+
+from . import actors
+from . import keys
+from . import serializers
+from . import signing
+from . import utils
+
+
+class SignatureAuthentication(authentication.BaseAuthentication):
+    def authenticate_actor(self, request):
+        headers = utils.clean_wsgi_headers(request.META)
+        try:
+            signature = headers['Signature']
+            key_id = keys.get_key_id_from_signature_header(signature)
+        except KeyError:
+            return
+        except ValueError as e:
+            raise exceptions.AuthenticationFailed(str(e))
+
+        try:
+            actor_data = actors.get_actor_data(key_id)
+        except Exception as e:
+            raise exceptions.AuthenticationFailed(str(e))
+
+        try:
+            public_key = actor_data['publicKey']['publicKeyPem']
+        except KeyError:
+            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'))
+        except cryptography.exceptions.InvalidSignature:
+            raise exceptions.AuthenticationFailed('Invalid signature')
+
+        return serializer.build()
+
+    def authenticate(self, request):
+        setattr(request, 'actor', None)
+        actor = self.authenticate_actor(request)
+        user = AnonymousUser()
+        setattr(request, 'actor', actor)
+        return (user, None)
diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..83d0285be263d228ffbb564a9bfc9f898fe77dbf
--- /dev/null
+++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py
@@ -0,0 +1,34 @@
+from django.forms import widgets
+
+from dynamic_preferences import types
+from dynamic_preferences.registries import global_preferences_registry
+
+federation = types.Section('federation')
+
+
+@global_preferences_registry.register
+class FederationPrivateKey(types.StringPreference):
+    show_in_api = False
+    section = federation
+    name = 'private_key'
+    default = ''
+    help_text = (
+        'Instance private key, used for signing federation HTTP requests'
+    )
+    verbose_name = (
+        'Instance private key (keep it secret, do not change it)'
+    )
+
+
+@global_preferences_registry.register
+class FederationPublicKey(types.StringPreference):
+    show_in_api = False
+    section = federation
+    name = 'public_key'
+    default = ''
+    help_text = (
+        'Instance public key, used for signing federation HTTP requests'
+    )
+    verbose_name = (
+        'Instance public key (do not change it)'
+    )
diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..31d864b36c065aacadd8b09ed02278e13bd46fcb
--- /dev/null
+++ b/api/funkwhale_api/federation/exceptions.py
@@ -0,0 +1,8 @@
+
+
+class MalformedPayload(ValueError):
+    pass
+
+
+class MissingSignature(KeyError):
+    pass
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..88c86f791937cd66fbcc72a42592b520078c8a7c
--- /dev/null
+++ b/api/funkwhale_api/federation/factories.py
@@ -0,0 +1,91 @@
+import factory
+import requests
+import requests_http_signature
+
+from django.utils import timezone
+
+from funkwhale_api.factories import registry
+
+from . import keys
+from . import models
+
+
+registry.register(keys.get_key_pair, name='federation.KeyPair')
+
+
+@registry.register(name='federation.SignatureAuth')
+class SignatureAuthFactory(factory.Factory):
+    algorithm = 'rsa-sha256'
+    key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
+    key_id = factory.Faker('url')
+    use_auth_header = False
+    headers = [
+        '(request-target)',
+        'user-agent',
+        'host',
+        'date',
+        'content-type',]
+    class Meta:
+        model = requests_http_signature.HTTPSignatureAuth
+
+
+@registry.register(name='federation.SignedRequest')
+class SignedRequestFactory(factory.Factory):
+    url = factory.Faker('url')
+    method = 'get'
+    auth = factory.SubFactory(SignatureAuthFactory)
+
+    class Meta:
+        model = requests.Request
+
+    @factory.post_generation
+    def headers(self, create, extracted, **kwargs):
+        default_headers = {
+            'User-Agent': 'Test',
+            'Host': 'test.host',
+            'Date': 'Right now',
+            'Content-Type': 'application/activity+json'
+        }
+        if extracted:
+            default_headers.update(extracted)
+        self.headers.update(default_headers)
+
+
+@registry.register
+class ActorFactory(factory.DjangoModelFactory):
+
+    public_key = None
+    private_key = None
+    preferred_username = factory.Faker('user_name')
+    summary = factory.Faker('paragraph')
+    domain = factory.Faker('domain_name')
+    url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username))
+    inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username))
+    outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username))
+
+    class Meta:
+        model = models.Actor
+
+    @classmethod
+    def _generate(cls, create, attrs):
+        has_public = attrs.get('public_key') is not None
+        has_private = attrs.get('private_key') is not None
+        if not has_public and not has_private:
+            private, public = keys.get_key_pair()
+            attrs['private_key'] = private.decode('utf-8')
+            attrs['public_key'] = public.decode('utf-8')
+        return super()._generate(create, attrs)
+
+
+@registry.register(name='federation.Note')
+class NoteFactory(factory.Factory):
+    type = 'Note'
+    id = factory.Faker('url')
+    published = factory.LazyFunction(
+        lambda: timezone.now().isoformat()
+    )
+    inReplyTo = None
+    content = factory.Faker('sentence')
+
+    class Meta:
+        model = dict
diff --git a/api/funkwhale_api/federation/keys.py b/api/funkwhale_api/federation/keys.py
new file mode 100644
index 0000000000000000000000000000000000000000..08d4034ea347a6a17bb5d5701217d54cc1c57fa0
--- /dev/null
+++ b/api/funkwhale_api/federation/keys.py
@@ -0,0 +1,49 @@
+from cryptography.hazmat.primitives import serialization as crypto_serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.backends import default_backend as crypto_default_backend
+
+import re
+import requests
+import urllib.parse
+
+from . import exceptions
+
+KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"')
+
+
+def get_key_pair(size=2048):
+    key = rsa.generate_private_key(
+        backend=crypto_default_backend(),
+        public_exponent=65537,
+        key_size=size
+    )
+    private_key = key.private_bytes(
+        crypto_serialization.Encoding.PEM,
+        crypto_serialization.PrivateFormat.PKCS8,
+        crypto_serialization.NoEncryption())
+    public_key = key.public_key().public_bytes(
+        crypto_serialization.Encoding.PEM,
+        crypto_serialization.PublicFormat.PKCS1
+    )
+
+    return private_key, public_key
+
+
+def get_key_id_from_signature_header(header_string):
+    parts = header_string.split(',')
+    try:
+        raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
+    except IndexError:
+        raise ValueError('Missing key id')
+
+    match = KEY_ID_REGEX.match(raw_key_id)
+    if not match:
+        raise ValueError('Invalid key id')
+
+    key_id = match.groups()[0]
+    url = urllib.parse.urlparse(key_id)
+    if not url.scheme or not url.netloc:
+        raise ValueError('Invalid url')
+    if url.scheme not in ['http', 'https']:
+        raise ValueError('Invalid shceme')
+    return key_id
diff --git a/api/funkwhale_api/federation/management/__init__.py b/api/funkwhale_api/federation/management/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/federation/management/commands/__init__.py b/api/funkwhale_api/federation/management/commands/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/federation/management/commands/generate_keys.py b/api/funkwhale_api/federation/management/commands/generate_keys.py
new file mode 100644
index 0000000000000000000000000000000000000000..eafe9aae3477753a7b61cbc854152e3d95e26e59
--- /dev/null
+++ b/api/funkwhale_api/federation/management/commands/generate_keys.py
@@ -0,0 +1,53 @@
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from dynamic_preferences.registries import global_preferences_registry
+
+from funkwhale_api.federation import keys
+
+
+class Command(BaseCommand):
+    help = (
+        'Generate a public/private key pair for your instance,'
+        ' for federation purposes. If a key pair already exists, does nothing.'
+    )
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            '--replace',
+            action='store_true',
+            dest='replace',
+            default=False,
+            help='Replace existing key pair, if any',
+        )
+        parser.add_argument(
+            '--noinput', '--no-input', action='store_false', dest='interactive',
+            help="Do NOT prompt the user for input of any kind.",
+        )
+
+    @transaction.atomic
+    def handle(self, *args, **options):
+        preferences = global_preferences_registry.manager()
+        existing_public = preferences['federation__public_key']
+        existing_private = preferences['federation__public_key']
+
+        if existing_public or existing_private and not options['replace']:
+            raise CommandError(
+                'Keys are already present! '
+                'Replace them with --replace if you know what you are doing.')
+
+        if options['interactive']:
+            message = (
+                'Are you sure you want to do this?\n\n'
+                "Type 'yes' to continue, or 'no' to cancel: "
+            )
+            if input(''.join(message)) != 'yes':
+                raise CommandError("Operation cancelled.")
+        private, public = keys.get_key_pair()
+        preferences['federation__public_key'] = public.decode('utf-8')
+        preferences['federation__private_key'] = private.decode('utf-8')
+
+        self.stdout.write(
+            'Your new key pair was generated.'
+            'Your public key is now:\n\n{}'.format(public.decode('utf-8'))
+        )
diff --git a/api/funkwhale_api/federation/migrations/0001_initial.py b/api/funkwhale_api/federation/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..a9157e57e3fa123c5e79b2d23176f7cd744ee926
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 2.0.3 on 2018-03-31 13:43
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Actor',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('url', models.URLField(db_index=True, max_length=500, unique=True)),
+                ('outbox_url', models.URLField(max_length=500)),
+                ('inbox_url', models.URLField(max_length=500)),
+                ('following_url', models.URLField(blank=True, max_length=500, null=True)),
+                ('followers_url', models.URLField(blank=True, max_length=500, null=True)),
+                ('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)),
+                ('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)),
+                ('name', models.CharField(blank=True, max_length=200, null=True)),
+                ('domain', models.CharField(max_length=1000)),
+                ('summary', models.CharField(blank=True, max_length=500, null=True)),
+                ('preferred_username', models.CharField(blank=True, max_length=200, null=True)),
+                ('public_key', models.CharField(blank=True, max_length=5000, null=True)),
+                ('private_key', models.CharField(blank=True, max_length=5000, null=True)),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('manually_approves_followers', models.NullBooleanField(default=None)),
+            ],
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/migrations/__init__.py b/api/funkwhale_api/federation/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..d76ad173be80c0d37dc063a7b0d829952f9e16db
--- /dev/null
+++ b/api/funkwhale_api/federation/models.py
@@ -0,0 +1,59 @@
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+TYPE_CHOICES = [
+    ('Person', 'Person'),
+    ('Application', 'Application'),
+    ('Group', 'Group'),
+    ('Organization', 'Organization'),
+    ('Service', 'Service'),
+]
+
+
+class Actor(models.Model):
+    url = models.URLField(unique=True, max_length=500, db_index=True)
+    outbox_url = models.URLField(max_length=500)
+    inbox_url = models.URLField(max_length=500)
+    following_url = models.URLField(max_length=500, null=True, blank=True)
+    followers_url = models.URLField(max_length=500, null=True, blank=True)
+    shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
+    type = models.CharField(
+        choices=TYPE_CHOICES, default='Person', max_length=25)
+    name = models.CharField(max_length=200, null=True, blank=True)
+    domain = models.CharField(max_length=1000)
+    summary = models.CharField(max_length=500, null=True, blank=True)
+    preferred_username = models.CharField(
+        max_length=200, null=True, blank=True)
+    public_key = models.CharField(max_length=5000, null=True, blank=True)
+    private_key = models.CharField(max_length=5000, null=True, blank=True)
+    creation_date = models.DateTimeField(default=timezone.now)
+    last_fetch_date = models.DateTimeField(
+        default=timezone.now)
+    manually_approves_followers = models.NullBooleanField(default=None)
+
+    @property
+    def webfinger_subject(self):
+        return '{}@{}'.format(
+            self.preferred_username,
+            settings.FEDERATION_HOSTNAME,
+        )
+
+    @property
+    def private_key_id(self):
+        return '{}#main-key'.format(self.url)
+
+    @property
+    def mention_username(self):
+        return '@{}@{}'.format(self.preferred_username, self.domain)
+
+    def save(self, **kwargs):
+        lowercase_fields = [
+            'domain',
+        ]
+        for field in lowercase_fields:
+            v = getattr(self, field, None)
+            if v:
+                setattr(self, field, v.lower())
+
+        super().save(**kwargs)
diff --git a/api/funkwhale_api/federation/parsers.py b/api/funkwhale_api/federation/parsers.py
new file mode 100644
index 0000000000000000000000000000000000000000..874d808f973dfcdf99372332b3991136c44d8605
--- /dev/null
+++ b/api/funkwhale_api/federation/parsers.py
@@ -0,0 +1,5 @@
+from rest_framework import parsers
+
+
+class ActivityParser(parsers.JSONParser):
+    media_type = 'application/activity+json'
diff --git a/api/funkwhale_api/federation/renderers.py b/api/funkwhale_api/federation/renderers.py
new file mode 100644
index 0000000000000000000000000000000000000000..642b634628f2787044eae9b6a96b9e7004b05244
--- /dev/null
+++ b/api/funkwhale_api/federation/renderers.py
@@ -0,0 +1,9 @@
+from rest_framework.renderers import JSONRenderer
+
+
+class ActivityPubRenderer(JSONRenderer):
+    media_type = 'application/activity+json'
+
+
+class WebfingerRenderer(JSONRenderer):
+    media_type = 'application/jrd+json'
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..2137e8d910373e0c8d23b3e38c0d9952e4a93787
--- /dev/null
+++ b/api/funkwhale_api/federation/serializers.py
@@ -0,0 +1,175 @@
+import urllib.parse
+
+from django.urls import reverse
+from django.conf import settings
+
+from rest_framework import serializers
+from dynamic_preferences.registries import global_preferences_registry
+
+from . import activity
+from . import models
+from . import utils
+
+
+class ActorSerializer(serializers.ModelSerializer):
+    # left maps to activitypub fields, right to our internal models
+    id = serializers.URLField(source='url')
+    outbox = serializers.URLField(source='outbox_url')
+    inbox = serializers.URLField(source='inbox_url')
+    following = serializers.URLField(source='following_url', required=False)
+    followers = serializers.URLField(source='followers_url', required=False)
+    preferredUsername = serializers.CharField(
+        source='preferred_username', required=False)
+    publicKey = serializers.JSONField(source='public_key', required=False)
+    manuallyApprovesFollowers = serializers.NullBooleanField(
+        source='manually_approves_followers', required=False)
+    summary = serializers.CharField(max_length=None, required=False)
+
+    class Meta:
+        model = models.Actor
+        fields = [
+            'id',
+            'type',
+            'name',
+            'summary',
+            'preferredUsername',
+            'publicKey',
+            'inbox',
+            'outbox',
+            'following',
+            'followers',
+            'manuallyApprovesFollowers',
+        ]
+
+    def to_representation(self, instance):
+        ret = super().to_representation(instance)
+        ret['@context'] = [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ]
+        if instance.public_key:
+            ret['publicKey'] = {
+                'owner': instance.url,
+                'publicKeyPem': instance.public_key,
+                'id': '{}#main-key'.format(instance.url)
+            }
+        ret['endpoints'] = {}
+        if instance.shared_inbox_url:
+            ret['endpoints']['sharedInbox'] = instance.shared_inbox_url
+        return ret
+
+    def prepare_missing_fields(self):
+        kwargs = {}
+        domain = urllib.parse.urlparse(self.validated_data['url']).netloc
+        kwargs['domain'] = domain
+        for endpoint, url in self.initial_data.get('endpoints', {}).items():
+            if endpoint == 'sharedInbox':
+                kwargs['shared_inbox_url'] = url
+                break
+        try:
+            kwargs['public_key'] = self.initial_data['publicKey']['publicKeyPem']
+        except KeyError:
+            pass
+        return kwargs
+
+    def build(self):
+        d = self.validated_data.copy()
+        d.update(self.prepare_missing_fields())
+        return self.Meta.model(**d)
+
+    def save(self, **kwargs):
+        kwargs.update(self.prepare_missing_fields())
+        return super().save(**kwargs)
+
+    def validate_summary(self, value):
+        if value:
+            return value[:500]
+
+
+class ActorWebfingerSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.Actor
+        fields = ['url']
+
+    def to_representation(self, instance):
+        data = {}
+        data['subject'] = 'acct:{}'.format(instance.webfinger_subject)
+        data['links'] = [
+            {
+                'rel': 'self',
+                'href': instance.url,
+                'type': 'application/activity+json'
+            }
+        ]
+        data['aliases'] = [
+            instance.url
+        ]
+        return data
+
+
+class ActivitySerializer(serializers.Serializer):
+    actor = serializers.URLField()
+    id = serializers.URLField()
+    type = serializers.ChoiceField(
+        choices=[(c, c) for c in activity.ACTIVITY_TYPES])
+    object = serializers.JSONField()
+
+    def validate_object(self, value):
+        try:
+            type = value['type']
+        except KeyError:
+            raise serializers.ValidationError('Missing object type')
+
+        try:
+            object_serializer = OBJECT_SERIALIZERS[type]
+        except KeyError:
+            raise serializers.ValidationError(
+                'Unsupported type {}'.format(type))
+
+        serializer = object_serializer(data=value)
+        serializer.is_valid(raise_exception=True)
+        return serializer.data
+
+    def validate_actor(self, value):
+        request_actor = self.context.get('actor')
+        if request_actor and request_actor.url != value:
+            raise serializers.ValidationError(
+                'The actor making the request do not match'
+                ' the activity actor'
+            )
+        return value
+
+
+class ObjectSerializer(serializers.Serializer):
+    id = serializers.URLField()
+    url = serializers.URLField(required=False, allow_null=True)
+    type = serializers.ChoiceField(
+        choices=[(c, c) for c in activity.OBJECT_TYPES])
+    content = serializers.CharField(
+        required=False, allow_null=True)
+    summary = serializers.CharField(
+        required=False, allow_null=True)
+    name = serializers.CharField(
+        required=False, allow_null=True)
+    published = serializers.DateTimeField(
+        required=False, allow_null=True)
+    updated = serializers.DateTimeField(
+        required=False, allow_null=True)
+    to = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+    cc = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+    bto = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+    bcc = serializers.ListField(
+        child=serializers.URLField(),
+        required=False, allow_null=True)
+
+OBJECT_SERIALIZERS = {
+    t: ObjectSerializer
+    for t in activity.OBJECT_TYPES
+}
diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e4d2aa5ae08748ea5b6975aa7345d33f993ab1c
--- /dev/null
+++ b/api/funkwhale_api/federation/signing.py
@@ -0,0 +1,55 @@
+import logging
+import requests
+import requests_http_signature
+
+from . import exceptions
+from . import utils
+
+logger = logging.getLogger(__name__)
+
+
+def verify(request, public_key):
+    return requests_http_signature.HTTPSignatureAuth.verify(
+        request,
+        key_resolver=lambda **kwargs: public_key,
+        use_auth_header=False,
+    )
+
+
+def verify_django(django_request, public_key):
+    """
+    Given a django WSGI request, create an underlying requests.PreparedRequest
+    instance we can verify
+    """
+    headers = utils.clean_wsgi_headers(django_request.META)
+    for h, v in list(headers.items()):
+        # we include lower-cased version of the headers for compatibility
+        # with requests_http_signature
+        headers[h.lower()] = v
+    try:
+        signature = headers['Signature']
+    except KeyError:
+        raise exceptions.MissingSignature
+    url = 'http://noop{}'.format(django_request.path)
+    query = django_request.META['QUERY_STRING']
+    if query:
+        url += '?{}'.format(query)
+    signature_headers = signature.split('headers="')[1].split('",')[0]
+    expected = signature_headers.split(' ')
+    logger.debug('Signature expected headers: %s', expected)
+    for header in expected:
+        try:
+            headers[header]
+        except KeyError:
+            logger.debug('Missing header: %s', header)
+    request = requests.Request(
+        method=django_request.method,
+        url=url,
+        data=django_request.body,
+        headers=headers)
+    for h in request.headers.keys():
+        v = request.headers[h]
+        if v:
+            request.headers[h] = str(v)
+    prepared_request = request.prepare()
+    return verify(request, public_key)
diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2c6f4c78c61973436b3d92aacebdec3506156fd
--- /dev/null
+++ b/api/funkwhale_api/federation/urls.py
@@ -0,0 +1,15 @@
+from rest_framework import routers
+
+from . import views
+
+router = routers.SimpleRouter(trailing_slash=False)
+router.register(
+    r'federation/instance/actors',
+    views.InstanceActorViewSet,
+    'instance-actors')
+router.register(
+    r'.well-known',
+    views.WellKnownViewSet,
+    'well-known')
+
+urlpatterns = router.urls
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..df093add8f934bbaabae8f6e9e68712f3762c53d
--- /dev/null
+++ b/api/funkwhale_api/federation/utils.py
@@ -0,0 +1,35 @@
+from django.conf import settings
+
+
+def full_url(path):
+    """
+    Given a relative path, return a full url usable for federation purpose
+    """
+    root = settings.FUNKWHALE_URL
+    if path.startswith('/') and root.endswith('/'):
+        return root + path[1:]
+    elif not path.startswith('/') and not root.endswith('/'):
+        return root + '/' + path
+    else:
+        return root + path
+
+
+def clean_wsgi_headers(raw_headers):
+    """
+    Convert WSGI headers from CONTENT_TYPE to Content-Type notation
+    """
+    cleaned = {}
+    non_prefixed = [
+        'content_type',
+        'content_length',
+    ]
+    for raw_header, value in raw_headers.items():
+        h = raw_header.lower()
+        if not h.startswith('http_') and h not in non_prefixed:
+            continue
+
+        words = h.replace('http_', '', 1).split('_')
+        cleaned_header = '-'.join([w.capitalize() for w in words])
+        cleaned[cleaned_header] = value
+
+    return cleaned
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e3feb8d082ebdca00917e59e13d5f3cc601eb33
--- /dev/null
+++ b/api/funkwhale_api/federation/views.py
@@ -0,0 +1,103 @@
+from django import forms
+from django.conf import settings
+from django.http import HttpResponse
+
+from rest_framework import viewsets
+from rest_framework import views
+from rest_framework import response
+from rest_framework.decorators import list_route, detail_route
+
+from . import actors
+from . import authentication
+from . import renderers
+from . import serializers
+from . import webfinger
+
+
+class FederationMixin(object):
+    def dispatch(self, request, *args, **kwargs):
+        if not settings.FEDERATION_ENABLED:
+            return HttpResponse(status=405)
+        return super().dispatch(request, *args, **kwargs)
+
+
+class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
+    lookup_field = 'actor'
+    lookup_value_regex = '[a-z]*'
+    authentication_classes = [
+        authentication.SignatureAuthentication]
+    permission_classes = []
+    renderer_classes = [renderers.ActivityPubRenderer]
+
+    def get_object(self):
+        try:
+            return actors.SYSTEM_ACTORS[self.kwargs['actor']]
+        except KeyError:
+            raise Http404
+
+    def retrieve(self, request, *args, **kwargs):
+        system_actor = self.get_object()
+        actor = system_actor.get_actor_instance()
+        serializer = serializers.ActorSerializer(actor)
+        return response.Response(serializer.data, status=200)
+
+    @detail_route(methods=['get', 'post'])
+    def inbox(self, request, *args, **kwargs):
+        system_actor = self.get_object()
+        handler = getattr(system_actor, '{}_inbox'.format(
+            request.method.lower()
+        ))
+
+        try:
+            data = handler(request.data, actor=request.actor)
+        except NotImplementedError:
+            return response.Response(status=405)
+        return response.Response(data, status=200)
+
+    @detail_route(methods=['get', 'post'])
+    def outbox(self, request, *args, **kwargs):
+        system_actor = self.get_object()
+        handler = getattr(system_actor, '{}_outbox'.format(
+            request.method.lower()
+        ))
+        try:
+            data = handler(request.data, actor=request.actor)
+        except NotImplementedError:
+            return response.Response(status=405)
+        return response.Response(data, status=200)
+
+
+class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
+    authentication_classes = []
+    permission_classes = []
+    renderer_classes = [renderers.WebfingerRenderer]
+
+    @list_route(methods=['get'])
+    def webfinger(self, request, *args, **kwargs):
+        try:
+            resource_type, resource = webfinger.clean_resource(
+                request.GET['resource'])
+            cleaner = getattr(webfinger, 'clean_{}'.format(resource_type))
+            result = cleaner(resource)
+        except forms.ValidationError as e:
+            return response.Response({
+                'errors': {
+                    'resource': e.message
+                }
+            }, status=400)
+        except KeyError:
+            return response.Response({
+                'errors': {
+                    'resource': 'This field is required',
+                }
+            }, status=400)
+
+        handler = getattr(self, 'handler_{}'.format(resource_type))
+        data = handler(result)
+
+        return response.Response(data)
+
+    def handler_acct(self, clean_result):
+        username, hostname = clean_result
+        actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
+        return serializers.ActorWebfingerSerializer(actor).data
diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e97533852421210aead271aa510c40d29478e6e
--- /dev/null
+++ b/api/funkwhale_api/federation/webfinger.py
@@ -0,0 +1,39 @@
+from django import forms
+from django.conf import settings
+from django.urls import reverse
+
+from . import actors
+from . import utils
+
+VALID_RESOURCE_TYPES = ['acct']
+
+
+def clean_resource(resource_string):
+    if not resource_string:
+        raise forms.ValidationError('Invalid resource string')
+
+    try:
+        resource_type, resource = resource_string.split(':', 1)
+    except ValueError:
+        raise forms.ValidationError('Missing webfinger resource type')
+
+    if resource_type not in VALID_RESOURCE_TYPES:
+        raise forms.ValidationError('Invalid webfinger resource type')
+
+    return resource_type, resource
+
+
+def clean_acct(acct_string):
+    try:
+        username, hostname = acct_string.split('@')
+    except ValueError:
+        raise forms.ValidationError('Invalid format')
+
+    if hostname.lower() != settings.FEDERATION_HOSTNAME:
+        raise forms.ValidationError(
+            'Invalid hostname {}'.format(hostname))
+
+    if username not in actors.SYSTEM_ACTORS:
+        raise forms.ValidationError('Invalid username')
+
+    return username, hostname
diff --git a/api/funkwhale_api/history/admin.py b/api/funkwhale_api/history/admin.py
index 6d0480e73b4209629499c7ae26b8c5efa8348999..5ddfb899848f389d776632eaf9e3b6d389cd7f58 100644
--- a/api/funkwhale_api/history/admin.py
+++ b/api/funkwhale_api/history/admin.py
@@ -4,7 +4,7 @@ from . import models
 
 @admin.register(models.Listening)
 class ListeningAdmin(admin.ModelAdmin):
-    list_display = ['track', 'end_date', 'user', 'session_key']
+    list_display = ['track', 'creation_date', 'user', 'session_key']
     search_fields = ['track__name', 'user__username']
     list_select_related = [
         'user',
diff --git a/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py
new file mode 100644
index 0000000000000000000000000000000000000000..d83dbb0a466b668279619e53406b8ae977ab5dc7
--- /dev/null
+++ b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py
@@ -0,0 +1,22 @@
+# Generated by Django 2.0.3 on 2018-03-25 14:33
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('history', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='listening',
+            options={'ordering': ('-creation_date',)},
+        ),
+        migrations.RenameField(
+            model_name='listening',
+            old_name='end_date',
+            new_name='creation_date',
+        ),
+    ]
diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py
index 56310ddc0d2546784bed02952fe6142a2d139858..762d5bf7b2cf66bdd9a96325c630db65a53ddaae 100644
--- a/api/funkwhale_api/history/models.py
+++ b/api/funkwhale_api/history/models.py
@@ -6,7 +6,8 @@ from funkwhale_api.music.models import Track
 
 
 class Listening(models.Model):
-    end_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
+    creation_date = models.DateTimeField(
+        default=timezone.now, null=True, blank=True)
     track = models.ForeignKey(
         Track, related_name="listenings", on_delete=models.CASCADE)
     user = models.ForeignKey(
@@ -18,7 +19,7 @@ class Listening(models.Model):
     session_key = models.CharField(max_length=100, null=True, blank=True)
 
     class Meta:
-        ordering = ('-end_date',)
+        ordering = ('-creation_date',)
 
     def save(self, **kwargs):
         if not self.user and not self.session_key:
diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py
index 7a2280cea2a236357982f309b7218c3e0d073299..8fe6fa6e01f07a395f2c337ea45591bd315a03d3 100644
--- a/api/funkwhale_api/history/serializers.py
+++ b/api/funkwhale_api/history/serializers.py
@@ -12,7 +12,7 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer):
     type = serializers.SerializerMethodField()
     object = TrackActivitySerializer(source='track')
     actor = UserActivitySerializer(source='user')
-    published = serializers.DateTimeField(source='end_date')
+    published = serializers.DateTimeField(source='creation_date')
 
     class Meta:
         model = models.Listening
@@ -36,7 +36,7 @@ class ListeningSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = models.Listening
-        fields = ('id', 'user', 'session_key', 'track', 'end_date')
+        fields = ('id', 'user', 'session_key', 'track', 'creation_date')
 
 
     def create(self, validated_data):
diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py
index 31d13d4957f6882b647b99388f334a1b03f092bb..3748d55730faf8188d56828bda3afd67dd219393 100644
--- a/api/funkwhale_api/music/metadata.py
+++ b/api/funkwhale_api/music/metadata.py
@@ -121,7 +121,13 @@ class Metadata(object):
 
     def __init__(self, path):
         self._file = mutagen.File(path)
-        self._conf = CONF[self.get_file_type(self._file)]
+        if self._file is None:
+            raise ValueError('Cannot parse metadata from {}'.format(path))
+        ft = self.get_file_type(self._file)
+        try:
+            self._conf = CONF[ft]
+        except KeyError:
+            raise ValueError('Unsupported format {}'.format(ft))
 
     def get_file_type(self, f):
         return f.__class__.__name__
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 0d33855a6b09060eb5094f8b56aaac9aac641a89..5ac3143f9e647feee128a07bbed29b18e9766aa7 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -328,7 +328,7 @@ class SubmitViewSet(viewsets.ViewSet):
         job = models.ImportJob.objects.create(mbid=request.POST['mbid'], batch=batch, source=request.POST['import_url'])
         tasks.import_job_run.delay(import_job_id=job.pk)
         serializer = serializers.ImportBatchSerializer(batch)
-        return Response(serializer.data)
+        return Response(serializer.data, status=201)
 
     def get_import_request(self, data):
         try:
diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
index 2fa5e464cb6c650c3250f68888b0dfe599b25546..dbc01289f1206c18a15a691d2fb8025695155b17 100644
--- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
+++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py
@@ -34,6 +34,13 @@ class Command(BaseCommand):
             default=False,
             help='Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI',
         )
+        parser.add_argument(
+            '--exit', '-x',
+            action='store_true',
+            dest='exit_on_failure',
+            default=False,
+            help='use this flag to disable error catching',
+        )
         parser.add_argument(
             '--no-acoustid',
             action='store_true',
@@ -106,20 +113,27 @@ class Command(BaseCommand):
         async = options['async']
         import_handler = tasks.import_job_run.delay if async else tasks.import_job_run
         for path in matching:
-            job = batch.jobs.create(
-                source='file://' + path,
-            )
-            name = os.path.basename(path)
-            with open(path, 'rb') as f:
-                job.audio_file.save(name, File(f))
-
-            job.save()
             try:
-                utils.on_commit(
-                    import_handler,
-                    import_job_id=job.pk,
-                    use_acoustid=not options['no_acoustid'])
+                self.stdout.write(message.format(path))
+                self.import_file(path, batch, import_handler, options)
             except Exception as e:
-                self.stdout.write('Error: {}'.format(e))
-
+                if options['exit_on_failure']:
+                    raise
+                m = 'Error while importing {}: {} {}'.format(
+                    path, e.__class__.__name__, e)
+                self.stderr.write(m)
         return batch
+
+    def import_file(self, path, batch, import_handler, options):
+        job = batch.jobs.create(
+            source='file://' + path,
+        )
+        name = os.path.basename(path)
+        with open(path, 'rb') as f:
+            job.audio_file.save(name, File(f))
+
+        job.save()
+        utils.on_commit(
+            import_handler,
+            import_job_id=job.pk,
+            use_acoustid=not options['no_acoustid'])
diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py
index 520e98652f4238de071ae49f419525f745e97785..2e7e6a409fb4e4102f1d713faaeb94ca3b7e759a 100644
--- a/api/funkwhale_api/radios/serializers.py
+++ b/api/funkwhale_api/radios/serializers.py
@@ -1,6 +1,7 @@
 from rest_framework import serializers
 
 from funkwhale_api.music.serializers import TrackSerializerNested
+from funkwhale_api.users.serializers import UserBasicSerializer
 
 from . import filters
 from . import models
@@ -15,6 +16,8 @@ class FilterSerializer(serializers.Serializer):
 
 
 class RadioSerializer(serializers.ModelSerializer):
+    user = UserBasicSerializer(read_only=True)
+
     class Meta:
         model = models.Radio
         fields = (
diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py
index 42652644224446ccade4d43b36c995581bb15783..ffd1d16593ddc2648ec2748dc3b4174afe41f7ed 100644
--- a/api/funkwhale_api/radios/views.py
+++ b/api/funkwhale_api/radios/views.py
@@ -20,6 +20,7 @@ class RadioViewSet(
         mixins.RetrieveModelMixin,
         mixins.UpdateModelMixin,
         mixins.ListModelMixin,
+        mixins.DestroyModelMixin,
         viewsets.GenericViewSet):
 
     serializer_class = serializers.RadioSerializer
@@ -40,6 +41,16 @@ class RadioViewSet(
             raise Http404
         return serializer.save(user=self.request.user)
 
+    @detail_route(methods=['get'])
+    def tracks(self, request, *args, **kwargs):
+        radio = self.get_object()
+        tracks = radio.get_candidates().for_nested_serialization()
+
+        page = self.paginate_queryset(tracks)
+        if page is not None:
+            serializer = TrackSerializerNested(page, many=True)
+            return self.get_paginated_response(serializer.data)
+
     @list_route(methods=['get'])
     def filters(self, request, *args, **kwargs):
         serializer = serializers.FilterSerializer(
diff --git a/api/manage.py b/api/manage.py
index d99574ebe2ac8b7f8381d768b4651cb002561e1f..c8db95ede2a7c2a36c4f2368ab77d1d600be3972 100755
--- a/api/manage.py
+++ b/api/manage.py
@@ -1,4 +1,5 @@
 #!/usr/bin/env python
+import django
 import os
 import sys
 
@@ -7,6 +8,12 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
 if __name__ == "__main__":
 
     os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
+    # we're doing this here since otherwise, missing environment
+    # files in settings result in AttributeError being raised, generating
+    # a cryptic django.core.exceptions.AppRegistryNotReady error.
+    # To prevent that, we explicitely load settings here before anything
+    # else, so we fail fast with a relevant error. See #140 for more details.
+    django.setup()
 
     from django.core.management import execute_from_command_line
 
diff --git a/api/requirements.txt b/api/requirements.txt
index d1197135eeb072ccaca11906b49b2fe160bd010f..00be27c5356b417fc5da953c6bceab608e2f382c 100644
--- a/api/requirements.txt
+++ b/api/requirements.txt
@@ -1,3 +1,4 @@
 # This file is here because many Platforms as a Service look for
 #	requirements.txt in the root directory of a project.
+-r requirements/base.txt
 -r requirements/production.txt
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index efcc4eea40e66fadd74b25dc8cfa1c89f4e8abe7..b66e297a9942524b02df71fdcc67c81225e62c1e 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -14,7 +14,7 @@ django-allauth>=0.34,<0.35
 
 
 # Python-PostgreSQL Database Adapter
-psycopg2>=2.7,<=2.8
+psycopg2-binary>=2.7,<=2.8
 
 # Time zones support
 pytz==2017.3
@@ -60,3 +60,7 @@ channels_redis>=2.1,<2.2
 django-cacheops>=4,<4.1
 
 daphne==2.0.4
+cryptography>=2,<3
+# requests-http-signature==0.0.3
+# clone until the branch is merged and released upstream
+git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support
diff --git a/api/requirements/test.txt b/api/requirements/test.txt
index c12b44827ebbf9440e180de7d686b8f2e190118c..20a14abea1685387764012ea5ac3e01d60a09a47 100644
--- a/api/requirements/test.txt
+++ b/api/requirements/test.txt
@@ -9,3 +9,6 @@ git+https://github.com/pytest-dev/pytest-django.git@d3d9bb3ef6f0377cb5356eb36899
 pytest-mock
 pytest-sugar
 pytest-xdist
+pytest-cov
+pytest-env
+requests-mock
diff --git a/api/setup.cfg b/api/setup.cfg
index 34daa8c6834452229971467c7876400b842b64c1..a2b8b92c682696a0ad4569cb6c9f9e25c01f9b9f 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -7,6 +7,12 @@ max-line-length = 120
 exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
 
 [tool:pytest]
-DJANGO_SETTINGS_MODULE=config.settings.test
 python_files = tests.py test_*.py *_tests.py
 testpaths = tests
+env =
+    SECRET_KEY=test
+    DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
+    CELERY_BROKER_URL=memory://
+    CELERY_TASK_ALWAYS_EAGER=True
+    CACHEOPS_ENABLED=False
+    FEDERATION_HOSTNAME=test.federation
diff --git a/api/tests/activity/__init__.py b/api/tests/activity/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/activity/test_serializers.py b/api/tests/activity/test_serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..792fa74b9cbb3ed778c5e84bd746fb210e738acf
--- /dev/null
+++ b/api/tests/activity/test_serializers.py
@@ -0,0 +1,17 @@
+from funkwhale_api.activity import serializers
+from funkwhale_api.favorites.serializers import TrackFavoriteActivitySerializer
+from funkwhale_api.history.serializers import \
+    ListeningActivitySerializer
+
+
+def test_autoserializer(factories):
+    favorite = factories['favorites.TrackFavorite']()
+    listening = factories['history.Listening']()
+    objects = [favorite, listening]
+    serializer = serializers.AutoSerializer(objects, many=True)
+    expected = [
+        TrackFavoriteActivitySerializer(favorite).data,
+        ListeningActivitySerializer(listening).data,
+    ]
+
+    assert serializer.data == expected
diff --git a/api/tests/activity/test_utils.py b/api/tests/activity/test_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..43bb45df84931ccd3ba2a56e555b991627c3a62c
--- /dev/null
+++ b/api/tests/activity/test_utils.py
@@ -0,0 +1,21 @@
+from funkwhale_api.activity import utils
+
+
+def test_get_activity(factories):
+    user = factories['users.User']()
+    listening = factories['history.Listening']()
+    favorite = factories['favorites.TrackFavorite']()
+
+    objects = list(utils.get_activity(user))
+    assert objects == [favorite, listening]
+
+
+def test_get_activity_honors_privacy_level(factories, anonymous_user):
+    listening = factories['history.Listening'](user__privacy_level='me')
+    favorite1 = factories['favorites.TrackFavorite'](
+        user__privacy_level='everyone')
+    favorite2 = factories['favorites.TrackFavorite'](
+        user__privacy_level='instance')
+
+    objects = list(utils.get_activity(anonymous_user))
+    assert objects == [favorite1]
diff --git a/api/tests/activity/test_views.py b/api/tests/activity/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..bdc3c6339ffe91981621c8f8272788347a01cc8e
--- /dev/null
+++ b/api/tests/activity/test_views.py
@@ -0,0 +1,18 @@
+from django.urls import reverse
+
+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
+    favorite = factories['favorites.TrackFavorite'](
+        user__privacy_level='everyone')
+    listening = factories['history.Listening']()
+    url = reverse('api:v1:activity-list')
+    objects = utils.get_activity(anonymous_user)
+    serializer = serializers.AutoSerializer(objects, many=True)
+    response = api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data['results'] == serializer.data
diff --git a/api/tests/channels/__init__.py b/api/tests/channels/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/common/__init__.py b/api/tests/common/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py
index 7c63431a38ae4e5eaf41c5285966afd80a2dd6e2..29a8fb05c4ead7d9be810de7b64ba430a17f7fee 100644
--- a/api/tests/common/test_fields.py
+++ b/api/tests/common/test_fields.py
@@ -10,7 +10,7 @@ from funkwhale_api.users.factories import UserFactory
 @pytest.mark.parametrize('user,expected', [
     (AnonymousUser(), Q(privacy_level='everyone')),
     (UserFactory.build(pk=1),
-     Q(privacy_level__in=['me', 'followers', 'instance', 'everyone'])),
+     Q(privacy_level__in=['followers', 'instance', 'everyone'])),
 ])
 def test_privacy_level_query(user,expected):
     query = fields.privacy_level_query(user)
diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py
index b5c5160f8accdf6e0bbeb29f9ee4d464962dff5b..f04f12e0b0e19a75992ad364eee4f05219d0e3f7 100644
--- a/api/tests/common/test_permissions.py
+++ b/api/tests/common/test_permissions.py
@@ -2,7 +2,6 @@ import pytest
 
 from rest_framework.views import APIView
 
-from django.contrib.auth.models import AnonymousUser
 from django.http import Http404
 
 from funkwhale_api.common import permissions
@@ -19,24 +18,26 @@ def test_owner_permission_owner_field_ok(nodb_factories, api_request):
     assert check is True
 
 
-def test_owner_permission_owner_field_not_ok(nodb_factories, api_request):
+def test_owner_permission_owner_field_not_ok(
+        anonymous_user, nodb_factories, api_request):
     playlist = nodb_factories['playlists.Playlist']()
     view = APIView.as_view()
     permission = permissions.OwnerPermission()
     request = api_request.get('/')
-    setattr(request, 'user', AnonymousUser())
+    setattr(request, 'user', anonymous_user)
 
     with pytest.raises(Http404):
         permission.has_object_permission(request, view, playlist)
 
 
-def test_owner_permission_read_only(nodb_factories, api_request):
+def test_owner_permission_read_only(
+        anonymous_user, nodb_factories, api_request):
     playlist = nodb_factories['playlists.Playlist']()
     view = APIView.as_view()
     setattr(view, 'owner_checks', ['write'])
     permission = permissions.OwnerPermission()
     request = api_request.get('/')
-    setattr(request, 'user', AnonymousUser())
+    setattr(request, 'user', anonymous_user)
     check = permission.has_object_permission(request, view, playlist)
 
     assert check is True
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 62bc5ada676327aa1d5044c7bd31eaea45904dea..d5bb565651c4b1282920fa455356db4bf6704c35 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -1,9 +1,13 @@
 import factory
-import tempfile
-import shutil
 import pytest
+import requests_mock
+import shutil
+import tempfile
 
+from django.contrib.auth.models import AnonymousUser
 from django.core.cache import cache as django_cache
+from django.test import client
+
 from dynamic_preferences.registries import global_preferences_registry
 
 from rest_framework.test import APIClient
@@ -31,7 +35,11 @@ def cache():
 def factories(db):
     from funkwhale_api import factories
     for v in factories.registry.values():
-        v._meta.strategy = factory.CREATE_STRATEGY
+        try:
+            v._meta.strategy = factory.CREATE_STRATEGY
+        except AttributeError:
+            # probably not a class based factory
+            pass
     yield factories.registry
 
 
@@ -39,12 +47,16 @@ def factories(db):
 def nodb_factories():
     from funkwhale_api import factories
     for v in factories.registry.values():
-        v._meta.strategy = factory.BUILD_STRATEGY
+        try:
+            v._meta.strategy = factory.BUILD_STRATEGY
+        except AttributeError:
+            # probably not a class based factory
+            pass
     yield factories.registry
 
 
 @pytest.fixture
-def preferences(db):
+def preferences(db, cache):
     manager = global_preferences_registry.manager()
     manager.all()
     yield manager
@@ -66,6 +78,11 @@ def logged_in_client(db, factories, client):
     delattr(client, 'user')
 
 
+@pytest.fixture
+def anonymous_user():
+    return AnonymousUser()
+
+
 @pytest.fixture
 def api_client(client):
     return APIClient()
@@ -103,6 +120,11 @@ def api_request():
     return APIRequestFactory()
 
 
+@pytest.fixture
+def fake_request():
+    return client.RequestFactory()
+
+
 @pytest.fixture
 def activity_registry():
     r = record.registry
@@ -126,3 +148,17 @@ def activity_registry():
 @pytest.fixture
 def activity_muted(activity_registry, mocker):
     yield mocker.patch.object(record, 'send')
+
+
+@pytest.fixture(autouse=True)
+def media_root(settings):
+    tmp_dir = tempfile.mkdtemp()
+    settings.MEDIA_ROOT = tmp_dir
+    yield settings.MEDIA_ROOT
+    shutil.rmtree(tmp_dir)
+
+
+@pytest.fixture
+def r_mock():
+    with requests_mock.mock() as m:
+        yield m
diff --git a/api/tests/favorites/__init__.py b/api/tests/favorites/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/federation/__init__.py b/api/tests/federation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/federation/conftest.py b/api/tests/federation/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5831914bef6a59ddc80e88731c29127ec7b38b3
--- /dev/null
+++ b/api/tests/federation/conftest.py
@@ -0,0 +1,10 @@
+import pytest
+
+
+@pytest.fixture
+def authenticated_actor(nodb_factories, mocker):
+    actor = nodb_factories['federation.Actor']()
+    mocker.patch(
+        'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
+        return_value=actor)
+    yield actor
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6e1d28aa23623251a1ab26661d2814c14704f00
--- /dev/null
+++ b/api/tests/federation/test_activity.py
@@ -0,0 +1,32 @@
+from funkwhale_api.federation import activity
+
+def test_deliver(nodb_factories, r_mock, mocker):
+    to = nodb_factories['federation.Actor']()
+    mocker.patch(
+        'funkwhale_api.federation.actors.get_actor',
+        return_value=to)
+    sender = nodb_factories['federation.Actor']()
+    ac = {
+        'id': 'http://test.federation/activity',
+        'type': 'Create',
+        'actor': sender.url,
+        'object': {
+            'id': 'http://test.federation/note',
+            'type': 'Note',
+            'content': 'Hello',
+        }
+    }
+
+    r_mock.post(to.inbox_url)
+
+    activity.deliver(
+        ac,
+        to=[to.url],
+        on_behalf_of=sender,
+    )
+    request = r_mock.request_history[0]
+
+    assert r_mock.called is True
+    assert r_mock.call_count == 1
+    assert request.url == to.inbox_url
+    assert request.headers['content-type'] == 'application/activity+json'
diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py
new file mode 100644
index 0000000000000000000000000000000000000000..b3b0f8df0bb7fc3b8295bb48b6b8bd6ad967c01f
--- /dev/null
+++ b/api/tests/federation/test_actors.py
@@ -0,0 +1,190 @@
+import pytest
+
+from django.urls import reverse
+from django.utils import timezone
+
+from rest_framework import exceptions
+
+from funkwhale_api.federation import actors
+from funkwhale_api.federation import serializers
+from funkwhale_api.federation import utils
+
+
+def test_actor_fetching(r_mock):
+    payload = {
+        'id': 'https://actor.mock/users/actor#main-key',
+        'owner': 'test',
+        'publicKeyPem': 'test_pem',
+    }
+    actor_url = 'https://actor.mock/'
+    r_mock.get(actor_url, json=payload)
+    r = actors.get_actor_data(actor_url)
+
+    assert r == payload
+
+
+def test_get_library(settings, preferences):
+    preferences['federation__public_key'] = 'public_key'
+    expected = {
+        'preferred_username': 'library',
+        'domain': settings.FEDERATION_HOSTNAME,
+        'type': 'Person',
+        'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
+        'manually_approves_followers': True,
+        'url': utils.full_url(
+            reverse(
+                'federation:instance-actors-detail',
+                kwargs={'actor': 'library'})),
+        'shared_inbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-inbox',
+                kwargs={'actor': 'library'})),
+        'inbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-inbox',
+                kwargs={'actor': 'library'})),
+        'outbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-outbox',
+                kwargs={'actor': 'library'})),
+        'public_key': 'public_key',
+        'summary': 'Bot account to federate with {}\'s library'.format(
+        settings.FEDERATION_HOSTNAME),
+    }
+    actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
+    for key, value in expected.items():
+        assert getattr(actor, key) == value
+
+
+def test_get_test(settings, preferences):
+    preferences['federation__public_key'] = 'public_key'
+    expected = {
+        'preferred_username': 'test',
+        'domain': settings.FEDERATION_HOSTNAME,
+        'type': 'Person',
+        'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
+        'manually_approves_followers': False,
+        'url': utils.full_url(
+            reverse(
+                'federation:instance-actors-detail',
+                kwargs={'actor': 'test'})),
+        'shared_inbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-inbox',
+                kwargs={'actor': 'test'})),
+        'inbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-inbox',
+                kwargs={'actor': 'test'})),
+        'outbox_url': utils.full_url(
+            reverse(
+                'federation:instance-actors-outbox',
+                kwargs={'actor': 'test'})),
+        'public_key': 'public_key',
+        'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
+        settings.FEDERATION_HOSTNAME),
+    }
+    actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
+    for key, value in expected.items():
+        assert getattr(actor, key) == value
+
+
+def test_test_get_outbox():
+    expected = {
+    	"@context": [
+    		"https://www.w3.org/ns/activitystreams",
+    		"https://w3id.org/security/v1",
+    		{}
+    	],
+    	"id": utils.full_url(
+            reverse(
+                'federation:instance-actors-outbox',
+                kwargs={'actor': 'test'})),
+    	"type": "OrderedCollection",
+    	"totalItems": 0,
+    	"orderedItems": []
+    }
+
+    data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
+
+    assert data == expected
+
+
+def test_test_post_inbox_requires_authenticated_actor():
+    with pytest.raises(exceptions.PermissionDenied):
+        actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None)
+
+
+def test_test_post_outbox_validates_actor(nodb_factories):
+    actor = nodb_factories['federation.Actor']()
+    data = {
+        'actor': 'noop'
+    }
+    with pytest.raises(exceptions.ValidationError) as exc_info:
+        actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
+        msg = 'The actor making the request do not match'
+        assert msg in exc_info.value
+
+
+def test_test_post_outbox_handles_create_note(
+        settings, mocker, factories):
+    deliver = mocker.patch(
+        'funkwhale_api.federation.activity.deliver')
+    actor = factories['federation.Actor']()
+    now = timezone.now()
+    mocker.patch('django.utils.timezone.now', return_value=now)
+    data = {
+        'actor': actor.url,
+        'type': 'Create',
+        'id': 'http://test.federation/activity',
+        'object': {
+            'type': 'Note',
+            'id': 'http://test.federation/object',
+            'content': '<p><a>@mention</a> /ping</p>'
+        }
+    }
+    test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
+    expected_note = factories['federation.Note'](
+        id='https://test.federation/activities/note/{}'.format(
+            now.timestamp()
+        ),
+        content='Pong!',
+        published=now.isoformat(),
+        inReplyTo=data['object']['id'],
+        cc=[],
+        summary=None,
+        sensitive=False,
+        attributedTo=test_actor.url,
+        attachment=[],
+        to=[actor.url],
+        url='https://{}/activities/note/{}'.format(
+            settings.FEDERATION_HOSTNAME, now.timestamp()
+        ),
+        tag=[{
+            'href': actor.url,
+            'name': actor.mention_username,
+            'type': 'Mention',
+        }]
+    )
+    expected_activity = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {}
+        ],
+        'actor': test_actor.url,
+        'id': 'https://{}/activities/note/{}/activity'.format(
+            settings.FEDERATION_HOSTNAME, now.timestamp()
+        ),
+        'to': actor.url,
+        'type': 'Create',
+        'published': now.isoformat(),
+        'object': expected_note,
+        'cc': [],
+    }
+    actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
+    deliver.assert_called_once_with(
+        expected_activity,
+        to=[actor.url],
+        on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
+    )
diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py
new file mode 100644
index 0000000000000000000000000000000000000000..1837b3950f471ba549d8f45077d5b773cc010da3
--- /dev/null
+++ b/api/tests/federation/test_authentication.py
@@ -0,0 +1,42 @@
+from funkwhale_api.federation import authentication
+from funkwhale_api.federation import keys
+from funkwhale_api.federation import signing
+
+
+def test_authenticate(nodb_factories, mocker, api_request):
+    private, public = keys.get_key_pair()
+    actor_url = 'https://test.federation/actor'
+    mocker.patch(
+        'funkwhale_api.federation.actors.get_actor_data',
+        return_value={
+            'id': actor_url,
+            'outbox': 'https://test.com',
+            'inbox': 'https://test.com',
+            'publicKey': {
+                'publicKeyPem': public.decode('utf-8'),
+                'owner': actor_url,
+                'id': actor_url + '#main-key',
+            }
+        })
+    signed_request = nodb_factories['federation.SignedRequest'](
+        auth__key=private,
+        auth__key_id=actor_url + '#main-key',
+        auth__headers=[
+            'date',
+        ]
+    )
+    prepared = signed_request.prepare()
+    django_request = api_request.get(
+        '/',
+        **{
+            'HTTP_DATE': prepared.headers['date'],
+            'HTTP_SIGNATURE': prepared.headers['signature'],
+        }
+    )
+    authenticator = authentication.SignatureAuthentication()
+    user, _ = authenticator.authenticate(django_request)
+    actor = django_request.actor
+
+    assert user.is_anonymous is True
+    assert actor.public_key == public.decode('utf-8')
+    assert actor.url == actor_url
diff --git a/api/tests/federation/test_commands.py b/api/tests/federation/test_commands.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c533306821a24a664c260089c0f15201c5a9870
--- /dev/null
+++ b/api/tests/federation/test_commands.py
@@ -0,0 +1,14 @@
+from django.core.management import call_command
+
+
+def test_generate_instance_key_pair(preferences, mocker):
+    mocker.patch(
+        'funkwhale_api.federation.keys.get_key_pair',
+        return_value=(b'private', b'public'))
+    assert preferences['federation__public_key'] == ''
+    assert preferences['federation__private_key'] == ''
+
+    call_command('generate_keys', interactive=False)
+
+    assert preferences['federation__private_key'] == 'private'
+    assert preferences['federation__public_key'] == 'public'
diff --git a/api/tests/federation/test_keys.py b/api/tests/federation/test_keys.py
new file mode 100644
index 0000000000000000000000000000000000000000..9dd71be092bc39dcfb09f9b0e931c51a9ea37f5c
--- /dev/null
+++ b/api/tests/federation/test_keys.py
@@ -0,0 +1,25 @@
+import pytest
+
+from funkwhale_api.federation import keys
+
+
+@pytest.mark.parametrize('raw, expected', [
+    ('algorithm="test",keyId="https://test.com"', 'https://test.com'),
+    ('keyId="https://test.com",algorithm="test"', 'https://test.com'),
+])
+def test_get_key_from_header(raw, expected):
+    r = keys.get_key_id_from_signature_header(raw)
+    assert r == expected
+
+
+@pytest.mark.parametrize('raw', [
+    'algorithm="test",keyid="badCase"',
+    'algorithm="test",wrong="wrong"',
+    'keyId = "wrong"',
+    'keyId=\'wrong\'',
+    'keyId="notanurl"',
+    'keyId="wrong://test.com"',
+])
+def test_get_key_from_header_invalid(raw):
+    with pytest.raises(ValueError):
+        keys.get_key_id_from_signature_header(raw)
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..efa92b16a26dcdf72287282895872c10e5d10070
--- /dev/null
+++ b/api/tests/federation/test_serializers.py
@@ -0,0 +1,146 @@
+from django.urls import reverse
+
+from funkwhale_api.federation import keys
+from funkwhale_api.federation import models
+from funkwhale_api.federation import serializers
+
+
+def test_actor_serializer_from_ap(db):
+    payload = {
+    	'id': 'https://test.federation/user',
+    	'type': 'Person',
+    	'following': 'https://test.federation/user/following',
+    	'followers': 'https://test.federation/user/followers',
+    	'inbox': 'https://test.federation/user/inbox',
+    	'outbox': 'https://test.federation/user/outbox',
+    	'preferredUsername': 'user',
+    	'name': 'Real User',
+    	'summary': 'Hello world',
+    	'url': 'https://test.federation/@user',
+    	'manuallyApprovesFollowers': False,
+    	'publicKey': {
+    		'id': 'https://test.federation/user#main-key',
+    		'owner': 'https://test.federation/user',
+    		'publicKeyPem': 'yolo'
+    	},
+    	'endpoints': {
+    		'sharedInbox': 'https://test.federation/inbox'
+    	},
+    }
+
+    serializer = serializers.ActorSerializer(data=payload)
+    assert serializer.is_valid()
+
+    actor = serializer.build()
+
+    assert actor.url == payload['id']
+    assert actor.inbox_url == payload['inbox']
+    assert actor.outbox_url == payload['outbox']
+    assert actor.shared_inbox_url == payload['endpoints']['sharedInbox']
+    assert actor.followers_url == payload['followers']
+    assert actor.following_url == payload['following']
+    assert actor.public_key == payload['publicKey']['publicKeyPem']
+    assert actor.preferred_username == payload['preferredUsername']
+    assert actor.name == payload['name']
+    assert actor.domain == 'test.federation'
+    assert actor.summary == payload['summary']
+    assert actor.type == 'Person'
+    assert actor.manually_approves_followers == payload['manuallyApprovesFollowers']
+
+
+def test_actor_serializer_only_mandatory_field_from_ap(db):
+    payload = {
+    	'id': 'https://test.federation/user',
+    	'type': 'Person',
+    	'following': 'https://test.federation/user/following',
+    	'followers': 'https://test.federation/user/followers',
+    	'inbox': 'https://test.federation/user/inbox',
+    	'outbox': 'https://test.federation/user/outbox',
+    	'preferredUsername': 'user',
+    }
+
+    serializer = serializers.ActorSerializer(data=payload)
+    assert serializer.is_valid()
+
+    actor = serializer.build()
+
+    assert actor.url == payload['id']
+    assert actor.inbox_url == payload['inbox']
+    assert actor.outbox_url == payload['outbox']
+    assert actor.followers_url == payload['followers']
+    assert actor.following_url == payload['following']
+    assert actor.preferred_username == payload['preferredUsername']
+    assert actor.domain == 'test.federation'
+    assert actor.type == 'Person'
+    assert actor.manually_approves_followers is None
+
+
+def test_actor_serializer_to_ap():
+    expected = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {},
+        ],
+    	'id': 'https://test.federation/user',
+    	'type': 'Person',
+    	'following': 'https://test.federation/user/following',
+    	'followers': 'https://test.federation/user/followers',
+    	'inbox': 'https://test.federation/user/inbox',
+    	'outbox': 'https://test.federation/user/outbox',
+    	'preferredUsername': 'user',
+    	'name': 'Real User',
+    	'summary': 'Hello world',
+    	'manuallyApprovesFollowers': False,
+    	'publicKey': {
+    		'id': 'https://test.federation/user#main-key',
+    		'owner': 'https://test.federation/user',
+    		'publicKeyPem': 'yolo'
+    	},
+    	'endpoints': {
+    		'sharedInbox': 'https://test.federation/inbox'
+    	},
+    }
+    ac = models.Actor(
+        url=expected['id'],
+        inbox_url=expected['inbox'],
+        outbox_url=expected['outbox'],
+        shared_inbox_url=expected['endpoints']['sharedInbox'],
+        followers_url=expected['followers'],
+        following_url=expected['following'],
+        public_key=expected['publicKey']['publicKeyPem'],
+        preferred_username=expected['preferredUsername'],
+        name=expected['name'],
+        domain='test.federation',
+        summary=expected['summary'],
+        type='Person',
+        manually_approves_followers=False,
+
+    )
+    serializer = serializers.ActorSerializer(ac)
+
+    assert serializer.data == expected
+
+
+def test_webfinger_serializer():
+    expected = {
+        'subject': 'acct:service@test.federation',
+        'links': [
+            {
+                'rel': 'self',
+                'href': 'https://test.federation/federation/instance/actor',
+                'type': 'application/activity+json',
+            }
+        ],
+        'aliases': [
+            'https://test.federation/federation/instance/actor',
+        ]
+    }
+    actor = models.Actor(
+        url=expected['links'][0]['href'],
+        preferred_username='service',
+        domain='test.federation',
+    )
+    serializer = serializers.ActorWebfingerSerializer(actor)
+
+    assert serializer.data == expected
diff --git a/api/tests/federation/test_signing.py b/api/tests/federation/test_signing.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c1ec2e0ba1dcd16722c2615eeeb48a5f01d7e87
--- /dev/null
+++ b/api/tests/federation/test_signing.py
@@ -0,0 +1,132 @@
+import cryptography.exceptions
+import io
+import pytest
+import requests_http_signature
+
+from funkwhale_api.federation import signing
+from funkwhale_api.federation import keys
+
+
+def test_can_sign_and_verify_request(nodb_factories):
+    private, public = nodb_factories['federation.KeyPair']()
+    auth = nodb_factories['federation.SignatureAuth'](key=private)
+    request = nodb_factories['federation.SignedRequest'](
+        auth=auth
+    )
+    prepared_request = request.prepare()
+    assert 'date' in prepared_request.headers
+    assert 'signature' in prepared_request.headers
+    assert signing.verify(
+        prepared_request, public) is None
+
+
+def test_can_sign_and_verify_request_digest(nodb_factories):
+    private, public = nodb_factories['federation.KeyPair']()
+    auth = nodb_factories['federation.SignatureAuth'](key=private)
+    request = nodb_factories['federation.SignedRequest'](
+        auth=auth,
+        method='post',
+        data=b'hello=world'
+    )
+    prepared_request = request.prepare()
+    assert 'date' in prepared_request.headers
+    assert 'digest' in prepared_request.headers
+    assert 'signature' in prepared_request.headers
+    assert signing.verify(prepared_request, public) is None
+
+
+def test_verify_fails_with_wrong_key(nodb_factories):
+    wrong_private, wrong_public = nodb_factories['federation.KeyPair']()
+    request = nodb_factories['federation.SignedRequest']()
+    prepared_request = request.prepare()
+
+    with pytest.raises(cryptography.exceptions.InvalidSignature):
+        signing.verify(prepared_request, wrong_public)
+
+
+def test_can_verify_django_request(factories, fake_request):
+    private_key, public_key = keys.get_key_pair()
+    signed_request = factories['federation.SignedRequest'](
+        auth__key=private_key,
+        auth__headers=[
+            'date',
+        ]
+    )
+    prepared = signed_request.prepare()
+    django_request = fake_request.get(
+        '/',
+        **{
+            'HTTP_DATE': prepared.headers['date'],
+            'HTTP_SIGNATURE': prepared.headers['signature'],
+        }
+    )
+    assert signing.verify_django(django_request, public_key) is None
+
+
+def test_can_verify_django_request_digest(factories, fake_request):
+    private_key, public_key = keys.get_key_pair()
+    signed_request = factories['federation.SignedRequest'](
+        auth__key=private_key,
+        method='post',
+        data=b'hello=world',
+        auth__headers=[
+            'date',
+            'digest',
+        ]
+    )
+    prepared = signed_request.prepare()
+    django_request = fake_request.post(
+        '/',
+        **{
+            'HTTP_DATE': prepared.headers['date'],
+            'HTTP_DIGEST': prepared.headers['digest'],
+            'HTTP_SIGNATURE': prepared.headers['signature'],
+        }
+    )
+
+    assert signing.verify_django(django_request, public_key) is None
+
+
+def test_can_verify_django_request_digest_failure(factories, fake_request):
+    private_key, public_key = keys.get_key_pair()
+    signed_request = factories['federation.SignedRequest'](
+        auth__key=private_key,
+        method='post',
+        data=b'hello=world',
+        auth__headers=[
+            'date',
+            'digest',
+        ]
+    )
+    prepared = signed_request.prepare()
+    django_request = fake_request.post(
+        '/',
+        **{
+            'HTTP_DATE': prepared.headers['date'],
+            'HTTP_DIGEST': prepared.headers['digest'] + 'noop',
+            'HTTP_SIGNATURE': prepared.headers['signature'],
+        }
+    )
+
+    with pytest.raises(cryptography.exceptions.InvalidSignature):
+        signing.verify_django(django_request, public_key)
+
+
+def test_can_verify_django_request_failure(factories, fake_request):
+    private_key, public_key = keys.get_key_pair()
+    signed_request = factories['federation.SignedRequest'](
+        auth__key=private_key,
+        auth__headers=[
+            'date',
+        ]
+    )
+    prepared = signed_request.prepare()
+    django_request = fake_request.get(
+        '/',
+        **{
+            'HTTP_DATE': 'Wrong',
+            'HTTP_SIGNATURE': prepared.headers['signature'],
+        }
+    )
+    with pytest.raises(cryptography.exceptions.InvalidSignature):
+        signing.verify_django(django_request, public_key)
diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc371ad9ed976a5d1179a14d9dc58e831e2873e8
--- /dev/null
+++ b/api/tests/federation/test_utils.py
@@ -0,0 +1,48 @@
+import pytest
+
+from funkwhale_api.federation import utils
+
+
+@pytest.mark.parametrize('url,path,expected', [
+    ('http://test.com', '/hello', 'http://test.com/hello'),
+    ('http://test.com/', 'hello', 'http://test.com/hello'),
+    ('http://test.com/', '/hello', 'http://test.com/hello'),
+    ('http://test.com', 'hello', 'http://test.com/hello'),
+])
+def test_full_url(settings, url, path, expected):
+    settings.FUNKWHALE_URL = url
+    assert utils.full_url(path) == expected
+
+
+def test_extract_headers_from_meta():
+    wsgi_headers = {
+        'HTTP_HOST': 'nginx',
+        'HTTP_X_REAL_IP': '172.20.0.4',
+        'HTTP_X_FORWARDED_FOR': '188.165.228.227, 172.20.0.4',
+        'HTTP_X_FORWARDED_PROTO': 'http',
+        'HTTP_X_FORWARDED_HOST': 'localhost:80',
+        'HTTP_X_FORWARDED_PORT': '80',
+        'HTTP_CONNECTION': 'close',
+        'CONTENT_LENGTH': '1155',
+        'CONTENT_TYPE': 'txt/application',
+        'HTTP_SIGNATURE': 'Hello',
+        'HTTP_DATE': 'Sat, 31 Mar 2018 13:53:55 GMT',
+        'HTTP_USER_AGENT': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'}
+
+    cleaned_headers = utils.clean_wsgi_headers(wsgi_headers)
+
+    expected = {
+        'Host': 'nginx',
+        'X-Real-Ip': '172.20.0.4',
+        'X-Forwarded-For': '188.165.228.227, 172.20.0.4',
+        'X-Forwarded-Proto': 'http',
+        'X-Forwarded-Host': 'localhost:80',
+        'X-Forwarded-Port': '80',
+        'Connection': 'close',
+        'Content-Length': '1155',
+        'Content-Type': 'txt/application',
+        'Signature': 'Hello',
+        'Date': 'Sat, 31 Mar 2018 13:53:55 GMT',
+        'User-Agent': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'
+    }
+    assert cleaned_headers == expected
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d2ac882fb25ecac154a8426ffc2949ca7f81435
--- /dev/null
+++ b/api/tests/federation/test_views.py
@@ -0,0 +1,64 @@
+from django.urls import reverse
+
+import pytest
+
+from funkwhale_api.federation import actors
+from funkwhale_api.federation import serializers
+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):
+    actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
+    url = reverse(
+        'federation:instance-actors-detail',
+        kwargs={'actor': system_actor})
+    response = api_client.get(url)
+    serializer = serializers.ActorSerializer(actor)
+
+    assert response.status_code == 200
+    assert response.data == serializer.data
+
+
+@pytest.mark.parametrize('route,kwargs', [
+    ('instance-actors-outbox', {'actor': 'library'}),
+    ('instance-actors-inbox', {'actor': 'library'}),
+    ('instance-actors-detail', {'actor': 'library'}),
+    ('well-known-webfinger', {}),
+])
+def test_instance_endpoints_405_if_federation_disabled(
+        authenticated_actor, db, settings, api_client, route, kwargs):
+    settings.FEDERATION_ENABLED = False
+    url = reverse('federation:{}'.format(route), kwargs=kwargs)
+    response = api_client.get(url)
+
+    assert response.status_code == 405
+
+
+def test_wellknown_webfinger_validates_resource(
+    db, api_client, settings, mocker):
+    clean = mocker.spy(webfinger, 'clean_resource')
+    url = reverse('federation:well-known-webfinger')
+    response = api_client.get(url, data={'resource': 'something'})
+
+    clean.assert_called_once_with('something')
+    assert url == '/.well-known/webfinger'
+    assert response.status_code == 400
+    assert response.data['errors']['resource'] == (
+        'Missing webfinger resource type'
+    )
+
+
+@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
+def test_wellknown_webfinger_system(
+        system_actor, db, api_client, settings, mocker):
+    actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
+    url = reverse('federation:well-known-webfinger')
+    response = api_client.get(
+        url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
+    serializer = serializers.ActorWebfingerSerializer(actor)
+
+    assert response.status_code == 200
+    assert response['Content-Type'] == 'application/jrd+json'
+    assert response.data == serializer.data
diff --git a/api/tests/federation/test_webfinger.py b/api/tests/federation/test_webfinger.py
new file mode 100644
index 0000000000000000000000000000000000000000..96258455ae6fe1f60e330d7768a58d6e94fc91fa
--- /dev/null
+++ b/api/tests/federation/test_webfinger.py
@@ -0,0 +1,42 @@
+import pytest
+
+from django import forms
+from django.urls import reverse
+
+from funkwhale_api.federation import webfinger
+
+
+def test_webfinger_clean_resource():
+    t, r = webfinger.clean_resource('acct:service@test.federation')
+    assert t == 'acct'
+    assert r == 'service@test.federation'
+
+
+@pytest.mark.parametrize('resource,message', [
+    ('', 'Invalid resource string'),
+    ('service@test.com', 'Missing webfinger resource type'),
+    ('noop:service@test.com', 'Invalid webfinger resource type'),
+])
+def test_webfinger_clean_resource_errors(resource, message):
+    with pytest.raises(forms.ValidationError) as excinfo:
+        webfinger.clean_resource(resource)
+
+        assert message == str(excinfo)
+
+
+def test_webfinger_clean_acct(settings):
+    username, hostname = webfinger.clean_acct('library@test.federation')
+    assert username == 'library'
+    assert hostname == 'test.federation'
+
+
+@pytest.mark.parametrize('resource,message', [
+    ('service', 'Invalid format'),
+    ('service@test.com', 'Invalid hostname test.com'),
+    ('noop@test.federation', 'Invalid account'),
+])
+def test_webfinger_clean_acct_errors(resource, message, settings):
+    with pytest.raises(forms.ValidationError) as excinfo:
+        webfinger.clean_resource(resource)
+
+        assert message == str(excinfo)
diff --git "a/api/tests/files/utf8-\303\251\303\240\342\227\214.ogg" "b/api/tests/files/utf8-\303\251\303\240\342\227\214.ogg"
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py
index b5ab07b8235f12045f5ee1b9fba3fce0c3da57d5..04000604b264394ab7c3c1425e49a1f2b59bff71 100644
--- a/api/tests/history/test_activity.py
+++ b/api/tests/history/test_activity.py
@@ -23,7 +23,7 @@ def test_activity_listening_serializer(factories):
         "id": listening.get_activity_url(),
         "actor": actor,
         "object": TrackActivitySerializer(listening.track).data,
-        "published": field.to_representation(listening.end_date),
+        "published": field.to_representation(listening.creation_date),
     }
 
     data = serializers.ListeningActivitySerializer(listening).data
diff --git a/api/tests/instance/__init__.py b/api/tests/instance/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/music/conftest.py b/api/tests/music/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d0fa4e38627ad7f0711082a0edd2c726f02b2e6
--- /dev/null
+++ b/api/tests/music/conftest.py
@@ -0,0 +1,566 @@
+import pytest
+
+
+_artists = {'search': {}, 'get': {}}
+
+_artists['search']['adhesive_wombat'] = {
+    'artist-list': [
+        {
+            'type': 'Person',
+            'ext:score': '100',
+            'id': '62c3befb-6366-4585-b256-809472333801',
+            'disambiguation': 'George Shaw',
+            'gender': 'male',
+            'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'},
+            'sort-name': 'Wombat, Adhesive',
+            'life-span': {'ended': 'false'},
+            'name': 'Adhesive Wombat'
+        },
+        {
+            'country': 'SE',
+            'type': 'Group',
+            'ext:score': '42',
+            'id': '61b34e69-7573-4208-bc89-7061bca5a8fc',
+            'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'},
+            'sort-name': 'Adhesive',
+            'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'},
+            'name': 'Adhesive',
+            'begin-area': {
+                'sort-name': 'Katrineholm',
+                'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f',
+                'name': 'Katrineholm'
+            },
+        },
+    ]
+}
+_artists['get']['adhesive_wombat'] = {'artist': _artists['search']['adhesive_wombat']['artist-list'][0]}
+
+_artists['get']['soad'] = {
+    'artist': {
+        'country': 'US',
+        'isni-list': ['0000000121055332'],
+        'type': 'Group',
+        'area': {
+            'iso-3166-1-code-list': ['US'],
+            'sort-name': 'United States',
+            'id': '489ce91b-6658-3307-9877-795b68554c98',
+            'name': 'United States'
+        },
+        'begin-area': {
+            'sort-name': 'Glendale',
+            'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373',
+            'name': 'Glendale'
+        },
+        'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
+        'life-span': {'begin': '1994'},
+        'sort-name': 'System of a Down',
+        'name': 'System of a Down'
+    }
+}
+
+_albums = {'search': {}, 'get': {}, 'get_with_includes': {}}
+_albums['search']['hypnotize'] = {
+    'release-list': [
+        {
+            "artist-credit": [
+                {
+                    "artist": {
+                        "alias-list": [
+                            {
+                                "alias": "SoaD",
+                                "sort-name": "SoaD",
+                                "type": "Search hint"
+                            },
+                            {
+                                "alias": "S.O.A.D.",
+                                "sort-name": "S.O.A.D.",
+                                "type": "Search hint"
+                            },
+                            {
+                                "alias": "System Of Down",
+                                "sort-name": "System Of Down",
+                                "type": "Search hint"
+                            }
+                        ],
+                        "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
+                        "name": "System of a Down",
+                        "sort-name": "System of a Down"
+                    }
+                }
+            ],
+            "artist-credit-phrase": "System of a Down",
+            "barcode": "",
+            "country": "US",
+            "date": "2005",
+            "ext:score": "100",
+            "id": "47ae093f-1607-49a3-be11-a15d335ccc94",
+            "label-info-list": [
+                {
+                    "catalog-number": "8-2796-93871-2",
+                    "label": {
+                        "id": "f5be9cfe-e1af-405c-a074-caeaed6797c0",
+                        "name": "American Recordings"
+                    }
+                },
+                {
+                    "catalog-number": "D162990",
+                    "label": {
+                        "id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f",
+                        "name": "BMG Direct Marketing, Inc."
+                    }
+                }
+            ],
+            "medium-count": 1,
+            "medium-list": [
+                {
+                    "disc-count": 1,
+                    "disc-list": [],
+                    "format": "CD",
+                    "track-count": 12,
+                    "track-list": []
+                }
+            ],
+            "medium-track-count": 12,
+            "packaging": "Digipak",
+            "release-event-list": [
+                {
+                    "area": {
+                        "id": "489ce91b-6658-3307-9877-795b68554c98",
+                        "iso-3166-1-code-list": [
+                            "US"
+                        ],
+                        "name": "United States",
+                        "sort-name": "United States"
+                    },
+                    "date": "2005"
+                }
+            ],
+            "release-group": {
+                "id": "72035143-d6ec-308b-8ee5-070b8703902a",
+                "primary-type": "Album",
+                "type": "Album"
+            },
+            "status": "Official",
+            "text-representation": {
+                "language": "eng",
+                "script": "Latn"
+            },
+            "title": "Hypnotize"
+        },
+        {
+            "artist-credit": [
+                {
+                    "artist": {
+                        "alias-list": [
+                            {
+                                "alias": "SoaD",
+                                "sort-name": "SoaD",
+                                "type": "Search hint"
+                            },
+                            {
+                                "alias": "S.O.A.D.",
+                                "sort-name": "S.O.A.D.",
+                                "type": "Search hint"
+                            },
+                            {
+                                "alias": "System Of Down",
+                                "sort-name": "System Of Down",
+                                "type": "Search hint"
+                            }
+                        ],
+                        "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
+                        "name": "System of a Down",
+                        "sort-name": "System of a Down"
+                    }
+                }
+            ],
+            "artist-credit-phrase": "System of a Down",
+            "asin": "B000C6NRY8",
+            "barcode": "827969387115",
+            "country": "US",
+            "date": "2005-12-20",
+            "ext:score": "100",
+            "id": "8a4034a9-7834-3b7e-a6f0-d0791e3731fb",
+            "medium-count": 1,
+            "medium-list": [
+                {
+                    "disc-count": 0,
+                    "disc-list": [],
+                    "format": "Vinyl",
+                    "track-count": 12,
+                    "track-list": []
+                }
+            ],
+            "medium-track-count": 12,
+            "release-event-list": [
+                {
+                    "area": {
+                        "id": "489ce91b-6658-3307-9877-795b68554c98",
+                        "iso-3166-1-code-list": [
+                            "US"
+                        ],
+                        "name": "United States",
+                        "sort-name": "United States"
+                    },
+                    "date": "2005-12-20"
+                }
+            ],
+            "release-group": {
+                "id": "72035143-d6ec-308b-8ee5-070b8703902a",
+                "primary-type": "Album",
+                "type": "Album"
+            },
+            "status": "Official",
+            "text-representation": {
+                "language": "eng",
+                "script": "Latn"
+            },
+            "title": "Hypnotize"
+        },
+    ]
+}
+_albums['get']['hypnotize'] = {'release': _albums['search']['hypnotize']['release-list'][0]}
+_albums['get_with_includes']['hypnotize'] = {
+  'release': {
+    'artist-credit': [
+        {'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
+            'name': 'System of a Down',
+            'sort-name': 'System of a Down'}}],
+  'artist-credit-phrase': 'System of a Down',
+  'barcode': '',
+  'country': 'US',
+  'cover-art-archive': {'artwork': 'true',
+   'back': 'false',
+   'count': '1',
+   'front': 'true'},
+  'date': '2005',
+  'id': '47ae093f-1607-49a3-be11-a15d335ccc94',
+  'medium-count': 1,
+  'medium-list': [{'format': 'CD',
+    'position': '1',
+    'track-count': 12,
+    'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3',
+      'length': '186000',
+      'number': '1',
+      'position': '1',
+      'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68',
+       'length': '186000',
+       'title': 'Attack'},
+      'track_or_recording_length': '186000'},
+     {'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608',
+      'length': '239000',
+      'number': '2',
+      'position': '2',
+      'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a',
+       'length': '239000',
+       'title': 'Dreaming'},
+      'track_or_recording_length': '239000'},
+     {'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f',
+      'length': '147000',
+      'number': '3',
+      'position': '3',
+      'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344',
+       'length': '147000',
+       'title': 'Kill Rock ’n Roll'},
+      'track_or_recording_length': '147000'},
+     {'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25',
+      'length': '189000',
+      'number': '4',
+      'position': '4',
+      'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605',
+       'length': '189000',
+       'title': 'Hypnotize'},
+      'track_or_recording_length': '189000'},
+     {'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32',
+      'length': '178000',
+      'number': '5',
+      'position': '5',
+      'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2',
+       'length': '178000',
+       'title': 'Stealing Society'},
+      'track_or_recording_length': '178000'},
+     {'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2',
+      'length': '216000',
+      'number': '6',
+      'position': '6',
+      'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5',
+       'length': '216000',
+       'title': 'Tentative'},
+      'track_or_recording_length': '216000'},
+     {'id': '265718ba-787f-3193-947b-3b6fa69ffe96',
+      'length': '175000',
+      'number': '7',
+      'position': '7',
+      'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120',
+       'length': '175000',
+       'title': 'U‐Fig'},
+      'title': 'U-Fig',
+      'track_or_recording_length': '175000'},
+     {'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a',
+      'length': '328000',
+      'number': '8',
+      'position': '8',
+      'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79',
+       'length': '328000',
+       'title': 'Holy Mountains'},
+      'track_or_recording_length': '328000'},
+     {'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df',
+      'length': '171000',
+      'number': '9',
+      'position': '9',
+      'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa',
+       'length': '171000',
+       'title': 'Vicinity of Obscenity'},
+      'track_or_recording_length': '171000'},
+     {'id': 'cdd45914-6741-353e-bbb5-d281048ff24f',
+      'length': '164000',
+      'number': '10',
+      'position': '10',
+      'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8',
+       'length': '164000',
+       'title': 'She’s Like Heroin'},
+      'track_or_recording_length': '164000'},
+     {'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d',
+      'length': '167000',
+      'number': '11',
+      'position': '11',
+      'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378',
+       'length': '167000',
+       'title': 'Lonely Day'},
+      'track_or_recording_length': '167000'},
+     {'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f',
+      'length': '220000',
+      'number': '12',
+      'position': '12',
+      'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88',
+       'length': '220000',
+       'title': 'Soldier Side'},
+      'track_or_recording_length': '220000'}]}],
+  'packaging': 'Digipak',
+  'quality': 'normal',
+  'release-event-count': 1,
+  'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98',
+     'iso-3166-1-code-list': ['US'],
+     'name': 'United States',
+     'sort-name': 'United States'},
+    'date': '2005'}],
+  'status': 'Official',
+  'text-representation': {'language': 'eng', 'script': 'Latn'},
+  'title': 'Hypnotize'}}
+
+_albums['get']['marsupial'] = {
+    'release': {
+        "artist-credit": [
+            {
+                "artist": {
+                    "disambiguation": "George Shaw",
+                    "id": "62c3befb-6366-4585-b256-809472333801",
+                    "name": "Adhesive Wombat",
+                    "sort-name": "Wombat, Adhesive"
+                }
+            }
+        ],
+        "artist-credit-phrase": "Adhesive Wombat",
+        "country": "XW",
+        "cover-art-archive": {
+            "artwork": "true",
+            "back": "false",
+            "count": "1",
+            "front": "true"
+        },
+        "date": "2013-06-05",
+        "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
+        "packaging": "None",
+        "quality": "normal",
+        "release-event-count": 1,
+        "release-event-list": [
+            {
+                "area": {
+                    "id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
+                    "iso-3166-1-code-list": [
+                        "XW"
+                    ],
+                    "name": "[Worldwide]",
+                    "sort-name": "[Worldwide]"
+                },
+                "date": "2013-06-05"
+            }
+        ],
+        "status": "Official",
+        "text-representation": {
+            "language": "eng",
+            "script": "Latn"
+        },
+        "title": "Marsupial Madness"
+    }
+}
+
+_tracks = {'search': {}, 'get': {}}
+
+_tracks['search']['8bitadventures'] = {
+    'recording-list': [
+        {
+            "artist-credit": [
+                {
+                    "artist": {
+                        "disambiguation": "George Shaw",
+                        "id": "62c3befb-6366-4585-b256-809472333801",
+                        "name": "Adhesive Wombat",
+                        "sort-name": "Wombat, Adhesive"
+                    }
+                }
+            ],
+            "artist-credit-phrase": "Adhesive Wombat",
+            "ext:score": "100",
+            "id": "9968a9d6-8d92-4051-8f76-674e157b6eed",
+            "length": "271000",
+            "release-list": [
+                {
+                    "country": "XW",
+                    "date": "2013-06-05",
+                    "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
+                    "medium-list": [
+                        {
+                            "format": "Digital Media",
+                            "position": "1",
+                            "track-count": 11,
+                            "track-list": [
+                                {
+                                    "id": "64d43604-c1ee-4f45-a02c-030672d2fe27",
+                                    "length": "271000",
+                                    "number": "1",
+                                    "title": "8-Bit Adventure",
+                                    "track_or_recording_length": "271000"
+                                }
+                            ]
+                        }
+                    ],
+                    "medium-track-count": 11,
+                    "release-event-list": [
+                        {
+                            "area": {
+                                "id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
+                                "iso-3166-1-code-list": [
+                                    "XW"
+                                ],
+                                "name": "[Worldwide]",
+                                "sort-name": "[Worldwide]"
+                            },
+                            "date": "2013-06-05"
+                        }
+                    ],
+                    "release-group": {
+                        "id": "447b4979-2178-405c-bfe6-46bf0b09e6c7",
+                        "primary-type": "Album",
+                        "type": "Album"
+                    },
+                    "status": "Official",
+                    "title": "Marsupial Madness"
+                }
+            ],
+            "title": "8-Bit Adventure",
+            "tag-list": [
+                {
+                    "count": "2",
+                    "name": "techno"
+                },
+                {
+                    "count": "2",
+                    "name": "good-music"
+                },
+            ],
+        },
+    ]
+}
+
+_tracks['get']['8bitadventures'] = {'recording': _tracks['search']['8bitadventures']['recording-list'][0]}
+_tracks['get']['chop_suey'] = {
+    'recording': {
+        'id': '46c7368a-013a-47b6-97cc-e55e7ab25213',
+        'length': '210240',
+        'title': 'Chop Suey!',
+        'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+        'type': 'performance',
+        'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0',
+        'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+            'language': 'eng',
+            'title': 'Chop Suey!'}}]}}
+
+_works = {'search': {}, 'get': {}}
+_works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
+  'language': 'eng',
+  'recording-relation-list': [{'direction': 'backward',
+    'recording': {'disambiguation': 'edit',
+     'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
+     'length': '170893',
+     'title': 'Chop Suey!'},
+    'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
+    'type': 'performance',
+    'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'},
+  ],
+  'title': 'Chop Suey!',
+  'type': 'Song',
+  'url-relation-list': [{'direction': 'backward',
+    'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!',
+    'type': 'lyrics',
+    'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}}
+
+
+@pytest.fixture()
+def artists():
+    return _artists
+
+
+@pytest.fixture()
+def albums():
+    return _albums
+
+
+@pytest.fixture()
+def tracks():
+    return _tracks
+
+
+@pytest.fixture()
+def works():
+    return _works
+
+
+@pytest.fixture()
+def lyricswiki_content():
+    return """<!doctype html>
+<html lang="en" dir="ltr">
+<head>
+
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+<meta name="generator" content="MediaWiki 1.19.24" />
+<meta name="keywords" content="Chop Suey! lyrics,System Of A Down Chop Suey! lyrics,Chop Suey! by System Of A Down lyrics,lyrics,LyricWiki,LyricWikia,lyricwiki,System Of A Down:Chop Suey!,System Of A Down,System Of A Down:Toxicity (2001),Enter Shikari,Enter Shikari:Chop Suey!,&quot;Weird Al&quot; Yankovic,&quot;Weird Al&quot; Yankovic:Angry White Boy Polka,Renard,Renard:Physicality,System Of A Down:Chop Suey!/pt,Daron Malakian" />
+<meta name="description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." />
+<meta name="twitter:card" content="summary" />
+<meta name="twitter:site" content="@Wikia" />
+<meta name="twitter:url" content="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" />
+<meta name="twitter:title" content="System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia" />
+<meta name="twitter:description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." />
+<link rel="canonical" href="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" />
+<link rel="alternate" type="application/x-wiki" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" />
+<link rel="edit" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" />
+<link rel="apple-touch-icon" href="http://img4.wikia.nocookie.net/__cb22/lyricwiki/images/b/bc/Wiki.png" />
+<link rel="shortcut icon" href="http://slot1.images.wikia.nocookie.net/__cb1474018633/common/skins/common/images/favicon.ico" />
+<link rel="search" type="application/opensearchdescription+xml" href="/opensearch_desc.php" title="LyricWikia (en)" />
+<link rel="EditURI" type="application/rsd+xml" href="http://lyrics.wikia.com/api.php?action=rsd" />
+<link rel="copyright" href="/wiki/LyricWiki:Copyrights" />
+<link rel="alternate" type="application/atom+xml" title="LyricWikia Atom feed" href="/wiki/Special:RecentChanges?feed=atom" />
+<title>System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia</title>
+
+<body>
+<div class='lyricbox'>
+<i>&#87;&#101;&#39;&#114;&#101;&#32;&#114;&#111;&#108;&#108;&#105;&#110;&#103;&#32;&#34;&#83;&#117;&#105;&#99;&#105;&#100;&#101;&#34;&#46;</i><br /><br />&#87;&#97;&#107;&#101;&#32;&#117;&#112;&#32;<i>&#40;&#119;&#97;&#107;&#101;&#32;&#117;&#112;&#41;</i><br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#111;&#110;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;&#32;<i>&#40;&#104;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#41;</i><br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#72;&#101;&#114;&#101;&#32;&#121;&#111;&#117;&#32;&#103;&#111;&#44;&#32;&#99;&#114;&#101;&#97;&#116;&#101;&#32;&#97;&#110;&#111;&#116;&#104;&#101;&#114;&#32;&#102;&#97;&#98;&#108;&#101;<br /><br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br /><br />&#73;&#32;&#100;&#111;&#110;&#39;&#116;&#32;&#116;&#104;&#105;&#110;&#107;&#32;&#121;&#111;&#117;&#32;&#116;&#114;&#117;&#115;&#116;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br /><br />&#87;&#97;&#107;&#101;&#32;&#117;&#112;&#32;<i>&#40;&#119;&#97;&#107;&#101;&#32;&#117;&#112;&#41;</i><br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#111;&#110;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;<i>&#40;&#104;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#41;</i><br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#72;&#101;&#114;&#101;&#32;&#121;&#111;&#117;&#32;&#103;&#111;&#44;&#32;&#99;&#114;&#101;&#97;&#116;&#101;&#32;&#97;&#110;&#111;&#116;&#104;&#101;&#114;&#32;&#102;&#97;&#98;&#108;&#101;<br /><br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br /><br />&#73;&#32;&#100;&#111;&#110;&#39;&#116;&#32;&#116;&#104;&#105;&#110;&#107;&#32;&#121;&#111;&#117;&#32;&#116;&#114;&#117;&#115;&#116;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br /><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#44;&#32;&#105;&#110;&#116;&#111;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#97;&#110;&#100;&#115;&#32;&#73;&#32;&#99;&#111;&#109;&#109;&#105;&#116;&#32;&#109;&#121;&#32;&#115;&#112;&#105;&#114;&#105;&#116;<br />&#70;&#97;&#116;&#104;&#101;&#114;&#44;&#32;&#105;&#110;&#116;&#111;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#97;&#110;&#100;&#115;<br /><br />&#87;&#104;&#121;&#32;&#104;&#97;&#118;&#101;&#32;&#121;&#111;&#117;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;&#63;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#101;&#121;&#101;&#115;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#116;&#104;&#111;&#117;&#103;&#104;&#116;&#115;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#101;&#97;&#114;&#116;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;&#44;&#32;&#111;&#104;<br /><br />&#84;&#114;&#117;&#115;&#116;&#32;&#105;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;&#10;
+</div>
+</body>
+</html>"""
+
+
+@pytest.fixture()
+def binary_cover():
+    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/cover.py b/api/tests/music/cover.py
deleted file mode 100644
index 401bc105227acc0fd2f2265ef899604b138475a3..0000000000000000000000000000000000000000
--- a/api/tests/music/cover.py
+++ /dev/null
@@ -1 +0,0 @@
-binary_data = 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/data.py b/api/tests/music/data.py
deleted file mode 100644
index 54da6bc846190992dd2bd24fde94aaeced0b1a63..0000000000000000000000000000000000000000
--- a/api/tests/music/data.py
+++ /dev/null
@@ -1,502 +0,0 @@
-artists = {'search': {}, 'get': {}}
-artists['search']['adhesive_wombat'] = {
-    'artist-list': [
-        {
-            'type': 'Person',
-            'ext:score': '100',
-            'id': '62c3befb-6366-4585-b256-809472333801',
-            'disambiguation': 'George Shaw',
-            'gender': 'male',
-            'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'},
-            'sort-name': 'Wombat, Adhesive',
-            'life-span': {'ended': 'false'},
-            'name': 'Adhesive Wombat'
-        },
-        {
-            'country': 'SE',
-            'type': 'Group',
-            'ext:score': '42',
-            'id': '61b34e69-7573-4208-bc89-7061bca5a8fc',
-            'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'},
-            'sort-name': 'Adhesive',
-            'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'},
-            'name': 'Adhesive',
-            'begin-area': {
-                'sort-name': 'Katrineholm',
-                'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f',
-                'name': 'Katrineholm'
-            },
-        },
-    ]
-}
-artists['get']['adhesive_wombat'] = {'artist': artists['search']['adhesive_wombat']['artist-list'][0]}
-
-artists['get']['soad'] = {
-    'artist': {
-        'country': 'US',
-        'isni-list': ['0000000121055332'],
-        'type': 'Group',
-        'area': {
-            'iso-3166-1-code-list': ['US'],
-            'sort-name': 'United States',
-            'id': '489ce91b-6658-3307-9877-795b68554c98',
-            'name': 'United States'
-        },
-        'begin-area': {
-            'sort-name': 'Glendale',
-            'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373',
-            'name': 'Glendale'
-        },
-        'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
-        'life-span': {'begin': '1994'},
-        'sort-name': 'System of a Down',
-        'name': 'System of a Down'
-    }
-}
-
-albums = {'search': {}, 'get': {}, 'get_with_includes': {}}
-albums['search']['hypnotize'] = {
-    'release-list': [
-        {
-            "artist-credit": [
-                {
-                    "artist": {
-                        "alias-list": [
-                            {
-                                "alias": "SoaD",
-                                "sort-name": "SoaD",
-                                "type": "Search hint"
-                            },
-                            {
-                                "alias": "S.O.A.D.",
-                                "sort-name": "S.O.A.D.",
-                                "type": "Search hint"
-                            },
-                            {
-                                "alias": "System Of Down",
-                                "sort-name": "System Of Down",
-                                "type": "Search hint"
-                            }
-                        ],
-                        "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
-                        "name": "System of a Down",
-                        "sort-name": "System of a Down"
-                    }
-                }
-            ],
-            "artist-credit-phrase": "System of a Down",
-            "barcode": "",
-            "country": "US",
-            "date": "2005",
-            "ext:score": "100",
-            "id": "47ae093f-1607-49a3-be11-a15d335ccc94",
-            "label-info-list": [
-                {
-                    "catalog-number": "8-2796-93871-2",
-                    "label": {
-                        "id": "f5be9cfe-e1af-405c-a074-caeaed6797c0",
-                        "name": "American Recordings"
-                    }
-                },
-                {
-                    "catalog-number": "D162990",
-                    "label": {
-                        "id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f",
-                        "name": "BMG Direct Marketing, Inc."
-                    }
-                }
-            ],
-            "medium-count": 1,
-            "medium-list": [
-                {
-                    "disc-count": 1,
-                    "disc-list": [],
-                    "format": "CD",
-                    "track-count": 12,
-                    "track-list": []
-                }
-            ],
-            "medium-track-count": 12,
-            "packaging": "Digipak",
-            "release-event-list": [
-                {
-                    "area": {
-                        "id": "489ce91b-6658-3307-9877-795b68554c98",
-                        "iso-3166-1-code-list": [
-                            "US"
-                        ],
-                        "name": "United States",
-                        "sort-name": "United States"
-                    },
-                    "date": "2005"
-                }
-            ],
-            "release-group": {
-                "id": "72035143-d6ec-308b-8ee5-070b8703902a",
-                "primary-type": "Album",
-                "type": "Album"
-            },
-            "status": "Official",
-            "text-representation": {
-                "language": "eng",
-                "script": "Latn"
-            },
-            "title": "Hypnotize"
-        },
-        {
-            "artist-credit": [
-                {
-                    "artist": {
-                        "alias-list": [
-                            {
-                                "alias": "SoaD",
-                                "sort-name": "SoaD",
-                                "type": "Search hint"
-                            },
-                            {
-                                "alias": "S.O.A.D.",
-                                "sort-name": "S.O.A.D.",
-                                "type": "Search hint"
-                            },
-                            {
-                                "alias": "System Of Down",
-                                "sort-name": "System Of Down",
-                                "type": "Search hint"
-                            }
-                        ],
-                        "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
-                        "name": "System of a Down",
-                        "sort-name": "System of a Down"
-                    }
-                }
-            ],
-            "artist-credit-phrase": "System of a Down",
-            "asin": "B000C6NRY8",
-            "barcode": "827969387115",
-            "country": "US",
-            "date": "2005-12-20",
-            "ext:score": "100",
-            "id": "8a4034a9-7834-3b7e-a6f0-d0791e3731fb",
-            "medium-count": 1,
-            "medium-list": [
-                {
-                    "disc-count": 0,
-                    "disc-list": [],
-                    "format": "Vinyl",
-                    "track-count": 12,
-                    "track-list": []
-                }
-            ],
-            "medium-track-count": 12,
-            "release-event-list": [
-                {
-                    "area": {
-                        "id": "489ce91b-6658-3307-9877-795b68554c98",
-                        "iso-3166-1-code-list": [
-                            "US"
-                        ],
-                        "name": "United States",
-                        "sort-name": "United States"
-                    },
-                    "date": "2005-12-20"
-                }
-            ],
-            "release-group": {
-                "id": "72035143-d6ec-308b-8ee5-070b8703902a",
-                "primary-type": "Album",
-                "type": "Album"
-            },
-            "status": "Official",
-            "text-representation": {
-                "language": "eng",
-                "script": "Latn"
-            },
-            "title": "Hypnotize"
-        },
-    ]
-}
-albums['get']['hypnotize'] = {'release': albums['search']['hypnotize']['release-list'][0]}
-albums['get_with_includes']['hypnotize'] = {
-  'release': {
-    'artist-credit': [
-        {'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
-            'name': 'System of a Down',
-            'sort-name': 'System of a Down'}}],
-  'artist-credit-phrase': 'System of a Down',
-  'barcode': '',
-  'country': 'US',
-  'cover-art-archive': {'artwork': 'true',
-   'back': 'false',
-   'count': '1',
-   'front': 'true'},
-  'date': '2005',
-  'id': '47ae093f-1607-49a3-be11-a15d335ccc94',
-  'medium-count': 1,
-  'medium-list': [{'format': 'CD',
-    'position': '1',
-    'track-count': 12,
-    'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3',
-      'length': '186000',
-      'number': '1',
-      'position': '1',
-      'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68',
-       'length': '186000',
-       'title': 'Attack'},
-      'track_or_recording_length': '186000'},
-     {'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608',
-      'length': '239000',
-      'number': '2',
-      'position': '2',
-      'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a',
-       'length': '239000',
-       'title': 'Dreaming'},
-      'track_or_recording_length': '239000'},
-     {'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f',
-      'length': '147000',
-      'number': '3',
-      'position': '3',
-      'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344',
-       'length': '147000',
-       'title': 'Kill Rock ’n Roll'},
-      'track_or_recording_length': '147000'},
-     {'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25',
-      'length': '189000',
-      'number': '4',
-      'position': '4',
-      'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605',
-       'length': '189000',
-       'title': 'Hypnotize'},
-      'track_or_recording_length': '189000'},
-     {'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32',
-      'length': '178000',
-      'number': '5',
-      'position': '5',
-      'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2',
-       'length': '178000',
-       'title': 'Stealing Society'},
-      'track_or_recording_length': '178000'},
-     {'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2',
-      'length': '216000',
-      'number': '6',
-      'position': '6',
-      'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5',
-       'length': '216000',
-       'title': 'Tentative'},
-      'track_or_recording_length': '216000'},
-     {'id': '265718ba-787f-3193-947b-3b6fa69ffe96',
-      'length': '175000',
-      'number': '7',
-      'position': '7',
-      'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120',
-       'length': '175000',
-       'title': 'U‐Fig'},
-      'title': 'U-Fig',
-      'track_or_recording_length': '175000'},
-     {'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a',
-      'length': '328000',
-      'number': '8',
-      'position': '8',
-      'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79',
-       'length': '328000',
-       'title': 'Holy Mountains'},
-      'track_or_recording_length': '328000'},
-     {'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df',
-      'length': '171000',
-      'number': '9',
-      'position': '9',
-      'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa',
-       'length': '171000',
-       'title': 'Vicinity of Obscenity'},
-      'track_or_recording_length': '171000'},
-     {'id': 'cdd45914-6741-353e-bbb5-d281048ff24f',
-      'length': '164000',
-      'number': '10',
-      'position': '10',
-      'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8',
-       'length': '164000',
-       'title': 'She’s Like Heroin'},
-      'track_or_recording_length': '164000'},
-     {'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d',
-      'length': '167000',
-      'number': '11',
-      'position': '11',
-      'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378',
-       'length': '167000',
-       'title': 'Lonely Day'},
-      'track_or_recording_length': '167000'},
-     {'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f',
-      'length': '220000',
-      'number': '12',
-      'position': '12',
-      'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88',
-       'length': '220000',
-       'title': 'Soldier Side'},
-      'track_or_recording_length': '220000'}]}],
-  'packaging': 'Digipak',
-  'quality': 'normal',
-  'release-event-count': 1,
-  'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98',
-     'iso-3166-1-code-list': ['US'],
-     'name': 'United States',
-     'sort-name': 'United States'},
-    'date': '2005'}],
-  'status': 'Official',
-  'text-representation': {'language': 'eng', 'script': 'Latn'},
-  'title': 'Hypnotize'}}
-
-albums['get']['marsupial'] = {
-    'release': {
-        "artist-credit": [
-            {
-                "artist": {
-                    "disambiguation": "George Shaw",
-                    "id": "62c3befb-6366-4585-b256-809472333801",
-                    "name": "Adhesive Wombat",
-                    "sort-name": "Wombat, Adhesive"
-                }
-            }
-        ],
-        "artist-credit-phrase": "Adhesive Wombat",
-        "country": "XW",
-        "cover-art-archive": {
-            "artwork": "true",
-            "back": "false",
-            "count": "1",
-            "front": "true"
-        },
-        "date": "2013-06-05",
-        "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
-        "packaging": "None",
-        "quality": "normal",
-        "release-event-count": 1,
-        "release-event-list": [
-            {
-                "area": {
-                    "id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
-                    "iso-3166-1-code-list": [
-                        "XW"
-                    ],
-                    "name": "[Worldwide]",
-                    "sort-name": "[Worldwide]"
-                },
-                "date": "2013-06-05"
-            }
-        ],
-        "status": "Official",
-        "text-representation": {
-            "language": "eng",
-            "script": "Latn"
-        },
-        "title": "Marsupial Madness"
-    }
-}
-
-tracks = {'search': {}, 'get': {}}
-
-tracks['search']['8bitadventures'] = {
-    'recording-list': [
-        {
-            "artist-credit": [
-                {
-                    "artist": {
-                        "disambiguation": "George Shaw",
-                        "id": "62c3befb-6366-4585-b256-809472333801",
-                        "name": "Adhesive Wombat",
-                        "sort-name": "Wombat, Adhesive"
-                    }
-                }
-            ],
-            "artist-credit-phrase": "Adhesive Wombat",
-            "ext:score": "100",
-            "id": "9968a9d6-8d92-4051-8f76-674e157b6eed",
-            "length": "271000",
-            "release-list": [
-                {
-                    "country": "XW",
-                    "date": "2013-06-05",
-                    "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
-                    "medium-list": [
-                        {
-                            "format": "Digital Media",
-                            "position": "1",
-                            "track-count": 11,
-                            "track-list": [
-                                {
-                                    "id": "64d43604-c1ee-4f45-a02c-030672d2fe27",
-                                    "length": "271000",
-                                    "number": "1",
-                                    "title": "8-Bit Adventure",
-                                    "track_or_recording_length": "271000"
-                                }
-                            ]
-                        }
-                    ],
-                    "medium-track-count": 11,
-                    "release-event-list": [
-                        {
-                            "area": {
-                                "id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
-                                "iso-3166-1-code-list": [
-                                    "XW"
-                                ],
-                                "name": "[Worldwide]",
-                                "sort-name": "[Worldwide]"
-                            },
-                            "date": "2013-06-05"
-                        }
-                    ],
-                    "release-group": {
-                        "id": "447b4979-2178-405c-bfe6-46bf0b09e6c7",
-                        "primary-type": "Album",
-                        "type": "Album"
-                    },
-                    "status": "Official",
-                    "title": "Marsupial Madness"
-                }
-            ],
-            "title": "8-Bit Adventure",
-            "tag-list": [
-                {
-                    "count": "2",
-                    "name": "techno"
-                },
-                {
-                    "count": "2",
-                    "name": "good-music"
-                },
-            ],
-        },
-    ]
-}
-
-tracks['get']['8bitadventures'] = {'recording': tracks['search']['8bitadventures']['recording-list'][0]}
-tracks['get']['chop_suey'] = {
-    'recording': {
-        'id': '46c7368a-013a-47b6-97cc-e55e7ab25213',
-        'length': '210240',
-        'title': 'Chop Suey!',
-        'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
-        'type': 'performance',
-        'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0',
-        'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
-            'language': 'eng',
-            'title': 'Chop Suey!'}}]}}
-
-works = {'search': {}, 'get': {}}
-works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
-  'language': 'eng',
-  'recording-relation-list': [{'direction': 'backward',
-    'recording': {'disambiguation': 'edit',
-     'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
-     'length': '170893',
-     'title': 'Chop Suey!'},
-    'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
-    'type': 'performance',
-    'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'},
-  ],
-  'title': 'Chop Suey!',
-  'type': 'Song',
-  'url-relation-list': [{'direction': 'backward',
-    'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!',
-    'type': 'lyrics',
-    'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}}
diff --git a/api/tests/music/mocking/lyricswiki.py b/api/tests/music/mocking/lyricswiki.py
deleted file mode 100644
index 360a7174f0740ff33ab9aa85dedc622e1dd176ef..0000000000000000000000000000000000000000
--- a/api/tests/music/mocking/lyricswiki.py
+++ /dev/null
@@ -1,32 +0,0 @@
-content = """<!doctype html>
-<html lang="en" dir="ltr">
-<head>
-
-<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
-	<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
-<meta name="generator" content="MediaWiki 1.19.24" />
-<meta name="keywords" content="Chop Suey! lyrics,System Of A Down Chop Suey! lyrics,Chop Suey! by System Of A Down lyrics,lyrics,LyricWiki,LyricWikia,lyricwiki,System Of A Down:Chop Suey!,System Of A Down,System Of A Down:Toxicity (2001),Enter Shikari,Enter Shikari:Chop Suey!,&quot;Weird Al&quot; Yankovic,&quot;Weird Al&quot; Yankovic:Angry White Boy Polka,Renard,Renard:Physicality,System Of A Down:Chop Suey!/pt,Daron Malakian" />
-<meta name="description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." />
-<meta name="twitter:card" content="summary" />
-<meta name="twitter:site" content="@Wikia" />
-<meta name="twitter:url" content="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" />
-<meta name="twitter:title" content="System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia" />
-<meta name="twitter:description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." />
-<link rel="canonical" href="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" />
-<link rel="alternate" type="application/x-wiki" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" />
-<link rel="edit" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" />
-<link rel="apple-touch-icon" href="http://img4.wikia.nocookie.net/__cb22/lyricwiki/images/b/bc/Wiki.png" />
-<link rel="shortcut icon" href="http://slot1.images.wikia.nocookie.net/__cb1474018633/common/skins/common/images/favicon.ico" />
-<link rel="search" type="application/opensearchdescription+xml" href="/opensearch_desc.php" title="LyricWikia (en)" />
-<link rel="EditURI" type="application/rsd+xml" href="http://lyrics.wikia.com/api.php?action=rsd" />
-<link rel="copyright" href="/wiki/LyricWiki:Copyrights" />
-<link rel="alternate" type="application/atom+xml" title="LyricWikia Atom feed" href="/wiki/Special:RecentChanges?feed=atom" />
-<title>System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia</title>
-
-<body>
-<div class='lyricbox'>
-<i>&#87;&#101;&#39;&#114;&#101;&#32;&#114;&#111;&#108;&#108;&#105;&#110;&#103;&#32;&#34;&#83;&#117;&#105;&#99;&#105;&#100;&#101;&#34;&#46;</i><br /><br />&#87;&#97;&#107;&#101;&#32;&#117;&#112;&#32;<i>&#40;&#119;&#97;&#107;&#101;&#32;&#117;&#112;&#41;</i><br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#111;&#110;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;&#32;<i>&#40;&#104;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#41;</i><br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#72;&#101;&#114;&#101;&#32;&#121;&#111;&#117;&#32;&#103;&#111;&#44;&#32;&#99;&#114;&#101;&#97;&#116;&#101;&#32;&#97;&#110;&#111;&#116;&#104;&#101;&#114;&#32;&#102;&#97;&#98;&#108;&#101;<br /><br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br /><br />&#73;&#32;&#100;&#111;&#110;&#39;&#116;&#32;&#116;&#104;&#105;&#110;&#107;&#32;&#121;&#111;&#117;&#32;&#116;&#114;&#117;&#115;&#116;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br /><br />&#87;&#97;&#107;&#101;&#32;&#117;&#112;&#32;<i>&#40;&#119;&#97;&#107;&#101;&#32;&#117;&#112;&#41;</i><br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#111;&#110;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;<i>&#40;&#104;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#41;</i><br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#72;&#101;&#114;&#101;&#32;&#121;&#111;&#117;&#32;&#103;&#111;&#44;&#32;&#99;&#114;&#101;&#97;&#116;&#101;&#32;&#97;&#110;&#111;&#116;&#104;&#101;&#114;&#32;&#102;&#97;&#98;&#108;&#101;<br /><br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#71;&#114;&#97;&#98;&#32;&#97;&#32;&#98;&#114;&#117;&#115;&#104;&#32;&#97;&#110;&#100;&#32;&#112;&#117;&#116;&#32;&#97;&#32;&#108;&#105;&#116;&#116;&#108;&#101;&#32;&#109;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#72;&#105;&#100;&#101;&#32;&#116;&#104;&#101;&#32;&#115;&#99;&#97;&#114;&#115;&#32;&#116;&#111;&#32;&#102;&#97;&#100;&#101;&#32;&#97;&#119;&#97;&#121;&#32;&#116;&#104;&#101;&#32;&#115;&#104;&#97;&#107;&#101;&#117;&#112;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br />&#87;&#104;&#121;&#39;&#100;&#32;&#121;&#111;&#117;&#32;&#108;&#101;&#97;&#118;&#101;&#32;&#116;&#104;&#101;&#32;&#107;&#101;&#121;&#115;&#32;&#117;&#112;&#111;&#110;&#32;&#116;&#104;&#101;&#32;&#116;&#97;&#98;&#108;&#101;&#63;<br />&#89;&#111;&#117;&#32;&#119;&#97;&#110;&#116;&#101;&#100;&#32;&#116;&#111;<br /><br />&#73;&#32;&#100;&#111;&#110;&#39;&#116;&#32;&#116;&#104;&#105;&#110;&#107;&#32;&#121;&#111;&#117;&#32;&#116;&#114;&#117;&#115;&#116;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br /><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#32;<i>&#40;&#102;&#97;&#116;&#104;&#101;&#114;&#41;</i><br />&#70;&#97;&#116;&#104;&#101;&#114;&#44;&#32;&#105;&#110;&#116;&#111;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#97;&#110;&#100;&#115;&#32;&#73;&#32;&#99;&#111;&#109;&#109;&#105;&#116;&#32;&#109;&#121;&#32;&#115;&#112;&#105;&#114;&#105;&#116;<br />&#70;&#97;&#116;&#104;&#101;&#114;&#44;&#32;&#105;&#110;&#116;&#111;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#97;&#110;&#100;&#115;<br /><br />&#87;&#104;&#121;&#32;&#104;&#97;&#118;&#101;&#32;&#121;&#111;&#117;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;&#63;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#101;&#121;&#101;&#115;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#116;&#104;&#111;&#117;&#103;&#104;&#116;&#115;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;<br />&#73;&#110;&#32;&#121;&#111;&#117;&#114;&#32;&#104;&#101;&#97;&#114;&#116;&#32;&#102;&#111;&#114;&#115;&#97;&#107;&#101;&#110;&#32;&#109;&#101;&#44;&#32;&#111;&#104;<br /><br />&#84;&#114;&#117;&#115;&#116;&#32;&#105;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;<br />&#73;&#110;&#32;&#109;&#121;&#32;&#115;&#101;&#108;&#102;&#45;&#114;&#105;&#103;&#104;&#116;&#101;&#111;&#117;&#115;&#32;&#115;&#117;&#105;&#99;&#105;&#100;&#101;<br />&#73;&#32;&#99;&#114;&#121;&#32;&#119;&#104;&#101;&#110;&#32;&#97;&#110;&#103;&#101;&#108;&#115;&#32;&#100;&#101;&#115;&#101;&#114;&#118;&#101;&#32;&#116;&#111;&#32;&#100;&#105;&#101;&#10;
-</div>
-</body>
-</html>
-"""
diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py
index 8196d3c092e62b4d85f5da5c1b7490edc6f2def2..606720e133928ecda0d2bf22e9440e6b1c9eeb07 100644
--- a/api/tests/music/test_api.py
+++ b/api/tests/music/test_api.py
@@ -8,34 +8,40 @@ from funkwhale_api.musicbrainz import api
 from funkwhale_api.music import serializers
 from funkwhale_api.music import tasks
 
-from . import data as api_data
 
 DATA_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
-def test_can_submit_youtube_url_for_track_import(mocker, superuser_client):
+def test_can_submit_youtube_url_for_track_import(
+        settings, artists, albums, tracks, mocker, superuser_client):
+    mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['adhesive_wombat'])
+        return_value=artists['get']['adhesive_wombat'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get']['marsupial'])
+        return_value=albums['get']['marsupial'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.get',
-        return_value=api_data.tracks['get']['8bitadventures'])
+        return_value=tracks['get']['8bitadventures'])
     mocker.patch(
         'funkwhale_api.music.models.TrackFile.download_file',
         return_value=None)
     mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
     video_id = 'tPEE9ZwTmy0'
     url = reverse('api:v1:submit-single')
+    video_url = 'https://www.youtube.com/watch?v={0}'.format(video_id)
     response = superuser_client.post(
         url,
-        {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
+        {'import_url': video_url,
          'mbid': mbid})
-    track = models.Track.objects.get(mbid=mbid)
-    assert track.artist.name == 'Adhesive Wombat'
-    assert track.album.title == 'Marsupial Madness'
+
+    assert response.status_code == 201
+    batch = superuser_client.user.imports.latest('id')
+    job = batch.jobs.latest('id')
+    assert job.status == 'pending'
+    assert str(job.mbid) == mbid
+    assert job.source == video_url
 
 
 def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
@@ -58,17 +64,18 @@ def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
     assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id)
 
 
-def test_can_import_whole_album(mocker, superuser_client):
+def test_can_import_whole_album(
+        artists, albums, mocker, superuser_client):
     mocker.patch('funkwhale_api.music.tasks.import_job_run')
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['soad'])
+        return_value=artists['get']['soad'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.images.get_front',
         return_value=b'')
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get_with_includes']['hypnotize'])
+        return_value=albums['get_with_includes']['hypnotize'])
     payload = {
         'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
         'tracks': [
@@ -97,7 +104,7 @@ def test_can_import_whole_album(mocker, superuser_client):
 
     album = models.Album.objects.latest('id')
     assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
-    medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
+    medium_data = albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
     assert int(medium_data['track-count']) == album.tracks.all().count()
 
     for track in medium_data['track-list']:
@@ -113,17 +120,18 @@ def test_can_import_whole_album(mocker, superuser_client):
         assert job.source == row['source']
 
 
-def test_can_import_whole_artist(mocker, superuser_client):
+def test_can_import_whole_artist(
+        artists, albums, mocker, superuser_client):
     mocker.patch('funkwhale_api.music.tasks.import_job_run')
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['soad'])
+        return_value=artists['get']['soad'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.images.get_front',
         return_value=b'')
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get_with_includes']['hypnotize'])
+        return_value=albums['get_with_includes']['hypnotize'])
     payload = {
         'artistId': 'mbid',
         'albums': [
@@ -157,7 +165,7 @@ def test_can_import_whole_artist(mocker, superuser_client):
 
     album = models.Album.objects.latest('id')
     assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
-    medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
+    medium_data = albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
     assert int(medium_data['track-count']) == album.tracks.all().count()
 
     for track in medium_data['track-list']:
@@ -173,55 +181,57 @@ def test_can_import_whole_artist(mocker, superuser_client):
         assert job.source == row['source']
 
 
-def test_user_can_query_api_for_his_own_batches(client, factories):
-    user1 = factories['users.SuperUser']()
-    user2 = factories['users.SuperUser']()
-
-    job = factories['music.ImportJob'](batch__submitted_by=user1)
+def test_user_can_query_api_for_his_own_batches(
+        superuser_api_client, factories):
+    factories['music.ImportJob']()
+    job = factories['music.ImportJob'](
+        batch__submitted_by=superuser_api_client.user)
     url = reverse('api:v1:import-batches-list')
 
-    client.login(username=user2.username, password='test')
-    response2 = client.get(url)
-    results = json.loads(response2.content.decode('utf-8'))
-    assert results['count'] == 0
-    client.logout()
-
-    client.login(username=user1.username, password='test')
-    response1 = client.get(url)
-    results = json.loads(response1.content.decode('utf-8'))
+    response = superuser_api_client.get(url)
+    results = response.data
     assert results['count'] == 1
     assert results['results'][0]['jobs'][0]['mbid'] == job.mbid
 
 
-def test_user_can_create_an_empty_batch(client, factories):
-    user = factories['users.SuperUser']()
+def test_user_cannnot_access_other_batches(
+        superuser_api_client, factories):
+    factories['music.ImportJob']()
+    job = factories['music.ImportJob']()
     url = reverse('api:v1:import-batches-list')
-    client.login(username=user.username, password='test')
-    response = client.post(url)
+
+    response = superuser_api_client.get(url)
+    results = response.data
+    assert results['count'] == 0
+
+
+def test_user_can_create_an_empty_batch(superuser_api_client, factories):
+    url = reverse('api:v1:import-batches-list')
+    response = superuser_api_client.post(url)
 
     assert response.status_code == 201
 
-    batch = user.imports.latest('id')
+    batch = superuser_api_client.user.imports.latest('id')
 
-    assert batch.submitted_by == user
+    assert batch.submitted_by == superuser_api_client.user
     assert batch.source == 'api'
 
 
-def test_user_can_create_import_job_with_file(client, factories, mocker):
+def test_user_can_create_import_job_with_file(
+        superuser_api_client, factories, mocker):
     path = os.path.join(DATA_DIR, 'test.ogg')
     m = mocker.patch('funkwhale_api.common.utils.on_commit')
-    user = factories['users.SuperUser']()
-    batch = factories['music.ImportBatch'](submitted_by=user)
+    batch = factories['music.ImportBatch'](
+        submitted_by=superuser_api_client.user)
     url = reverse('api:v1:import-jobs-list')
-    client.login(username=user.username, password='test')
     with open(path, 'rb') as f:
         content = f.read()
         f.seek(0)
-        response = client.post(url, {
+        response = superuser_api_client.post(url, {
             'batch': batch.pk,
             'audio_file': f,
             'source': 'file://'
-        }, format='multipart')
+        })
 
     assert response.status_code == 201
 
@@ -237,16 +247,16 @@ def test_user_can_create_import_job_with_file(client, factories, mocker):
         import_job_id=job.pk)
 
 
-def test_can_search_artist(factories, client):
+def test_can_search_artist(factories, logged_in_client):
     artist1 = factories['music.Artist']()
     artist2 = factories['music.Artist']()
     expected = [serializers.ArtistSerializerNested(artist1).data]
     url = reverse('api:v1:artists-search')
-    response = client.get(url, {'query': artist1.name})
-    assert json.loads(response.content.decode('utf-8')) == expected
+    response = logged_in_client.get(url, {'query': artist1.name})
+    assert response.data == expected
 
 
-def test_can_search_artist_by_name_start(factories, client):
+def test_can_search_artist_by_name_start(factories, logged_in_client):
     artist1 = factories['music.Artist'](name='alpha')
     artist2 = factories['music.Artist'](name='beta')
     expected = {
@@ -256,20 +266,20 @@ def test_can_search_artist_by_name_start(factories, client):
         'results': [serializers.ArtistSerializerNested(artist1).data]
     }
     url = reverse('api:v1:artists-list')
-    response = client.get(url, {'name__startswith': 'a'})
+    response = logged_in_client.get(url, {'name__startswith': 'a'})
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
 
 
-def test_can_search_tracks(factories, client):
+def test_can_search_tracks(factories, logged_in_client):
     track1 = factories['music.Track'](title="test track 1")
     track2 = factories['music.Track']()
     query = 'test track 1'
     expected = [serializers.TrackSerializerNested(track1).data]
     url = reverse('api:v1:tracks-search')
-    response = client.get(url, {'query': query})
+    response = logged_in_client.get(url, {'query': query})
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
 
 
 @pytest.mark.parametrize('route,method', [
@@ -278,24 +288,31 @@ def test_can_search_tracks(factories, client):
     ('api:v1:artists-list', 'get'),
     ('api:v1:albums-list', 'get'),
 ])
-def test_can_restrict_api_views_to_authenticated_users(db, route, method, settings, client):
+def test_can_restrict_api_views_to_authenticated_users(
+        db, route, method, settings, client):
     url = reverse(route)
     settings.API_AUTHENTICATION_REQUIRED = True
     response = getattr(client, method)(url)
     assert response.status_code == 401
 
 
-def test_track_file_url_is_restricted_to_authenticated_users(client, factories, settings):
+def test_track_file_url_is_restricted_to_authenticated_users(
+        api_client, factories, settings):
     settings.API_AUTHENTICATION_REQUIRED = True
     f = factories['music.TrackFile']()
     assert f.audio_file is not None
     url = f.path
-    response = client.get(url)
+    response = api_client.get(url)
     assert response.status_code == 401
 
-    user = factories['users.SuperUser']()
-    client.login(username=user.username, password='test')
-    response = client.get(url)
+
+def test_track_file_url_is_accessible_to_authenticated_users(
+        logged_in_api_client, factories, settings):
+    settings.API_AUTHENTICATION_REQUIRED = True
+    f = factories['music.TrackFile']()
+    assert f.audio_file is not None
+    url = f.path
+    response = logged_in_api_client.get(url)
 
     assert response.status_code == 200
     assert response['X-Accel-Redirect'] == '/_protected{}'.format(f.audio_file.url)
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
index f2ca1abbd04a562764194f09653e17c4724f3cc4..0f709e81f508fcb0e4e2ee06e92991a2f907cdff 100644
--- a/api/tests/music/test_import.py
+++ b/api/tests/music/test_import.py
@@ -2,23 +2,21 @@ import json
 
 from django.urls import reverse
 
-from . import data as api_data
-
 
 def test_create_import_can_bind_to_request(
-        mocker, factories, superuser_api_client):
+        artists, albums, mocker, factories, superuser_api_client):
     request = factories['requests.ImportRequest']()
 
     mocker.patch('funkwhale_api.music.tasks.import_job_run')
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['soad'])
+        return_value=artists['get']['soad'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.images.get_front',
         return_value=b'')
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get_with_includes']['hypnotize'])
+        return_value=albums['get_with_includes']['hypnotize'])
     payload = {
         'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
         'importRequest': request.pk,
diff --git a/api/tests/music/test_lyrics.py b/api/tests/music/test_lyrics.py
index d10d113d7741d1e53e64d9af856a4d466bab6d35..3aee368c0e9c98a0da626b05f427191b4f8372d4 100644
--- a/api/tests/music/test_lyrics.py
+++ b/api/tests/music/test_lyrics.py
@@ -7,15 +7,12 @@ from funkwhale_api.music import serializers
 from funkwhale_api.music import tasks
 from funkwhale_api.music import lyrics as lyrics_utils
 
-from .mocking import lyricswiki
-from . import data as api_data
 
-
-
-def test_works_import_lyrics_if_any(mocker, factories):
+def test_works_import_lyrics_if_any(
+        lyricswiki_content, mocker, factories):
     mocker.patch(
         'funkwhale_api.music.lyrics._get_html',
-        return_value=lyricswiki.content)
+        return_value=lyricswiki_content)
     lyrics = factories['music.Lyrics'](
         url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
 
@@ -48,16 +45,22 @@ Is it me you're looking for?"""
     assert expected == l.content_rendered
 
 
-def test_works_import_lyrics_if_any(mocker, factories, logged_in_client):
+def test_works_import_lyrics_if_any(
+        lyricswiki_content,
+        works,
+        tracks,
+        mocker,
+        factories,
+        logged_in_client):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.works.get',
-        return_value=api_data.works['get']['chop_suey'])
+        return_value=works['get']['chop_suey'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.get',
-        return_value=api_data.tracks['get']['chop_suey'])
+        return_value=tracks['get']['chop_suey'])
     mocker.patch(
         'funkwhale_api.music.lyrics._get_html',
-        return_value=lyricswiki.content)
+        return_value=lyricswiki_content)
     track = factories['music.Track'](
         work=None,
         mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py
index 076ad2bd05cb714c6436592666dffeeae61396ba..4162912e4fdee2e13d192ce14ed2a1fc4dcd2a23 100644
--- a/api/tests/music/test_music.py
+++ b/api/tests/music/test_music.py
@@ -2,14 +2,11 @@ import pytest
 from funkwhale_api.music import models
 import datetime
 
-from . import data as api_data
-from .cover import binary_data
 
-
-def test_can_create_artist_from_api(mocker, db):
+def test_can_create_artist_from_api(artists, mocker, db):
     mocker.patch(
         'musicbrainzngs.search_artists',
-        return_value=api_data.artists['search']['adhesive_wombat'])
+        return_value=artists['search']['adhesive_wombat'])
     artist = models.Artist.create_from_api(query="Adhesive wombat")
     data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
 
@@ -19,13 +16,13 @@ def test_can_create_artist_from_api(mocker, db):
     assert artist.name, 'Adhesive Wombat'
 
 
-def test_can_create_album_from_api(mocker, db):
+def test_can_create_album_from_api(artists, albums, mocker, db):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.search',
-        return_value=api_data.albums['search']['hypnotize'])
+        return_value=albums['search']['hypnotize'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['soad'])
+        return_value=artists['get']['soad'])
     album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album')
     data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0]
 
@@ -38,16 +35,16 @@ def test_can_create_album_from_api(mocker, db):
     assert album.artist.mbid, data['artist-credit'][0]['artist']['id']
 
 
-def test_can_create_track_from_api(mocker, db):
+def test_can_create_track_from_api(artists, albums, tracks, mocker, db):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['adhesive_wombat'])
+        return_value=artists['get']['adhesive_wombat'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get']['marsupial'])
+        return_value=albums['get']['marsupial'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.search',
-        return_value=api_data.tracks['search']['8bitadventures'])
+        return_value=tracks['search']['8bitadventures'])
     track = models.Track.create_from_api(query="8-bit adventure")
     data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
     assert int(data['ext:score']) == 100
@@ -60,16 +57,17 @@ def test_can_create_track_from_api(mocker, db):
     assert track.album.title == 'Marsupial Madness'
 
 
-def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
+def test_can_create_track_from_api_with_corresponding_tags(
+        artists, albums, tracks, mocker, db):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['adhesive_wombat'])
+        return_value=artists['get']['adhesive_wombat'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get']['marsupial'])
+        return_value=albums['get']['marsupial'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.get',
-        return_value=api_data.tracks['get']['8bitadventures'])
+        return_value=tracks['get']['8bitadventures'])
     track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed')
     expected_tags = ['techno', 'good-music']
     track_tags = [tag.slug for tag in track.tags.all()]
@@ -77,16 +75,17 @@ def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
         assert tag in track_tags
 
 
-def test_can_get_or_create_track_from_api(mocker, db):
+def test_can_get_or_create_track_from_api(
+        artists, albums, tracks, mocker, db):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['adhesive_wombat'])
+        return_value=artists['get']['adhesive_wombat'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get']['marsupial'])
+        return_value=albums['get']['marsupial'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.search',
-        return_value=api_data.tracks['search']['8bitadventures'])
+        return_value=tracks['search']['8bitadventures'])
     track = models.Track.create_from_api(query="8-bit adventure")
     data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
     assert int(data['ext:score']) == 100
@@ -126,13 +125,13 @@ def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_querie
         assert tag in artist.tags
 
 
-def test_can_download_image_file_for_album(mocker, factories):
+def test_can_download_image_file_for_album(binary_cover, mocker, factories):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.images.get_front',
-        return_value=binary_data)
+        return_value=binary_cover)
     # client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
     album = factories['music.Album'](mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
     album.get_image()
     album.save()
 
-    assert album.cover.file.read() == binary_data
+    assert album.cover.file.read() == binary_cover
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index 5ecf9b9e46310c5f3779fa4ced90daa0809c5aff..ddbc4ba9a2c7407bd067dc2799f499654cbb004c 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -4,8 +4,6 @@ import pytest
 from funkwhale_api.providers.acoustid import get_acoustid_client
 from funkwhale_api.music import tasks
 
-from . import data as api_data
-
 DATA_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
@@ -50,7 +48,7 @@ def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
 
 
 def test_import_job_can_run_with_file_and_acoustid(
-        preferences, factories, mocker):
+        artists, albums, tracks, preferences, factories, mocker):
     preferences['providers_acoustid__api_key'] = 'test'
     path = os.path.join(DATA_DIR, 'test.ogg')
     mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
@@ -66,13 +64,13 @@ def test_import_job_can_run_with_file_and_acoustid(
     }
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['adhesive_wombat'])
+        return_value=artists['get']['adhesive_wombat'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get']['marsupial'])
+        return_value=albums['get']['marsupial'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.search',
-        return_value=api_data.tracks['search']['8bitadventures'])
+        return_value=tracks['search']['8bitadventures'])
     mocker.patch('acoustid.match', return_value=acoustid_payload)
 
     job = factories['music.FileImportJob'](audio_file__path=path)
@@ -129,7 +127,8 @@ def test__do_import_skipping_accoustid_if_no_key(
     m.assert_called_once_with(p)
 
 
-def test_import_job_can_be_skipped(factories, mocker, preferences):
+def test_import_job_can_be_skipped(
+        artists, albums, tracks, factories, mocker, preferences):
     preferences['providers_acoustid__api_key'] = 'test'
     path = os.path.join(DATA_DIR, 'test.ogg')
     mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
@@ -146,13 +145,13 @@ def test_import_job_can_be_skipped(factories, mocker, preferences):
     }
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['adhesive_wombat'])
+        return_value=artists['get']['adhesive_wombat'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.get',
-        return_value=api_data.albums['get']['marsupial'])
+        return_value=albums['get']['marsupial'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.search',
-        return_value=api_data.tracks['search']['8bitadventures'])
+        return_value=tracks['search']['8bitadventures'])
     mocker.patch('acoustid.match', return_value=acoustid_payload)
 
     job = factories['music.FileImportJob'](audio_file__path=path)
diff --git a/api/tests/music/test_works.py b/api/tests/music/test_works.py
index 9b72768ad07bf05adae848eef73784866de83c7d..13f6447bec60f54797f890f441412494f557b7c7 100644
--- a/api/tests/music/test_works.py
+++ b/api/tests/music/test_works.py
@@ -5,13 +5,11 @@ from funkwhale_api.music import models
 from funkwhale_api.musicbrainz import api
 from funkwhale_api.music import serializers
 
-from . import data as api_data
 
-
-def test_can_import_work(factories, mocker):
+def test_can_import_work(factories, mocker, works):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.works.get',
-        return_value=api_data.works['get']['chop_suey'])
+        return_value=works['get']['chop_suey'])
     recording = factories['music.Track'](
         mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
     mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
@@ -28,13 +26,13 @@ def test_can_import_work(factories, mocker):
     assert recording.work == work
 
 
-def test_can_get_work_from_recording(factories, mocker):
+def test_can_get_work_from_recording(factories, mocker, works, tracks):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.works.get',
-        return_value=api_data.works['get']['chop_suey'])
+        return_value=works['get']['chop_suey'])
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.get',
-        return_value=api_data.tracks['get']['chop_suey'])
+        return_value=tracks['get']['chop_suey'])
     recording = factories['music.Track'](
         work=None,
         mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
@@ -53,10 +51,10 @@ def test_can_get_work_from_recording(factories, mocker):
     assert recording.work == work
 
 
-def test_works_import_lyrics_if_any(db, mocker):
+def test_works_import_lyrics_if_any(db, mocker, works):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.works.get',
-        return_value=api_data.works['get']['chop_suey'])
+        return_value=works['get']['chop_suey'])
     mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
     work = models.Work.create_from_api(id=mbid)
 
diff --git a/api/tests/musicbrainz/data.py b/api/tests/musicbrainz/conftest.py
similarity index 96%
rename from api/tests/musicbrainz/data.py
rename to api/tests/musicbrainz/conftest.py
index 1d7b9a3defa3c9927a649485e89b487eed7b9123..505d6e5537ab367090a4ead483e56105f561080c 100644
--- a/api/tests/musicbrainz/data.py
+++ b/api/tests/musicbrainz/conftest.py
@@ -1,5 +1,7 @@
-artists = {'search': {}, 'get': {}}
-artists['search']['lost fingers'] = {
+import pytest
+
+_artists = {'search': {}, 'get': {}}
+_artists['search']['lost fingers'] = {
     'artist-count': 696,
     'artist-list': [
         {
@@ -21,7 +23,7 @@ artists['search']['lost fingers'] = {
         },
     ]
 }
-artists['get']['lost fingers'] = {
+_artists['get']['lost fingers'] = {
     "artist": {
         "life-span": {
             "begin": "2008"
@@ -102,8 +104,8 @@ artists['get']['lost fingers'] = {
 }
 
 
-release_groups = {'browse': {}}
-release_groups['browse']["lost fingers"] = {
+_release_groups = {'browse': {}}
+_release_groups['browse']["lost fingers"] = {
     "release-group-list": [
         {
             "first-release-date": "2010",
@@ -165,8 +167,8 @@ release_groups['browse']["lost fingers"] = {
     "release-group-count": 8
 }
 
-recordings = {'search': {}, 'get': {}}
-recordings['search']['brontide matador'] = {
+_recordings = {'search': {}, 'get': {}}
+_recordings['search']['brontide matador'] = {
     "recording-count": 1044,
     "recording-list": [
         {
@@ -217,8 +219,8 @@ recordings['search']['brontide matador'] = {
     ]
 }
 
-releases = {'search': {}, 'get': {}, 'browse': {}}
-releases['search']['brontide matador'] = {
+_releases = {'search': {}, 'get': {}, 'browse': {}}
+_releases['search']['brontide matador'] = {
     "release-count": 116, "release-list": [
         {
             "ext:score": "100",
@@ -283,7 +285,7 @@ releases['search']['brontide matador'] = {
     ]
 }
 
-releases['browse']['Lost in the 80s'] = {
+_releases['browse']['Lost in the 80s'] = {
     "release-count": 3,
     "release-list": [
         {
@@ -476,3 +478,23 @@ releases['browse']['Lost in the 80s'] = {
         },
     ]
 }
+
+
+@pytest.fixture()
+def releases():
+    return _releases
+
+
+@pytest.fixture()
+def release_groups():
+    return _release_groups
+
+
+@pytest.fixture()
+def artists():
+    return _artists
+
+
+@pytest.fixture()
+def recordings():
+    return _recordings
diff --git a/api/tests/musicbrainz/test_api.py b/api/tests/musicbrainz/test_api.py
index bbade340060dae4cbc4f2a64d296eb86e6c59e79..fdd1dbdb03b74769588ef6c66c23a49cc1053b29 100644
--- a/api/tests/musicbrainz/test_api.py
+++ b/api/tests/musicbrainz/test_api.py
@@ -2,64 +2,65 @@ import json
 from django.urls import reverse
 
 from funkwhale_api.musicbrainz import api
-from . import data as api_data
 
 
 
-def test_can_search_recording_in_musicbrainz_api(db, mocker, client):
+def test_can_search_recording_in_musicbrainz_api(
+        recordings, db, mocker, logged_in_api_client):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.recordings.search',
-        return_value=api_data.recordings['search']['brontide matador'])
+        return_value=recordings['search']['brontide matador'])
     query = 'brontide matador'
     url = reverse('api:v1:providers:musicbrainz:search-recordings')
-    expected = api_data.recordings['search']['brontide matador']
-    response = client.get(url, data={'query': query})
+    expected = recordings['search']['brontide matador']
+    response = logged_in_api_client.get(url, data={'query': query})
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
 
 
-def test_can_search_release_in_musicbrainz_api(db, mocker, client):
+def test_can_search_release_in_musicbrainz_api(releases, db, mocker, logged_in_api_client):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.search',
-        return_value=api_data.releases['search']['brontide matador'])
+        return_value=releases['search']['brontide matador'])
     query = 'brontide matador'
     url = reverse('api:v1:providers:musicbrainz:search-releases')
-    expected = api_data.releases['search']['brontide matador']
-    response = client.get(url, data={'query': query})
+    expected = releases['search']['brontide matador']
+    response = logged_in_api_client.get(url, data={'query': query})
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
 
 
-def test_can_search_artists_in_musicbrainz_api(db, mocker, client):
+def test_can_search_artists_in_musicbrainz_api(artists, db, mocker, logged_in_api_client):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.search',
-        return_value=api_data.artists['search']['lost fingers'])
+        return_value=artists['search']['lost fingers'])
     query = 'lost fingers'
     url = reverse('api:v1:providers:musicbrainz:search-artists')
-    expected = api_data.artists['search']['lost fingers']
-    response = client.get(url, data={'query': query})
+    expected = artists['search']['lost fingers']
+    response = logged_in_api_client.get(url, data={'query': query})
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
 
 
-def test_can_get_artist_in_musicbrainz_api(db, mocker, client):
+def test_can_get_artist_in_musicbrainz_api(artists, db, mocker, logged_in_api_client):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['lost fingers'])
+        return_value=artists['get']['lost fingers'])
     uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
     url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={
         'uuid': uuid,
     })
-    response = client.get(url)
-    expected = api_data.artists['get']['lost fingers']
+    response = logged_in_api_client.get(url)
+    expected = artists['get']['lost fingers']
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
 
 
-def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
+def test_can_broswe_release_group_using_musicbrainz_api(
+        release_groups, db, mocker, logged_in_api_client):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.release_groups.browse',
-        return_value=api_data.release_groups['browse']['lost fingers'])
+        return_value=release_groups['browse']['lost fingers'])
     uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
     url = reverse(
         'api:v1:providers:musicbrainz:release-group-browse',
@@ -67,16 +68,17 @@ def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
             'artist_uuid': uuid,
         }
     )
-    response = client.get(url)
-    expected = api_data.release_groups['browse']['lost fingers']
+    response = logged_in_api_client.get(url)
+    expected = release_groups['browse']['lost fingers']
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
 
 
-def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
+def test_can_broswe_releases_using_musicbrainz_api(
+        releases, db, mocker, logged_in_api_client):
     mocker.patch(
         'funkwhale_api.musicbrainz.api.releases.browse',
-        return_value=api_data.releases['browse']['Lost in the 80s'])
+        return_value=releases['browse']['Lost in the 80s'])
     uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
     url = reverse(
         'api:v1:providers:musicbrainz:release-browse',
@@ -84,7 +86,7 @@ def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
             'release_group_uuid': uuid,
         }
     )
-    response = client.get(url)
-    expected = api_data.releases['browse']['Lost in the 80s']
+    response = logged_in_api_client.get(url)
+    expected = releases['browse']['Lost in the 80s']
 
-    assert expected == json.loads(response.content.decode('utf-8'))
+    assert expected == response.data
diff --git a/api/tests/playlists/__init__.py b/api/tests/playlists/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
index 5bf83488859177aaf4e756a5f25668f568641b35..f0fb6d0fdc19286b2ebbbc0b2ef5336d3bd642f5 100644
--- a/api/tests/playlists/test_views.py
+++ b/api/tests/playlists/test_views.py
@@ -106,7 +106,9 @@ def test_deleting_plt_updates_indexes(
 
 
 @pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
-def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
+def test_playlist_privacy_respected_in_list_anon(
+        settings, level, factories, api_client):
+    settings.API_AUTHENTICATION_REQUIRED = False
     factories['playlists.Playlist'](privacy_level=level)
     url = reverse('api:v1:playlists-list')
     response = api_client.get(url)
@@ -115,26 +117,28 @@ def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
 
 
 @pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
-def test_only_owner_can_edit_playlist(method, factories, api_client):
+def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
     playlist = factories['playlists.Playlist']()
     url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
-    response = api_client.get(url)
+    response = getattr(logged_in_api_client, method.lower())(url)
 
     assert response.status_code == 404
 
 
 @pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
-def test_only_owner_can_edit_playlist_track(method, factories, api_client):
+def test_only_owner_can_edit_playlist_track(
+        method, factories, logged_in_api_client):
     plt = factories['playlists.PlaylistTrack']()
     url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
-    response = api_client.get(url)
+    response = getattr(logged_in_api_client, method.lower())(url)
 
     assert response.status_code == 404
 
 
 @pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
 def test_playlist_track_privacy_respected_in_list_anon(
-        level, factories, api_client):
+        level, factories, api_client, settings):
+    settings.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/__init__.py b/api/tests/radios/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py
index b731e3024b039bd5006023bb80276405565edd8e..c8038a4dbadcb4283d073492beb715e7092bab20 100644
--- a/api/tests/radios/test_radios.py
+++ b/api/tests/radios/test_radios.py
@@ -151,14 +151,18 @@ 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(client, db):
+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 = client.post(url, {'radio_type': 'random'})
+    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 == client.session.session_key
+    assert session.session_key == api_client.session.session_key
 
 
 def test_can_get_track_for_session_from_api(factories, logged_in_client):
@@ -228,13 +232,18 @@ def test_can_start_tag_radio(factories):
         assert radio.pick() in good_tracks
 
 
-def test_can_start_artist_radio_from_api(client, factories):
+def test_can_start_artist_radio_from_api(api_client, settings, factories):
+    settings.API_AUTHENTICATION_REQUIRED = False
     artist = factories['music.Artist']()
     url = reverse('api:v1:radios:sessions-list')
 
-    response = client.post(
+    response = 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
 
diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py
index 4f3de27db47df47250c69a844e4f15a37f092c21..67263e66d0c56309bb21f344274277b7d4464165 100644
--- a/api/tests/test_import_audio_file.py
+++ b/api/tests/test_import_audio_file.py
@@ -98,3 +98,27 @@ def test_import_files_skip_acoustid(factories, mocker):
         music_tasks.import_job_run.delay,
         import_job_id=job.pk,
         use_acoustid=False)
+
+
+def test_import_files_works_with_utf8_file_name(factories, mocker):
+    m = mocker.patch('funkwhale_api.common.utils.on_commit')
+    user = factories['users.User'](username='me')
+    path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
+    call_command(
+        'import_files',
+        path,
+        username='me',
+        async=True,
+        no_acoustid=True,
+        interactive=False)
+    batch = user.imports.latest('id')
+    job = batch.jobs.first()
+    m.assert_called_once_with(
+        music_tasks.import_job_run.delay,
+        import_job_id=job.pk,
+        use_acoustid=False)
+
+
+def test_storage_rename_utf_8_files(factories):
+    tf = factories['music.TrackFile'](audio_file__filename='été.ogg')
+    assert tf.audio_file.name.endswith('ete.ogg')
diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py
index 017d742ef834562f33425cbc84046fdf7d580bb4..441179095d98398697c60fb3fabc922dafbeab6e 100644
--- a/api/tests/test_youtube.py
+++ b/api/tests/test_youtube.py
@@ -17,13 +17,15 @@ def test_can_get_search_results_from_youtube(mocker):
     assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo'
 
 
-def test_can_get_search_results_from_funkwhale(mocker, client, db):
+def test_can_get_search_results_from_funkwhale(
+        settings, mocker, api_client, db):
+    settings.API_AUTHENTICATION_REQUIRED = False
     mocker.patch(
         'funkwhale_api.providers.youtube.client._do_search',
         return_value=api_data.search['8 bit adventure'])
     query = '8 bit adventure'
     url = reverse('api:v1:providers:youtube:search')
-    response = client.get(url, {'query': query})
+    response = api_client.get(url, {'query': query})
     # we should cast the youtube result to something more generic
     expected = {
         "id": "0HxZn6CzOIo",
@@ -37,7 +39,7 @@ def test_can_get_search_results_from_funkwhale(mocker, client, db):
         "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
     }
 
-    assert json.loads(response.content.decode('utf-8'))[0] == expected
+    assert response.data[0] == expected
 
 
 def test_can_send_multiple_queries_at_once(mocker):
@@ -67,7 +69,9 @@ def test_can_send_multiple_queries_at_once(mocker):
     assert results['2'][0]['full_url'] == 'https://www.youtube.com/watch?v=BorYwGi2SJc'
 
 
-def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client):
+def test_can_send_multiple_queries_at_once_from_funwkhale(
+        settings, mocker, db, api_client):
+    settings.API_AUTHENTICATION_REQUIRED = False
     mocker.patch(
         'funkwhale_api.providers.youtube.client._do_search',
         return_value=api_data.search['8 bit adventure'])
@@ -89,7 +93,6 @@ def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client):
     }
 
     url = reverse('api:v1:providers:youtube:searchs')
-    response = client.post(
-        url, json.dumps(queries), content_type='application/json')
+    response = api_client.post(url, queries, format='json')
 
-    assert expected == json.loads(response.content.decode('utf-8'))['1'][0]
+    assert expected == response.data['1'][0]
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 02b903bf41ea6bb9169987bd5ed1d147aff2122e..4be586965f8d5f02bac7cfc9d3c9b871e2d8fd31 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -23,6 +23,23 @@ def test_can_create_user_via_api(preferences, client, db):
     assert u.username == 'test1'
 
 
+def test_can_restrict_usernames(settings, preferences, db, client):
+    url = reverse('rest_register')
+    preferences['users__registration_enabled'] = True
+    settings.USERNAME_BLACKLIST = ['funkwhale']
+    data = {
+        'username': 'funkwhale',
+        'email': 'contact@funkwhale.io',
+        'password1': 'testtest',
+        'password2': 'testtest',
+    }
+
+    response = client.post(url, data)
+
+    assert response.status_code == 400
+    assert 'username' in response.data
+
+
 def test_can_disable_registration_view(preferences, client, db):
     url = reverse('rest_register')
     data = {
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index e1a381b945b88529107b9b3c7bea7224308dc498..a016b34c7eea37e0272fb8b08e5afcfd6a9c085f 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -4,12 +4,13 @@
 # - DJANGO_ALLOWED_HOSTS
 # - FUNKWHALE_URL
 
-# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
-# variables:
+# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables:
 # - DATABASE_URL
 # - CACHE_URL
 # - STATIC_ROOT
 # - MEDIA_ROOT
+#
+# You **don't** need to update those variables on pure docker setups.
 
 # Docker only
 # -----------
diff --git a/deploy/funkwhale_proxy.conf b/deploy/funkwhale_proxy.conf
index 1b1dd0d20e951455eb999e17aacc1c45e6fed669..312986f43a0bd2a15169eea427d9a2f54dd0e7fb 100644
--- a/deploy/funkwhale_proxy.conf
+++ b/deploy/funkwhale_proxy.conf
@@ -3,8 +3,8 @@ proxy_set_header Host $host;
 proxy_set_header X-Real-IP $remote_addr;
 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 proxy_set_header X-Forwarded-Proto $scheme;
-proxy_set_header X-Forwarded-Host   $host:$server_port;
-proxy_set_header X-Forwarded-Port   $server_port;
+proxy_set_header X-Forwarded-Host $host:$server_port;
+proxy_set_header X-Forwarded-Port $server_port;
 proxy_redirect off;
 
 # websocket support
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index 1c7b9ae8357d8222aba78e9c6dc34f45fbbb8ca4..1c304b4938892bf87ba50ab05f2394784b020248 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -62,6 +62,16 @@ server {
         proxy_pass   http://funkwhale-api/api/;
     }
 
+    location /federation/ {
+        include /etc/nginx/funkwhale_proxy.conf;
+        proxy_pass   http://funkwhale-api/federation/;
+    }
+
+    location /.well-known/webfinger {
+        include /etc/nginx/funkwhale_proxy.conf;
+        proxy_pass   http://funkwhale-api/.well-known/webfinger;
+    }
+
     location /media/ {
         alias /srv/funkwhale/data/media/;
     }
diff --git a/dev.yml b/dev.yml
index 8d2129bef978e78bc8c1a874ae19692d8ee62997..c0470a2ab6f6172127e90fa9d3911b0cae9b33db 100644
--- a/dev.yml
+++ b/dev.yml
@@ -1,27 +1,35 @@
-version: '2'
+version: '3'
 
 services:
-
   front:
     build: front
-    env_file: .env.dev
+    env_file:
+      - .env.dev
+      - .env
     environment:
       - "HOST=0.0.0.0"
+      - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
     ports:
-      - "8080:8080"
+      - "${WEBPACK_DEVSERVER_PORT-8080}:${WEBPACK_DEVSERVER_PORT-8080}"
     volumes:
       - './front:/app'
 
   postgres:
-    env_file: .env.dev
+    env_file:
+      - .env.dev
+      - .env
     image: postgres
 
   redis:
-    env_file: .env.dev
+    env_file:
+      - .env.dev
+      - .env
     image: redis:3.0
 
   celeryworker:
-    env_file: .env.dev
+    env_file:
+      - .env.dev
+      - .env
     build:
       context: ./api
       dockerfile: docker/Dockerfile.test
@@ -30,18 +38,15 @@ services:
      - redis
     command: celery -A funkwhale_api.taskapp worker -l debug
     environment:
-      - "DJANGO_ALLOWED_HOSTS=localhost"
-      - "DJANGO_SETTINGS_MODULE=config.settings.local"
-      - "DJANGO_SECRET_KEY=dev"
-      - C_FORCE_ROOT=true
       - "DATABASE_URL=postgresql://postgres@postgres/postgres"
       - "CACHE_URL=redis://redis:6379/0"
-      - "FUNKWHALE_URL=http://funkwhale.test"
     volumes:
       - ./api:/app
       - ./data/music:/music
   api:
-    env_file: .env.dev
+    env_file:
+      - .env.dev
+      - .env
     build:
       context: ./api
       dockerfile: docker/Dockerfile.test
@@ -50,24 +55,27 @@ services:
       - ./api:/app
       - ./data/music:/music
     environment:
-      - "DJANGO_ALLOWED_HOSTS=localhost,nginx"
-      - "DJANGO_SETTINGS_MODULE=config.settings.local"
-      - "DJANGO_SECRET_KEY=dev"
       - "DATABASE_URL=postgresql://postgres@postgres/postgres"
       - "CACHE_URL=redis://redis:6379/0"
-      - "FUNKWHALE_URL=http://funkwhale.test"
     links:
       - postgres
       - redis
 
   nginx:
-    env_file: .env.dev
+    command: /entrypoint.sh
+    env_file:
+      - .env.dev
+      - .env
     image: nginx
+    environment:
+      - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
     links:
       - api
       - front
     volumes:
       - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
+      - ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
+      - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
       - ./api/funkwhale_api/media:/protected/media
     ports:
       - "0.0.0.0:6001:6001"
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 9847c2dcbcc71bf3040802d6289c7336ff40c340..e832a5ae3ee5b4d96833bd1cf5bf23fee6d5033f 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -37,19 +37,7 @@ http {
         listen 6001;
         charset     utf-8;
         client_max_body_size 20M;
-
-        # global proxy pass config
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-        proxy_set_header X-Forwarded-Host   localhost:8080;
-        proxy_set_header X-Forwarded-Port   8080;
-        proxy_http_version 1.1;
-        proxy_set_header Upgrade $http_upgrade;
-        proxy_set_header Connection $connection_upgrade;
-        proxy_redirect off;
-
+        include /etc/nginx/funkwhale_proxy.conf;
         location /_protected/media {
             internal;
             alias   /protected/media;
@@ -63,8 +51,7 @@ http {
             if ($request_uri ~* "[^\?]+\?(.*)$") {
                 set $query $1;
             }
-            proxy_set_header X-Forwarded-Host   localhost:8080;
-            proxy_set_header X-Forwarded-Port   8080;
+            include /etc/nginx/funkwhale_proxy.conf;
             proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
             proxy_pass_request_body off;
             proxy_set_header        Content-Length "";
@@ -78,6 +65,7 @@ http {
             if ($args ~ (.*)jwt=[^&]*(.*)) {
                 set $cleaned_args $1$2;
             }
+            include /etc/nginx/funkwhale_proxy.conf;
             proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args";
             proxy_cache transcode;
             proxy_cache_valid 200 7d;
@@ -87,6 +75,7 @@ http {
             proxy_pass http://api:12081;
         }
         location / {
+            include /etc/nginx/funkwhale_proxy.conf;
             proxy_pass   http://api:12081/;
         }
     }
diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh
new file mode 100755
index 0000000000000000000000000000000000000000..1819acf1cf579cb3e0d70b754d06f2b6fc4959c6
--- /dev/null
+++ b/docker/nginx/entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/bash -eux
+FIRST_HOST=$(echo ${DJANGO_ALLOWED_HOSTS} | cut -d, -f1)
+echo "Copying template file..."
+cp /etc/nginx/funkwhale_proxy.conf{.template,}
+sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FIRST_HOST}:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
+sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FIRST_HOST}/" /etc/nginx/funkwhale_proxy.conf
+sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
+
+cat /etc/nginx/funkwhale_proxy.conf
+nginx -g "daemon off;"
diff --git a/docs/index.rst b/docs/index.rst
index 17e9fe7f0b699cc427c71cac4597708b5a311e31..f5acecce516ee8d6b635c91e3aa0fed2686f5739 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -15,6 +15,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
    installation/index
    configuration
    importing-music
+   upgrading
    changelog
 
 Indices and tables
diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst
index 86ccb4dd3ee6df1678f758d0e8e7834bb16a4e3b..c4e54218d53bf85fc4aa647e0516be86956df319 100644
--- a/docs/installation/debian.rst
+++ b/docs/installation/debian.rst
@@ -89,7 +89,7 @@ First, we'll download the latest api release.
 
     curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/|version|/download?job=build_api"
     unzip "api-|version|.zip" -d extracted
-    mv extracted/api api
+    mv extracted/api/* api/
     rmdir extracted
 
 
@@ -100,7 +100,7 @@ Then we'll download the frontend files:
     curl -L -o "front-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/|version|/download?job=build_front"
     unzip "front-|version|.zip" -d extracted
     mv extracted/front .
-    rmdir extracted
+    rm -rf extracted
 
 You can leave the ZIP archives in the directory, this will help you know
 which version you've installed next time you want to upgrade your installation.
diff --git a/docs/installation/docker.rst b/docs/installation/docker.rst
index 34e8187c58dc2e104392badf862dec1dcadcad43..dc031caed91b8c169ff0bd68827865c432f3504d 100644
--- a/docs/installation/docker.rst
+++ b/docs/installation/docker.rst
@@ -17,7 +17,9 @@ Create your env file:
 
 .. parsed-literal::
 
+    export FUNKWHALE_VERSION="|version|"
     curl -L -o .env "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/env.prod.sample"
+    sed -i "s/FUNKWHALE_VERSION=latest/FUNKWHALE_VERSION=$FUNKWHALE_VERSION/" .env
 
 Ensure to edit it to match your needs (this file is heavily commented)
 
diff --git a/docs/upgrading.rst b/docs/upgrading.rst
new file mode 100644
index 0000000000000000000000000000000000000000..674878ba7c4d8f4dd41f64f0dbda615ea515ba2d
--- /dev/null
+++ b/docs/upgrading.rst
@@ -0,0 +1,94 @@
+Upgrading your funkwhale instance to a newer version
+====================================================
+
+.. note::
+
+    Before upgrading your instance, we strongly advise you to make at least a database backup. Ideally, you should make a full backup, including
+    the database and the media files.
+
+    We're commited to make upgrade as easy and straightforward as possible,
+    however, funkwhale is still in development and you'll be safer with a backup.
+
+
+Reading the release notes
+-------------------------
+
+Please take a few minutes to read the :doc:`changelog`: updates should work
+similarly from version to version, but some of them may require additional steps.
+Those steps would be described in the version release notes.
+
+Upgrade the static files
+------------------------
+
+Regardless of your deployment choice (docker/non-docker) the front-end app
+is updated separately from the API. This is as simple as downloading
+the zip with the static files and extracting it in the correct place.
+
+The following example assume your setup match :ref:`frontend-setup`.
+
+.. parsed-literal::
+
+    # this assumes you want to upgrade to version "|version|"
+    export FUNKWHALE_VERSION="|version|"
+    cd /srv/funkwhale
+    curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
+    unzip -o front.zip
+    rm front.zip
+
+Upgrading the API
+-----------------
+
+Docker setup
+^^^^^^^^^^^^
+
+If you've followed the setup instructions in :doc:`Docker`, upgrade path is
+easy:
+
+.. parsed-literal::
+
+    cd /srv/funkwhale
+    # hardcode the targeted version your env file
+    # (look for the FUNKWHALE_VERSION variable)
+    nano .env
+    # Pull the new version containers
+    docker-compose pull
+    # Apply the database migrations
+    docker-compose run --rm api python manage.py migrate
+    # Relaunch the containers
+    docker-compose up -d
+
+Non-docker setup
+^^^^^^^^^^^^^^^^
+
+On non docker-setup, upgrade involves a few more commands. We assume your setup
+match what is described in :doc:`debian`:
+
+.. parsed-literal::
+
+    # stop the services
+    sudo systemctl stop funkwhale.target
+
+    # this assumes you want to upgrade to version "|version|"
+    export FUNKWALE_VERSION="|version|"
+    cd /srv/funkwhale
+
+    # download more recent API files
+    curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWALE_VERSION/download?job=build_api"
+    unzip "api-$FUNKWALE_VERSION.zip" -d extracted
+    rm -rf api/ && mv extracted/api .
+    rm -rf extracted
+
+    # update os dependencies
+    sudo api/install_os_dependencies.sh install
+    # update python dependencies
+    source /srv/funkwhale/load_env
+    source /srv/funkwhale/virtualenv/bin/activate
+    pip install -r api/requirements.txt
+
+    # apply database migrations
+    python api/manage.py migrate
+    # collect static files
+    python api/manage.py collectstatic --no-input
+
+    # restart the services
+    sudo systemctl restart funkwhale.target
diff --git a/front/config/index.js b/front/config/index.js
index 14cbe3e4388ea6a5e18a6053a1611ffd0cd6ab38..669ce54f37dbfe912cf61dda75b7ccb6366c0824 100644
--- a/front/config/index.js
+++ b/front/config/index.js
@@ -23,25 +23,37 @@ module.exports = {
   },
   dev: {
     env: require('./dev.env'),
-    port: 8080,
+    port: parseInt(process.env.WEBPACK_DEVSERVER_PORT),
     host: '127.0.0.1',
     autoOpenBrowser: true,
     assetsSubDirectory: 'static',
     assetsPublicPath: '/',
     proxyTable: {
-      '/api': {
+      '**': {
         target: 'http://nginx:6001',
         changeOrigin: true,
-        ws: true
+        ws: true,
+        filter: function (pathname, req) {
+          let proxified = ['.well-known', 'staticfiles', 'media', 'federation', 'api']
+          let matches = proxified.filter(e => {
+            return pathname.match(`^/${e}`)
+          })
+          return matches.length > 0
+        }
       },
-      '/media': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-      },
-      '/staticfiles': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-      }
+      // '/.well-known': {
+      //   target: 'http://nginx:6001',
+      //   changeOrigin: true
+      // },
+      // '/media': {
+      //   target: 'http://nginx:6001',
+      //   changeOrigin: true,
+      // },
+      // '/staticfiles': {
+      //   target: 'http://nginx:6001',
+      //   changeOrigin: true,
+      // },
+
     },
     // CSS Sourcemaps off by default because relative paths are "buggy"
     // with this option, according to the CSS-Loader README
diff --git a/front/package.json b/front/package.json
index 201694e43648e08c6bd23b2fa869ca83c29c7e2d..d67375f7e5851f7cf2af379ac298a6f85f973f78 100644
--- a/front/package.json
+++ b/front/package.json
@@ -41,7 +41,7 @@
     "autoprefixer": "^6.7.2",
     "babel-core": "^6.22.1",
     "babel-eslint": "^7.1.1",
-    "babel-loader": "^6.2.10",
+    "babel-loader": "7",
     "babel-plugin-istanbul": "^4.1.1",
     "babel-plugin-transform-runtime": "^6.22.0",
     "babel-preset-env": "^1.3.2",
@@ -101,7 +101,7 @@
     "vue-loader": "^12.1.0",
     "vue-style-loader": "^3.0.1",
     "vue-template-compiler": "^2.3.3",
-    "webpack": "^2.6.1",
+    "webpack": "3",
     "webpack-bundle-analyzer": "^2.2.1",
     "webpack-dev-middleware": "^1.10.0",
     "webpack-hot-middleware": "^2.18.0",
diff --git a/front/src/App.vue b/front/src/App.vue
index d15eebdba69db25580d2d86a055819439a7c113a..e8ab18694ae4c44a5c6995d263b194dffe598dc2 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -21,6 +21,7 @@
           <div class="ten wide column">
             <h4 class="ui header">About funkwhale</h4>
             <p>Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!</p>
+            <p>The funkwhale logo was kindly designed and provided by Francis Gading.</p>
           </div>
         </div>
       </div>
@@ -35,9 +36,6 @@
 </template>
 
 <script>
-import { WebSocketBridge } from 'django-channels'
-
-import logger from '@/logging'
 import Sidebar from '@/components/Sidebar'
 import Raven from '@/components/Raven'
 
@@ -52,34 +50,11 @@ export default {
   },
   created () {
     this.$store.dispatch('instance/fetchSettings')
-    this.openWebsocket()
     let self = this
     setInterval(() => {
       // used to redraw ago dates every minute
       self.$store.commit('ui/computeLastDate')
     }, 1000 * 60)
-  },
-  methods: {
-    openWebsocket () {
-      if (!this.$store.state.auth.authenticated) {
-        return
-      }
-      let self = this
-      let token = this.$store.state.auth.token
-      // let token = 'test'
-      const bridge = new WebSocketBridge()
-      bridge.connect(
-        `/api/v1/instance/activity?token=${token}`,
-        null,
-        {reconnectInterval: 5000})
-      bridge.listen(function (event) {
-        logger.default.info('Received timeline update', event)
-        self.$store.commit('instance/event', event)
-      })
-      bridge.socket.addEventListener('open', function () {
-        console.log('Connected to WebSocket')
-      })
-    }
   }
 }
 </script>
diff --git a/front/src/components/About.vue b/front/src/components/About.vue
index 92bafd7afddde359aa7ffef0b7a2fb1e4b531e29..09a5ee24c523f8f8f5db77b47b7c6694ae74387b 100644
--- a/front/src/components/About.vue
+++ b/front/src/components/About.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'About This Instance'">
     <div class="ui vertical center aligned stripe segment">
       <div class="ui text container">
         <h1 class="ui huge header">
diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue
index ad1ad93cb08c2a29cff5b84859b769345dee9872..0e24dcd59008c52fe198e6a1778d9d1e0c00545c 100644
--- a/front/src/components/Home.vue
+++ b/front/src/components/Home.vue
@@ -1,9 +1,9 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Welcome'">
     <div class="ui vertical center aligned stripe segment">
       <div class="ui text container">
         <h1 class="ui huge header">
-            Welcome on funkwhale
+            Welcome on Funkwhale
         </h1>
         <p>We think listening music should be simple.</p>
         <router-link class="ui icon button" to="/about">
@@ -143,9 +143,7 @@
 export default {
   name: 'home',
   data () {
-    return {
-      msg: 'Welcome to Your Vue.js App'
-    }
+    return {}
   }
 }
 </script>
diff --git a/front/src/components/PageNotFound.vue b/front/src/components/PageNotFound.vue
index 3b88a6921f05d69aa49a5c7a965e509c3725249a..25e6f86fd209a7cb6132c9c36812e918c1f116f9 100644
--- a/front/src/components/PageNotFound.vue
+++ b/front/src/components/PageNotFound.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Page Not Found'">
     <div class="ui vertical stripe segment">
       <div class="ui text container">
         <h1 class="ui huge header">
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index f225313b6b5f64139ed668473a6ebea51bc58eec..42a923b6b3faaf5586a35613277bbcdd7ea8c27d 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -16,8 +16,8 @@
 
   <div class="menu-area">
     <div class="ui compact fluid two item inverted menu">
-      <a class="active item" data-tab="library">Browse</a>
-      <a class="item" data-tab="queue">
+      <a class="active item" @click="selectedTab = 'library'" data-tab="library">Browse</a>
+      <a class="item" @click="selectedTab = 'queue'" data-tab="queue">
         Queue &nbsp;
          <template v-if="queue.tracks.length === 0">
            (empty)
@@ -46,6 +46,8 @@
           v-if="$store.state.auth.authenticated"
           class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
       </div>
+
+      <player></player>
     </div>
     <div v-if="queue.previousQueue " class="ui black icon message">
       <i class="history icon"></i>
@@ -96,7 +98,6 @@
       </div>
     </div>
   </div>
-  <player></player>
 </div>
 </template>
 
@@ -121,6 +122,7 @@ export default {
   },
   data () {
     return {
+      selectedTab: 'library',
       backend: backend,
       isCollapsed: true
     }
@@ -140,11 +142,36 @@ export default {
     }),
     reorder: function (oldValue, newValue) {
       this.$store.commit('queue/reorder', {oldValue, newValue})
+    },
+    scrollToCurrent () {
+      let current = $(this.$el).find('[data-tab="queue"] .active')[0]
+      if (!current) {
+        return
+      }
+      let container = $(this.$el).find('.tabs')[0]
+      // Position container at the top line then scroll current into view
+      container.scrollTop = 0
+      current.scrollIntoView(true)
+      // Scroll back nothing if element is at bottom of container else do it
+      // for half the height of the containers display area
+      var scrollBack = (container.scrollHeight - container.scrollTop <= container.clientHeight) ? 0 : container.clientHeight / 2
+      container.scrollTop = container.scrollTop - scrollBack
+      console.log(container.scrollHeight - container.scrollTop, container.clientHeight)
     }
   },
   watch: {
     url: function () {
       this.isCollapsed = true
+    },
+    selectedTab: function (newValue) {
+      if (newValue === 'queue') {
+        this.scrollToCurrent()
+      }
+    },
+    '$store.state.queue.currentIndex': function () {
+      if (this.selectedTab !== 'queue') {
+        this.scrollToCurrent()
+      }
     }
   }
 }
@@ -204,19 +231,38 @@ $sidebar-color: #3D3E3F;
   }
 }
 .tabs {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
   overflow-y: auto;
+  justify-content: space-between;
   @include media(">tablet") {
     height: 0px;
   }
   @include media("<desktop") {
-    max-height: 400px;
+    max-height: 500px;
   }
 }
+.ui.tab.active {
+  display: flex;
+}
 .tab[data-tab="queue"] {
+  flex-direction: column;
   tr {
     cursor: pointer;
   }
 }
+.tab[data-tab="library"] {
+  flex-direction: column;
+  flex: 1 1 auto;
+  > .menu {
+    flex: 1;
+    flex-grow: 1;
+  }
+  > .player-wrapper {
+    width: 100%;
+  }
+}
 .sidebar .segment {
   margin: 0;
   border-radius: 0;
@@ -225,9 +271,6 @@ $sidebar-color: #3D3E3F;
 .ui.inverted.segment.header-wrapper {
   padding: 0;
 }
-.tabs {
-  flex: 1;
-}
 
 .logo {
   cursor: pointer;
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 75a01c52e015b419d4c919b90bfe97e7db8ab02b..31f6dc35ac3478a9b3c84b15a76ea9adcc044882 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -59,8 +59,8 @@
         <div
           title="Previous track"
           class="two wide column control"
-          :disabled="!hasPrevious">
-            <i @click="previous" :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i>
+          :disabled="emptyQueue">
+            <i @click="previous" :class="['ui', 'backward', {'disabled': emptyQueue}, 'big', 'icon']"></i>
         </div>
         <div
           v-if="!playing"
@@ -205,7 +205,7 @@ export default {
     ...mapGetters({
       currentTrack: 'queue/currentTrack',
       hasNext: 'queue/hasNext',
-      hasPrevious: 'queue/hasPrevious',
+      emptyQueue: 'queue/isEmpty',
       durationFormatted: 'player/durationFormatted',
       currentTimeFormatted: 'player/currentTimeFormatted',
       progress: 'player/progress'
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
index 370d8ae2d3a6c5a4076c5220f5e6d0f1f7f80e66..68dd34459870f432d7ca5c9e3b35a6d7966b225d 100644
--- a/front/src/components/audio/Track.vue
+++ b/front/src/components/audio/Track.vue
@@ -31,7 +31,8 @@ export default {
   },
   data () {
     return {
-      sourceErrors: 0
+      sourceErrors: 0,
+      isUpdatingTime: false
     }
   },
   computed: {
@@ -99,6 +100,7 @@ export default {
       }
     },
     updateProgress: _.throttle(function () {
+      this.isUpdatingTime = true
       if (this.$refs.audio) {
         this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime)
       }
@@ -130,6 +132,12 @@ export default {
     },
     volume: function (newValue) {
       this.$refs.audio.volume = newValue
+    },
+    currentTime (newValue) {
+      if (!this.isUpdatingTime) {
+        this.setCurrentTime(newValue)
+      }
+      this.isUpdatingTime = false
     }
   }
 }
diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue
index 2cf6d5f6db1cd8bc4e4d72d96be5c70d559e9452..d232da2a35e38d4f774072fb049473ac5cfa1e86 100644
--- a/front/src/components/auth/Login.vue
+++ b/front/src/components/auth/Login.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Log In'">
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
         <h2>Log in to your Funkwhale account</h2>
diff --git a/front/src/components/auth/Logout.vue b/front/src/components/auth/Logout.vue
index fbacca70338ed295dc284664d443c9b239b25da0..980740c94cfc415e5afc467186c14d1a193748cc 100644
--- a/front/src/components/auth/Logout.vue
+++ b/front/src/components/auth/Logout.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Log Out'">
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
         <h2>Are you sure you want to log out?</h2>
diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue
index 54af5a11c4c0027fc68f81e9c6eca283d4ed8aee..9fafccffa83f0e9b63ac6a692de586a4adf9e4f8 100644
--- a/front/src/components/auth/Profile.vue
+++ b/front/src/components/auth/Profile.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="username + '\'s Profile'">
     <div v-if="isLoading" class="ui vertical segment">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index 8cd03d75580aa52f4e7e382a5a6ec0278cb3db92..859c0d04b97ccecb97911575ad505cdda475ebf5 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Account Settings'">
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
         <h2 class="ui header">Account settings</h2>
diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue
index 749d2eb0254a5d55b163532afff252302b4d50e6..39ca366631367afe452e3dddcaf54f56ad5f511a 100644
--- a/front/src/components/auth/Signup.vue
+++ b/front/src/components/auth/Signup.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Sign Up'">
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
         <h2>Create a funkwhale account</h2>
diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue
index c65144a93bf9c6ae1c43f0148f2df75ee4be9105..dbef186b6b5c7d8a6aec611b798ab22a41f03362 100644
--- a/front/src/components/favorites/List.vue
+++ b/front/src/components/favorites/List.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Your Favorites'">
     <div class="ui vertical center aligned stripe segment">
       <div :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
         <div class="ui text loader">Loading your favorites...</div>
diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue
index 65768aafe7851868b35030e2b6273dfbcfefc3b7..1e96757c342a9b325cb293f8363ea2078f358aff 100644
--- a/front/src/components/library/Album.vue
+++ b/front/src/components/library/Album.vue
@@ -1,10 +1,10 @@
 <template>
   <div>
-    <div v-if="isLoading" class="ui vertical segment">
+    <div v-if="isLoading" class="ui vertical segment" v-title="'Album'">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
     <template v-if="album">
-      <div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
+      <div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title">
         <div class="segment-content">
           <h2 class="ui center aligned icon header">
             <i class="circular inverted sound yellow icon"></i>
diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue
index 9a546aa0e9fa98aa4bdda13fcde57fd6ad4b2986..07d8cbbc74cff682e2251eb0c22515ca0fc8a9c0 100644
--- a/front/src/components/library/Artist.vue
+++ b/front/src/components/library/Artist.vue
@@ -1,10 +1,10 @@
 <template>
   <div>
-    <div v-if="isLoading" class="ui vertical segment">
+    <div v-if="isLoading" class="ui vertical segment" v-title="'Artist'">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
     <template v-if="artist">
-      <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
+      <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="artist.name">
         <div class="segment-content">
           <h2 class="ui center aligned icon header">
             <i class="circular inverted users violet icon"></i>
diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue
index 52ccbdd7465c654b28eb53c141f50a6e62ca0209..72ad108f1d2d78ce32357753d163045284263715 100644
--- a/front/src/components/library/Artists.vue
+++ b/front/src/components/library/Artists.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Artists'">
     <div class="ui vertical stripe segment">
       <h2 class="ui header">Browsing artists</h2>
       <div :class="['ui', {'loading': isLoading}, 'form']">
diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue
index 40f6808f98f3c382353fd9bb205d3c09cbbcb8bd..26352d1fd6c0314129dc7e212d7ddb7d97dd4519 100644
--- a/front/src/components/library/Home.vue
+++ b/front/src/components/library/Home.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Home'">
     <div class="ui vertical stripe segment">
       <search :autofocus="true"></search>
     </div>
diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue
index 303ce100ecf35ec46be8d6909b03398dbe125826..7ab14ab14f8d3dbc4fa02a52c34758d69b38b5e9 100644
--- a/front/src/components/library/Radios.vue
+++ b/front/src/components/library/Radios.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Radios'">
     <div class="ui vertical stripe segment">
       <h2 class="ui header">Browsing radios</h2>
       <router-link class="ui green basic button" to="/library/radios/build" exact>Create your own radio</router-link>
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index 0437ac88151ad166ea6704579b8069adf35007f2..e614635c9877ca315743d16cf0d4ebba47679597 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -1,10 +1,10 @@
 <template>
   <div>
-    <div v-if="isLoadingTrack" class="ui vertical segment">
+    <div v-if="isLoadingTrack" class="ui vertical segment" v-title="'Track'">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
     <template v-if="track">
-      <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
+      <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="track.title">
         <div class="segment-content">
           <h2 class="ui center aligned icon header">
             <i class="circular inverted music orange icon"></i>
diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue
index 0864d2464abf9a769e9c15d1d6d32b3ebf21f219..362a5acd17972e6b26bc3deca8ae5881b2865e79 100644
--- a/front/src/components/library/import/BatchDetail.vue
+++ b/front/src/components/library/import/BatchDetail.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Import Batch #' + id">
     <div v-if="isLoading && !batch" class="ui vertical segment">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue
index de4fef554c5c4699ec1d501dbcbc471dc28d2379..857fef0457256aa7e7ea12f7d355bab1a36b32fe 100644
--- a/front/src/components/library/import/BatchList.vue
+++ b/front/src/components/library/import/BatchList.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Import Batches'">
     <div class="ui vertical stripe segment">
       <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
       <button
diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue
index 66e16b71f467163cfb9313eaf5f6fa542cec4216..e2b6e484912d803a0494590afa04e6e8931401b8 100644
--- a/front/src/components/library/import/Main.vue
+++ b/front/src/components/library/import/Main.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Import Music'">
     <div class="ui vertical stripe segment">
       <div class="ui top three attached ordered steps">
         <a @click="currentStep = 0" :class="['step', {'active': currentStep === 0}, {'completed': currentStep > 0}]">
diff --git a/front/src/components/library/radios/Builder.vue b/front/src/components/library/radios/Builder.vue
index 8d67b61e18b5f21d67e360c3c4fe05def2e52fcc..5fbf0c992618616e0c0be0f76e458deb997737b8 100644
--- a/front/src/components/library/radios/Builder.vue
+++ b/front/src/components/library/radios/Builder.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="ui vertical stripe segment">
+  <div class="ui vertical stripe segment" v-title="'Radio Builder'">
     <div>
       <div>
         <h2 class="ui header">Builder</h2>
@@ -180,7 +180,7 @@ export default {
         let url = 'radios/radios/'
         axios.post(url, final).then((response) => {
           self.$router.push({
-            name: 'library.radios.edit',
+            name: 'library.radios.detail',
             params: {
               id: response.data.id
             }
diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue
index d2c14c37c78dfbc23b858c93a5eddd28b3519fb9..17de3c85fe3c67d5a124d046aa9e5fa233503396 100644
--- a/front/src/components/radios/Card.vue
+++ b/front/src/components/radios/Card.vue
@@ -1,7 +1,11 @@
 <template>
     <div class="ui card">
       <div class="content">
-        <div class="header">{{ radio.name }}</div>
+        <div class="header">
+          <router-link class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}">
+            {{ radio.name }}
+          </router-link>
+        </div>
         <div class="description">
           {{ radio.description }}
         </div>
diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue
index 33ba04f53c61a47e59fcd991185d3961ad12e693..5d4db243acce765d72e64c61e3771b54bf17e452 100644
--- a/front/src/components/requests/RequestsList.vue
+++ b/front/src/components/requests/RequestsList.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Import Requests'">
     <div class="ui vertical stripe segment">
       <h2 class="ui header">Music requests</h2>
       <div :class="['ui', {'loading': isLoading}, 'form']">
diff --git a/front/src/main.js b/front/src/main.js
index f20af42bfa7ed7ab3850cc8ebf76d6e4ae0916e7..5030e5c9c429d62eda3d6c20e86acc1d4e19a3c2 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -30,6 +30,10 @@ require('masonry-layout')
 Vue.use(VueMasonryPlugin)
 Vue.use(VueLazyload)
 Vue.config.productionTip = false
+Vue.directive('title', {
+  inserted: (el, binding) => { console.log(binding.value); document.title = binding.value + ' - Funkwhale' },
+  updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' }
+})
 
 axios.defaults.baseURL = config.API_URL
 axios.interceptors.request.use(function (config) {
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 802844461325560a48cf8002f21ef8e3f5462594..d41764227bb1a1cbd3536791ae24275b7228d249 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -18,6 +18,7 @@ import LibraryTrack from '@/components/library/Track'
 import LibraryImport from '@/components/library/import/Main'
 import LibraryRadios from '@/components/library/Radios'
 import RadioBuilder from '@/components/library/radios/Builder'
+import RadioDetail from '@/views/radios/Detail'
 import BatchList from '@/components/library/import/BatchList'
 import BatchDetail from '@/components/library/import/BatchDetail'
 import RequestsList from '@/components/requests/RequestsList'
@@ -111,6 +112,7 @@ export default new Router({
         },
         { path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true },
         { path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true },
+        { path: 'radios/:id', name: 'library.radios.detail', component: RadioDetail, props: true },
         {
           path: 'playlists/',
           name: 'library.playlists.browse',
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index 2436eab079cd72f11fe48ecf2f64e857bc4f1e58..245acaf039adb4cc92e5df0760c3b38733e6b241 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -43,6 +43,9 @@ export default {
       if (state.events.length > state.maxEvents) {
         state.events = state.events.slice(0, state.maxEvents)
       }
+    },
+    events: (state, value) => {
+      state.events = value
     }
   },
   actions: {
diff --git a/front/src/store/queue.js b/front/src/store/queue.js
index 07263da63315deb04fb7d819037bbd04ff669931..6a26fa1e9a0dc70609b0ea6fcba6e5b11745f277 100644
--- a/front/src/store/queue.js
+++ b/front/src/store/queue.js
@@ -41,7 +41,6 @@ export default {
         state.currentIndex += 1
       }
     }
-
   },
   getters: {
     currentTrack: state => {
@@ -50,9 +49,7 @@ export default {
     hasNext: state => {
       return state.currentIndex < state.tracks.length - 1
     },
-    hasPrevious: state => {
-      return state.currentIndex > 0
-    }
+    isEmpty: state => state.tracks.length === 0
   },
   actions: {
     append ({commit, state, dispatch}, {track, index, skipPlay}) {
@@ -104,9 +101,11 @@ export default {
         dispatch('next')
       }
     },
-    previous ({state, dispatch}) {
-      if (state.currentIndex > 0) {
+    previous ({state, dispatch, rootState}) {
+      if (state.currentIndex > 0 && rootState.player.currentTime < 3) {
         dispatch('currentIndex', state.currentIndex - 1)
+      } else {
+        dispatch('currentIndex', state.currentIndex)
       }
     },
     next ({state, dispatch, commit, rootState}) {
@@ -141,7 +140,9 @@ export default {
       commit('ended', true)
     },
     shuffle ({dispatch, commit, state}) {
-      let shuffled = _.shuffle(state.tracks)
+      let toKeep = state.tracks.slice(0, state.currentIndex + 1)
+      let toShuffle = state.tracks.slice(state.currentIndex + 1)
+      let shuffled = toKeep.concat(_.shuffle(toShuffle))
       commit('player/currentTime', 0, {root: true})
       commit('tracks', [])
       dispatch('appendMany', {tracks: shuffled})
diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue
index b959c25d66d397fc24c3c4b9d2ab62f26cdfb9fb..f4ddedfb72e287235081cc8d86c5f5a388197c79 100644
--- a/front/src/views/instance/Timeline.vue
+++ b/front/src/views/instance/Timeline.vue
@@ -1,7 +1,10 @@
 <template>
-  <div class="main pusher">
+  <div class="main pusher" v-title="'Instance Timeline'">
     <div class="ui vertical center aligned stripe segment">
-      <div class="ui text container">
+      <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
+        <div class="ui text loader">Loading timeline...</div>
+      </div>
+      <div v-else class="ui text container">
         <h1 class="ui header">Recent activity on this instance</h1>
         <div class="ui feed">
           <component
@@ -26,6 +29,9 @@
 
 <script>
 import {mapState} from 'vuex'
+import { WebSocketBridge } from 'django-channels'
+import axios from 'axios'
+import logger from '@/logging'
 
 import Like from '@/components/activity/Like'
 import Listen from '@/components/activity/Listen'
@@ -33,16 +39,51 @@ import Listen from '@/components/activity/Listen'
 export default {
   data () {
     return {
+      isLoading: false,
       components: {
         'Like': Like,
         'Listen': Listen
       }
     }
   },
+  created () {
+    this.openWebsocket()
+    this.fetchEvents()
+  },
   computed: {
     ...mapState({
       events: state => state.instance.events
     })
+  },
+  methods: {
+    fetchEvents () {
+      this.isLoading = true
+      let self = this
+      axios.get('/activity/').then((response) => {
+        self.isLoading = false
+        self.$store.commit('instance/events', response.data.results)
+      })
+    },
+    openWebsocket () {
+      if (!this.$store.state.auth.authenticated) {
+        return
+      }
+      let self = this
+      let token = this.$store.state.auth.token
+      // let token = 'test'
+      const bridge = new WebSocketBridge()
+      bridge.connect(
+        `/api/v1/instance/activity?token=${token}`,
+        null,
+        {reconnectInterval: 5000})
+      bridge.listen(function (event) {
+        logger.default.info('Received timeline update', event)
+        self.$store.commit('instance/event', event)
+      })
+      bridge.socket.addEventListener('open', function () {
+        console.log('Connected to WebSocket')
+      })
+    }
   }
 }
 </script>
diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue
index 6c3a988fd195ae3df5d4e597a69f4bb3dee9c40b..2769317e6440f24c034f21d5db68bbd7fd52524b 100644
--- a/front/src/views/playlists/Detail.vue
+++ b/front/src/views/playlists/Detail.vue
@@ -1,9 +1,9 @@
 <template>
   <div>
-    <div v-if="isLoading" class="ui vertical segment">
+    <div v-if="isLoading" class="ui vertical segment" v-title="'Playlist'">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
-    <div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment">
+    <div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment" v-title="playlist.name">
       <div class="segment-content">
         <h2 class="ui center aligned icon header">
           <i class="circular inverted list yellow icon"></i>
diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue
index fc5dcbe54b441c4ee2009ba4ebe7881173ab108a..96aa36c4779bc11a6050208cf497ac861a7dabcd 100644
--- a/front/src/views/playlists/List.vue
+++ b/front/src/views/playlists/List.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div v-title="'Playlists'">
     <div class="ui vertical stripe segment">
       <h2 class="ui header">Browsing playlists</h2>
       <div :class="['ui', {'loading': isLoading}, 'form']">
diff --git a/front/src/views/radios/Detail.vue b/front/src/views/radios/Detail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..397dcb49ec3fbd7e05fa45007f38418712a2d3b7
--- /dev/null
+++ b/front/src/views/radios/Detail.vue
@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <div v-if="isLoading" class="ui vertical segment" v-title="'Radio'">
+      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+    </div>
+    <div v-if="!isLoading && radio" class="ui head vertical center aligned stripe segment" v-title="radio.name">
+      <div class="segment-content">
+        <h2 class="ui center aligned icon header">
+          <i class="circular inverted feed blue icon"></i>
+          <div class="content">
+            {{ radio.name }}
+            <div class="sub header">
+              Radio containing {{ tracks.length }} tracks,
+              by <username :username="radio.user.username"></username>
+            </div>
+          </div>
+        </h2>
+        <div class="ui hidden divider"></div>
+        <radio-button type="custom" :custom-radio-id="radio.id"></radio-button>
+        <router-link class="ui icon button" :to="{name: 'library.radios.edit', params: {id: radio.id}}" exact>
+          <i class="pencil icon"></i>
+          Edit…
+        </router-link>
+        <dangerous-button class="labeled icon" :action="deleteRadio">
+          <i class="trash icon"></i> Delete
+          <p slot="modal-header">Do you want to delete the radio "{{ radio.name }}"?</p>
+          <p slot="modal-content">This will completely delete this radio and cannot be undone.</p>
+          <p slot="modal-confirm">Delete radio</p>
+        </dangerous-button>
+      </div>
+    </div>
+    <div class="ui vertical stripe segment">
+      <h2>Tracks</h2>
+      <track-table :tracks="tracks"></track-table>
+      <div class="ui center aligned basic segment">
+        <pagination
+          v-if="totalTracks > 25"
+          @page-changed="selectPage"
+          :current="page"
+          :paginate-by="25"
+          :total="totalTracks"
+          ></pagination>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import TrackTable from '@/components/audio/track/Table'
+import RadioButton from '@/components/radios/Button'
+import Pagination from '@/components/Pagination'
+
+export default {
+  props: {
+    id: {required: true}
+  },
+  components: {
+    TrackTable,
+    RadioButton,
+    Pagination
+  },
+  data: function () {
+    return {
+      isLoading: false,
+      radio: null,
+      tracks: [],
+      totalTracks: 0,
+      page: 1
+    }
+  },
+  created: function () {
+    this.fetch()
+  },
+  methods: {
+    selectPage: function (page) {
+      this.page = page
+    },
+    fetch: function () {
+      let self = this
+      self.isLoading = true
+      let url = 'radios/radios/' + this.id + '/'
+      axios.get(url).then((response) => {
+        self.radio = response.data
+        axios.get(url + 'tracks', {params: {page: this.page}}).then((response) => {
+          console.log(response.data.count)
+          this.totalTracks = response.data.count
+          this.tracks = response.data.results
+        }).then(() => {
+          self.isLoading = false
+        })
+      })
+    },
+    deleteRadio () {
+      let self = this
+      let url = 'radios/radios/' + this.id + '/'
+      axios.delete(url).then((response) => {
+        self.$router.push({
+          path: '/library'
+        })
+      })
+    }
+  },
+  watch: {
+    page: function () {
+      this.fetch()
+    }
+  }
+}
+</script>
diff --git a/front/static/favicon.png b/front/static/favicon.png
index 089442fab7cb4e68cec9de87d421b64495e3263a..0220a4202cf807816c57b0433bed5bc97fa3f875 100644
Binary files a/front/static/favicon.png and b/front/static/favicon.png differ
diff --git a/front/test/unit/specs/store/queue.spec.js b/front/test/unit/specs/store/queue.spec.js
index 5439362dc389e1731e65e038a58c247974a816b8..2bc5cde4efec16a0cfdcf40ce36b1385780b93a1 100644
--- a/front/test/unit/specs/store/queue.spec.js
+++ b/front/test/unit/specs/store/queue.spec.js
@@ -81,14 +81,6 @@ describe('store/queue', () => {
       const state = { tracks: [1, 2, 3], currentIndex: 2 }
       expect(store.getters['hasNext'](state)).to.equal(false)
     })
-    it('hasPrevious true', () => {
-      const state = { currentIndex: 1 }
-      expect(store.getters['hasPrevious'](state)).to.equal(true)
-    })
-    it('hasPrevious false', () => {
-      const state = { currentIndex: 0 }
-      expect(store.getters['hasPrevious'](state)).to.equal(false)
-    })
   })
   describe('actions', () => {
     it('append at end', (done) => {
@@ -212,22 +204,33 @@ describe('store/queue', () => {
         expectedActions: []
       }, done)
     })
-    it('previous when at beginning does nothing', (done) => {
+    it('previous when at beginning', (done) => {
       testAction({
         action: store.actions.previous,
         params: {state: {currentIndex: 0}},
-        expectedActions: []
+        expectedActions: [
+          { type: 'currentIndex', payload: 0 }
+        ]
       }, done)
     })
-    it('previous', (done) => {
+    it('previous after less than 3 seconds of playback', (done) => {
       testAction({
         action: store.actions.previous,
-        params: {state: {currentIndex: 1}},
+        params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 1}}},
         expectedActions: [
           { type: 'currentIndex', payload: 0 }
         ]
       }, done)
     })
+    it('previous after more than 3 seconds of playback', (done) => {
+      testAction({
+        action: store.actions.previous,
+        params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 3}}},
+        expectedActions: [
+          { type: 'currentIndex', payload: 1 }
+        ]
+      }, done)
+    })
     it('next on last track when looping on queue', (done) => {
       testAction({
         action: store.actions.next,
@@ -316,18 +319,18 @@ describe('store/queue', () => {
     })
     it('shuffle', (done) => {
       let _shuffle = sandbox.stub(_, 'shuffle')
-      let tracks = [1, 2, 3]
-      let shuffledTracks = [2, 3, 1]
+      let tracks = ['a', 'b', 'c', 'd', 'e']
+      let shuffledTracks = ['e', 'd', 'c']
       _shuffle.returns(shuffledTracks)
       testAction({
         action: store.actions.shuffle,
-        params: {state: {tracks: tracks}},
+        params: {state: {currentIndex: 1, tracks: tracks}},
         expectedMutations: [
           { type: 'player/currentTime', payload: 0 , options: {root: true}},
           { type: 'tracks', payload: [] }
         ],
         expectedActions: [
-          { type: 'appendMany', payload: {tracks: shuffledTracks} }
+          { type: 'appendMany', payload: {tracks: ['a', 'b'].concat(shuffledTracks)} }
         ]
       }, done)
     })
diff --git a/pyproject.toml b/pyproject.toml
index baea16861cd0cb3b81afa8c6561921148f8b1d68..421f98c0261cb940ed3e5f5ce0e094977ee52aff 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,11 @@
         name = "Features"
         showcontent = true
 
+    [[tool.towncrier.type]]
+        directory = "enhancement"
+        name = "Enhancements"
+        showcontent = true
+
     [[tool.towncrier.type]]
         directory = "bugfix"
         name = "Bugfixes"