diff --git a/CHANGELOG b/CHANGELOG
index aed490eaa3224a062e6b2fd6b4fb0d33e53c768b..fbf8d1fbff5a3350570db2b94b43806421dfbdd7 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,26 @@ Changelog
 0.2.7 (Unreleased)
 ------------------
 
+- Shortcuts: can now use the ``f`` shortcut to toggle the currently playing track
+  as a favorite (#53)
+- Shortcuts: avoid collisions between shortcuts by using the exact modifier (#53)
+- Player: Added looping controls and shortcuts (#52)
+- Player: Added shuffling controls and shortcuts (#52)
+- Favorites: can now modify the ordering of track list (#50)
+- Library: can now search/reorder results on artist browsing view (#50)
+- Upgraded celery to 4.1, added endpoint logic for fingerprinting audio files
+- Fixed #56: invalidate tokens on password change, also added change password form
+- Fixed #57: now refresh jwt token on page refresh
+- removed ugly dividers in batch import list
+- Fixed a few padding issues
+- Now persist/restore queue/radio/player state automatically
+- Removed old broken imports
+- Now force tests paths
+- Fixed #54: Now use pytest everywhere \o/
+- Now use vuex to manage state for favorites
+- Now use vuex to manage state for authentication
+- Now use vuex to manage state for player/queue/radios
+
 
 0.2.6 (2017-12-15)
 ------------------
diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index 13205fe3de7bf93b9c46add6a71970da6ac68d4f..a41c7bc0f3eb1b524f15b88f166b93141af2b1d9 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -46,9 +46,8 @@ v1_patterns += [
         include(
             ('funkwhale_api.users.api_urls', 'users'),
             namespace='users')),
-    url(r'^token/',
-        jwt_views.obtain_jwt_token),
-    url(r'^token/refresh/', jwt_views.refresh_jwt_token),
+    url(r'^token/$', jwt_views.obtain_jwt_token, name='token'),
+    url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
 ]
 
 urlpatterns = [
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 9804bb9c08d133b74bfd08440f5d207314ded2cf..7dffebe94ba9e528006778a1ca924626449e8f15 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -280,8 +280,9 @@ JWT_AUTH = {
     'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7),
     'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=30),
     'JWT_AUTH_HEADER_PREFIX': 'JWT',
+    'JWT_GET_USER_SECRET_KEY': lambda user: user.secret_key
 }
-
+OLD_PASSWORD_FIELD_ENABLED = True
 ACCOUNT_ADAPTER = 'funkwhale_api.users.adapters.FunkwhaleAccountAdapter'
 CORS_ORIGIN_ALLOW_ALL = True
 # CORS_ORIGIN_WHITELIST = (
diff --git a/api/config/urls.py b/api/config/urls.py
index 8c490a5e6599e2f44bb60bb110a6a362f4153f93..de67ebb571de4b5f4e15cedd969d1adadeb42aee 100644
--- a/api/config/urls.py
+++ b/api/config/urls.py
@@ -31,3 +31,9 @@ if settings.DEBUG:
         url(r'^404/$', default_views.page_not_found),
         url(r'^500/$', default_views.server_error),
     ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+
+    if 'debug_toolbar' in settings.INSTALLED_APPS:
+        import debug_toolbar
+        urlpatterns += [
+            url(r'^__debug__/', include(debug_toolbar.urls)),
+        ]
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index f1a7b86a37a61722ccccfcd98eaf4a2cb7ce9a5d..1f0087ecb79198b12d4efc4924a8aa54298beeb6 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.2.6'
+__version__ = '0.3'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/common/tests/test_jwt_querystring.py b/api/funkwhale_api/common/tests/test_jwt_querystring.py
deleted file mode 100644
index 90e63775d9ef7e5e2aeeaac6a144d3ff1812bd6e..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/common/tests/test_jwt_querystring.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from test_plus.test import TestCase
-from rest_framework_jwt.settings import api_settings
-
-from funkwhale_api.users.models import User
-
-
-jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
-jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
-
-
-class TestJWTQueryString(TestCase):
-    www_authenticate_realm = 'api'
-
-    def test_can_authenticate_using_token_param_in_url(self):
-        user = User.objects.create_superuser(
-            username='test', email='test@test.com', password='test')
-
-        url = self.reverse('api:v1:tracks-list')
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 401)
-
-        payload = jwt_payload_handler(user)
-        token = jwt_encode_handler(payload)
-        print(payload, token)
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url, data={
-                'jwt': token
-            })
-
-            self.assertEqual(response.status_code, 200)
diff --git a/api/funkwhale_api/downloader/tests/test_downloader.py b/api/funkwhale_api/downloader/tests/test_downloader.py
deleted file mode 100644
index 7cfaa63c83e40f040aaa4f21de1a519c74d18582..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/downloader/tests/test_downloader.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import os
-from test_plus.test import TestCase
-from .. import downloader
-from funkwhale_api.utils.tests import TMPDirTestCaseMixin
-
-
-class TestDownloader(TMPDirTestCaseMixin, TestCase):
-
-    def test_can_download_audio_from_youtube_url_to_vorbis(self):
-        data = downloader.download('https://www.youtube.com/watch?v=tPEE9ZwTmy0', target_directory=self.download_dir)
-        self.assertEqual(
-            data['audio_file_path'],
-            os.path.join(self.download_dir, 'tPEE9ZwTmy0.ogg'))
-        self.assertTrue(os.path.exists(data['audio_file_path']))
diff --git a/api/funkwhale_api/factories.py b/api/funkwhale_api/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..6fed66edb2ed000e75df819ffb862949672889a5
--- /dev/null
+++ b/api/funkwhale_api/factories.py
@@ -0,0 +1,30 @@
+import factory
+import persisting_theory
+
+
+class FactoriesRegistry(persisting_theory.Registry):
+    look_into = 'factories'
+
+    def prepare_name(self, data, name=None):
+        return name or data._meta.model._meta.label
+
+
+registry = FactoriesRegistry()
+
+
+def ManyToManyFromList(field_name):
+    """
+    To automate the pattern described in
+    http://factoryboy.readthedocs.io/en/latest/recipes.html#simple-many-to-many-relationship
+    """
+
+    @factory.post_generation
+    def inner(self, create, extracted, **kwargs):
+        if not create:
+            return
+
+        if extracted:
+            field = getattr(self, field_name)
+            field.add(*extracted)
+
+    return inner
diff --git a/api/funkwhale_api/favorites/factories.py b/api/funkwhale_api/favorites/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..233dd049c5477fdb0c83719d397f41c9f62536d4
--- /dev/null
+++ b/api/funkwhale_api/favorites/factories.py
@@ -0,0 +1,15 @@
+import factory
+
+from funkwhale_api.factories import registry
+
+from funkwhale_api.music.factories import TrackFactory
+from funkwhale_api.users.factories import UserFactory
+
+
+@registry.register
+class TrackFavorite(factory.django.DjangoModelFactory):
+    track = factory.SubFactory(TrackFactory)
+    user = factory.SubFactory(UserFactory)
+
+    class Meta:
+        model = 'favorites.TrackFavorite'
diff --git a/api/funkwhale_api/favorites/tests/test_favorites.py b/api/funkwhale_api/favorites/tests/test_favorites.py
deleted file mode 100644
index 78c64a41338607a11830a8fdb64df758d9214ab6..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/favorites/tests/test_favorites.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music.models import Track, Artist
-from funkwhale_api.favorites.models import TrackFavorite
-from funkwhale_api.users.models import User
-
-class TestFavorites(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.artist = Artist.objects.create(name='test')
-        self.track = Track.objects.create(title='test', artist=self.artist)
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_user_can_add_favorite(self):
-        TrackFavorite.add(self.track, self.user)
-
-        favorite = TrackFavorite.objects.latest('id')
-        self.assertEqual(favorite.track, self.track)
-        self.assertEqual(favorite.user, self.user)
-
-    def test_user_can_get_his_favorites(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:favorites:tracks-list')
-        self.client.login(username=self.user.username, password='test')
-
-        response = self.client.get(url)
-
-        expected = [
-            {
-                'track': self.track.pk,
-                'id': favorite.id,
-                'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
-            }
-        ]
-        parsed_json = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(expected, parsed_json['results'])
-
-    def test_user_can_add_favorite_via_api(self):
-        url = reverse('api:v1:favorites:tracks-list')
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.post(url, {'track': self.track.pk})
-
-        favorite = TrackFavorite.objects.latest('id')
-        expected = {
-            'track': self.track.pk,
-            'id': favorite.id,
-            'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
-        }
-        parsed_json = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(expected, parsed_json)
-        self.assertEqual(favorite.track, self.track)
-        self.assertEqual(favorite.user, self.user)
-
-    def test_user_can_remove_favorite_via_api(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.delete(url, {'track': self.track.pk})
-        self.assertEqual(response.status_code, 204)
-        self.assertEqual(TrackFavorite.objects.count(), 0)
-
-    def test_user_can_remove_favorite_via_api_using_track_id(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:favorites:tracks-remove')
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.delete(
-            url, json.dumps({'track': self.track.pk}),
-            content_type='application/json'
-        )
-
-        self.assertEqual(response.status_code, 204)
-        self.assertEqual(TrackFavorite.objects.count(), 0)
-
-    from funkwhale_api.users.models import User
-
-    def test_can_restrict_api_views_to_authenticated_users(self):
-        urls = [
-            ('api:v1:favorites:tracks-list', 'get'),
-        ]
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=True):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 401)
-
-        self.client.login(username=self.user.username, password='test')
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=False):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 200)
-
-    def test_can_filter_tracks_by_favorites(self):
-        favorite = TrackFavorite.add(self.track, self.user)
-
-        url = reverse('api:v1:tracks-list')
-        self.client.login(username=self.user.username, password='test')
-
-        response = self.client.get(url, data={'favorites': True})
-
-        parsed_json = json.loads(response.content.decode('utf-8'))
-        self.assertEqual(parsed_json['count'], 1)
-        self.assertEqual(parsed_json['results'][0]['id'], self.track.id)
diff --git a/api/funkwhale_api/history/tests/factories.py b/api/funkwhale_api/history/factories.py
similarity index 58%
rename from api/funkwhale_api/history/tests/factories.py
rename to api/funkwhale_api/history/factories.py
index 0a411adf0ce02467e41fe8dd94958755606f0b95..86fea64d251c3a0228bc2a0c5edd5525e10cd7dd 100644
--- a/api/funkwhale_api/history/tests/factories.py
+++ b/api/funkwhale_api/history/factories.py
@@ -1,9 +1,11 @@
 import factory
-from funkwhale_api.music.tests import factories
 
-from funkwhale_api.users.tests.factories import UserFactory
+from funkwhale_api.factories import registry
+from funkwhale_api.music import factories
+from funkwhale_api.users.factories import UserFactory
 
 
+@registry.register
 class ListeningFactory(factory.django.DjangoModelFactory):
     user = factory.SubFactory(UserFactory)
     track = factory.SubFactory(factories.TrackFactory)
diff --git a/api/funkwhale_api/history/tests/test_history.py b/api/funkwhale_api/history/tests/test_history.py
deleted file mode 100644
index 5cb45c946d092d8e0d58d357397581b117bb616f..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/history/tests/test_history.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import random
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-from django.core.exceptions import ValidationError
-from django.utils import timezone
-
-from funkwhale_api.music.tests.factories import TrackFactory
-
-from funkwhale_api.users.models import User
-from funkwhale_api.history import models
-
-
-class TestHistory(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_can_create_listening(self):
-        track = TrackFactory()
-        now = timezone.now()
-        l = models.Listening.objects.create(user=self.user, track=track)
-
-    def test_anonymous_user_can_create_listening_via_api(self):
-        track = TrackFactory()
-        url = self.reverse('api:v1:history:listenings-list')
-        response = self.client.post(url, {
-            'track': track.pk,
-        })
-
-        listening = models.Listening.objects.latest('id')
-
-        self.assertEqual(listening.track, track)
-        self.assertIsNotNone(listening.session_key)
-
-    def test_logged_in_user_can_create_listening_via_api(self):
-        track = TrackFactory()
-
-        self.client.login(username=self.user.username, password='test')
-
-        url = self.reverse('api:v1:history:listenings-list')
-        response = self.client.post(url, {
-            'track': track.pk,
-        })
-
-        listening = models.Listening.objects.latest('id')
-
-        self.assertEqual(listening.track, track)
-        self.assertEqual(listening.user, self.user)
diff --git a/api/funkwhale_api/music/tests/factories.py b/api/funkwhale_api/music/factories.py
similarity index 81%
rename from api/funkwhale_api/music/tests/factories.py
rename to api/funkwhale_api/music/factories.py
index 567e2a765f426e8ca9ce4359bf6644431ef97dec..d776cd9459cd42fdbac62e7168b7262ef2718b0e 100644
--- a/api/funkwhale_api/music/tests/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -1,11 +1,16 @@
 import factory
 import os
 
-from funkwhale_api.users.tests.factories import UserFactory
+from funkwhale_api.factories import registry, ManyToManyFromList
+from funkwhale_api.users.factories import UserFactory
 
-SAMPLES_PATH = os.path.dirname(os.path.abspath(__file__))
+SAMPLES_PATH = os.path.join(
+    os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
+    'tests', 'music'
+)
 
 
+@registry.register
 class ArtistFactory(factory.django.DjangoModelFactory):
     name = factory.Faker('name')
     mbid = factory.Faker('uuid4')
@@ -14,6 +19,7 @@ class ArtistFactory(factory.django.DjangoModelFactory):
         model = 'music.Artist'
 
 
+@registry.register
 class AlbumFactory(factory.django.DjangoModelFactory):
     title = factory.Faker('sentence', nb_words=3)
     mbid = factory.Faker('uuid4')
@@ -26,17 +32,19 @@ class AlbumFactory(factory.django.DjangoModelFactory):
         model = 'music.Album'
 
 
+@registry.register
 class TrackFactory(factory.django.DjangoModelFactory):
     title = factory.Faker('sentence', nb_words=3)
     mbid = factory.Faker('uuid4')
     album = factory.SubFactory(AlbumFactory)
     artist = factory.SelfAttribute('album.artist')
     position = 1
-
+    tags = ManyToManyFromList('tags')
     class Meta:
         model = 'music.Track'
 
 
+@registry.register
 class TrackFileFactory(factory.django.DjangoModelFactory):
     track = factory.SubFactory(TrackFactory)
     audio_file = factory.django.FileField(
@@ -46,6 +54,7 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
         model = 'music.TrackFile'
 
 
+@registry.register
 class ImportBatchFactory(factory.django.DjangoModelFactory):
     submitted_by = factory.SubFactory(UserFactory)
 
@@ -53,14 +62,17 @@ class ImportBatchFactory(factory.django.DjangoModelFactory):
         model = 'music.ImportBatch'
 
 
+@registry.register
 class ImportJobFactory(factory.django.DjangoModelFactory):
     batch = factory.SubFactory(ImportBatchFactory)
     source = factory.Faker('url')
+    mbid = factory.Faker('uuid4')
 
     class Meta:
         model = 'music.ImportJob'
 
 
+@registry.register
 class WorkFactory(factory.django.DjangoModelFactory):
     mbid = factory.Faker('uuid4')
     language = 'eng'
@@ -71,6 +83,7 @@ class WorkFactory(factory.django.DjangoModelFactory):
         model = 'music.Work'
 
 
+@registry.register
 class LyricsFactory(factory.django.DjangoModelFactory):
     work = factory.SubFactory(WorkFactory)
     url = factory.Faker('url')
@@ -80,6 +93,7 @@ class LyricsFactory(factory.django.DjangoModelFactory):
         model = 'music.Lyrics'
 
 
+@registry.register
 class TagFactory(factory.django.DjangoModelFactory):
     name = factory.SelfAttribute('slug')
     slug = factory.Faker('slug')
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index ba3fa453d77443eb75247b3987849db972963e1c..ff937a0f5c3aac8d5609b188d18b796e3b809e3a 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -8,5 +8,5 @@ class ArtistFilter(django_filters.FilterSet):
     class Meta:
         model = models.Artist
         fields = {
-            'name': ['exact', 'iexact', 'startswith']
+            'name': ['exact', 'iexact', 'startswith', 'icontains']
         }
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 43daf9d5a19404309b3ccf1f85e2e4c4713a9840..cf9d8749021ca8f5a1c03f297924de2ed1a583ca 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -13,14 +13,14 @@ class TagSerializer(serializers.ModelSerializer):
 class SimpleArtistSerializer(serializers.ModelSerializer):
     class Meta:
         model = models.Artist
-        fields = ('id', 'mbid', 'name')
+        fields = ('id', 'mbid', 'name', 'creation_date')
 
 
 class ArtistSerializer(serializers.ModelSerializer):
     tags = TagSerializer(many=True, read_only=True)
     class Meta:
         model = models.Artist
-        fields = ('id', 'mbid', 'name', 'tags')
+        fields = ('id', 'mbid', 'name', 'tags', 'creation_date')
 
 
 class TrackFileSerializer(serializers.ModelSerializer):
diff --git a/api/funkwhale_api/music/tests/test_api.py b/api/funkwhale_api/music/tests/test_api.py
deleted file mode 100644
index 2460fa97d0830a8b66ceaeef80f680def65b02cc..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_api.py
+++ /dev/null
@@ -1,256 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music import models
-from funkwhale_api.utils.tests import TMPDirTestCaseMixin
-from funkwhale_api.musicbrainz import api
-from funkwhale_api.music import serializers
-from funkwhale_api.users.models import User
-
-from . import data as api_data
-from . import factories
-
-
-class TestAPI(TMPDirTestCaseMixin, TestCase):
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures'])
-    @unittest.mock.patch('funkwhale_api.music.models.TrackFile.download_file', return_value=None)
-    def test_can_submit_youtube_url_for_track_import(self, *mocks):
-        mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
-        video_id = 'tPEE9ZwTmy0'
-        url = reverse('api:v1:submit-single')
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-        response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid})
-        track = models.Track.objects.get(mbid=mbid)
-        self.assertEqual(track.artist.name, 'Adhesive Wombat')
-        self.assertEqual(track.album.title, 'Marsupial Madness')
-        # self.assertIn(video_id, track.files.first().audio_file.name)
-
-    def test_import_creates_an_import_with_correct_data(self):
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
-        video_id = 'tPEE9ZwTmy0'
-        url = reverse('api:v1:submit-single')
-        self.client.login(username=user.username, password='test')
-        with self.settings(CELERY_ALWAYS_EAGER=False):
-            response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid})
-
-        batch = models.ImportBatch.objects.latest('id')
-        self.assertEqual(batch.jobs.count(), 1)
-        self.assertEqual(batch.submitted_by, user)
-        self.assertEqual(batch.status, 'pending')
-        job = batch.jobs.first()
-        self.assertEqual(str(job.mbid), mbid)
-        self.assertEqual(job.status, 'pending')
-        self.assertEqual(job.source, 'https://www.youtube.com/watch?v={0}'.format(video_id))
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'')
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize'])
-    def test_can_import_whole_album(self, *mocks):
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        payload = {
-            'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
-            'tracks': [
-                {
-                'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
-                'source': 'https://www.youtube.com/watch?v=1111111111',
-                },
-                {
-                'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
-                'source': 'https://www.youtube.com/watch?v=2222222222',
-                },
-                {
-                'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
-                'source': 'https://www.youtube.com/watch?v=3333333333',
-                },
-            ]
-        }
-        url = reverse('api:v1:submit-album')
-        self.client.login(username=user.username, password='test')
-        with self.settings(CELERY_ALWAYS_EAGER=False):
-            response = self.client.post(url, json.dumps(payload), content_type="application/json")
-
-        batch = models.ImportBatch.objects.latest('id')
-        self.assertEqual(batch.jobs.count(), 3)
-        self.assertEqual(batch.submitted_by, user)
-        self.assertEqual(batch.status, 'pending')
-
-        album = models.Album.objects.latest('id')
-        self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94')
-        medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
-        self.assertEqual(int(medium_data['track-count']), album.tracks.all().count())
-
-        for track in medium_data['track-list']:
-            instance = models.Track.objects.get(mbid=track['recording']['id'])
-            self.assertEqual(instance.title, track['recording']['title'])
-            self.assertEqual(instance.position, int(track['position']))
-            self.assertEqual(instance.title, track['recording']['title'])
-
-        for row in payload['tracks']:
-            job = models.ImportJob.objects.get(mbid=row['mbid'])
-            self.assertEqual(str(job.mbid), row['mbid'])
-            self.assertEqual(job.status, 'pending')
-            self.assertEqual(job.source, row['source'])
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'')
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize'])
-    def test_can_import_whole_artist(self, *mocks):
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        payload = {
-            'artistId': 'mbid',
-            'albums': [
-                {
-                    'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
-                    'tracks': [
-                        {
-                        'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
-                        'source': 'https://www.youtube.com/watch?v=1111111111',
-                        },
-                        {
-                        'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
-                        'source': 'https://www.youtube.com/watch?v=2222222222',
-                        },
-                        {
-                        'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
-                        'source': 'https://www.youtube.com/watch?v=3333333333',
-                        },
-                    ]
-                }
-            ]
-        }
-        url = reverse('api:v1:submit-artist')
-        self.client.login(username=user.username, password='test')
-        with self.settings(CELERY_ALWAYS_EAGER=False):
-            response = self.client.post(url, json.dumps(payload), content_type="application/json")
-
-        batch = models.ImportBatch.objects.latest('id')
-        self.assertEqual(batch.jobs.count(), 3)
-        self.assertEqual(batch.submitted_by, user)
-        self.assertEqual(batch.status, 'pending')
-
-        album = models.Album.objects.latest('id')
-        self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94')
-        medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
-        self.assertEqual(int(medium_data['track-count']), album.tracks.all().count())
-
-        for track in medium_data['track-list']:
-            instance = models.Track.objects.get(mbid=track['recording']['id'])
-            self.assertEqual(instance.title, track['recording']['title'])
-            self.assertEqual(instance.position, int(track['position']))
-            self.assertEqual(instance.title, track['recording']['title'])
-
-        for row in payload['albums'][0]['tracks']:
-            job = models.ImportJob.objects.get(mbid=row['mbid'])
-            self.assertEqual(str(job.mbid), row['mbid'])
-            self.assertEqual(job.status, 'pending')
-            self.assertEqual(job.source, row['source'])
-
-    def test_user_can_query_api_for_his_own_batches(self):
-        user1 = User.objects.create_superuser(username='test1', email='test1@test.com', password='test')
-        user2 = User.objects.create_superuser(username='test2', email='test2@test.com', password='test')
-        mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
-        source = 'https://www.youtube.com/watch?v=tPEE9ZwTmy0'
-
-        batch = models.ImportBatch.objects.create(submitted_by=user1)
-        job = models.ImportJob.objects.create(batch=batch, mbid=mbid, source=source)
-
-        url = reverse('api:v1:import-batches-list')
-
-        self.client.login(username=user2.username, password='test')
-        response2 = self.client.get(url)
-        self.assertJSONEqual(response2.content.decode('utf-8'), '{"count":0,"next":null,"previous":null,"results":[]}')
-        self.client.logout()
-
-        self.client.login(username=user1.username, password='test')
-        response1 = self.client.get(url)
-        self.assertIn(mbid, response1.content.decode('utf-8'))
-
-    def test_can_search_artist(self):
-        artist1 = models.Artist.objects.create(name='Test1')
-        artist2 = models.Artist.objects.create(name='Test2')
-        query = 'test1'
-        expected = '[{0}]'.format(json.dumps(serializers.ArtistSerializerNested(artist1).data))
-        url = self.reverse('api:v1:artists-search')
-        response = self.client.get(url + '?query={0}'.format(query))
-
-        self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    def test_can_search_artist_by_name_start(self):
-        artist1 = factories.ArtistFactory(name='alpha')
-        artist2 = factories.ArtistFactory(name='beta')
-        results = {
-            'next': None,
-            'previous': None,
-            'count': 1,
-            'results': [serializers.ArtistSerializerNested(artist1).data]
-        }
-        expected = json.dumps(results)
-        url = self.reverse('api:v1:artists-list')
-        response = self.client.get(url, {'name__startswith': 'a'})
-
-        self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    def test_can_search_tracks(self):
-        artist1 = models.Artist.objects.create(name='Test1')
-        artist2 = models.Artist.objects.create(name='Test2')
-        track1 = models.Track.objects.create(artist=artist1, title="test_track1")
-        track2 = models.Track.objects.create(artist=artist2, title="test_track2")
-        query = 'test track 1'
-        expected = '[{0}]'.format(json.dumps(serializers.TrackSerializerNested(track1).data))
-        url = self.reverse('api:v1:tracks-search')
-        response = self.client.get(url + '?query={0}'.format(query))
-
-        self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    def test_can_restrict_api_views_to_authenticated_users(self):
-        urls = [
-            ('api:v1:tags-list', 'get'),
-            ('api:v1:tracks-list', 'get'),
-            ('api:v1:artists-list', 'get'),
-            ('api:v1:albums-list', 'get'),
-        ]
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=True):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 401)
-
-        user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-
-        for route_name, method in urls:
-            url = self.reverse(route_name)
-            with self.settings(API_AUTHENTICATION_REQUIRED=False):
-                response = getattr(self.client, method)(url)
-            self.assertEqual(response.status_code, 200)
-
-    def test_track_file_url_is_restricted_to_authenticated_users(self):
-        f = factories.TrackFileFactory()
-        self.assertNotEqual(f.audio_file, None)
-        url = f.path
-
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 401)
-
-        user = User.objects.create_superuser(
-            username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-        with self.settings(API_AUTHENTICATION_REQUIRED=True):
-            response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 200)
-
-        self.assertEqual(
-            response['X-Accel-Redirect'],
-            '/_protected{}'.format(f.audio_file.url)
-        )
diff --git a/api/funkwhale_api/music/tests/test_lyrics.py b/api/funkwhale_api/music/tests/test_lyrics.py
deleted file mode 100644
index 9a05e5eb87b88752ea9aa81ac081a0e2981d9570..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_lyrics.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music import models
-from funkwhale_api.musicbrainz import api
-from funkwhale_api.music import serializers
-from funkwhale_api.users.models import User
-from funkwhale_api.music import lyrics as lyrics_utils
-
-from .mocking import lyricswiki
-from . import factories
-from . import data as api_data
-
-
-
-class TestLyrics(TestCase):
-
-    @unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
-                         return_value=lyricswiki.content)
-    def test_works_import_lyrics_if_any(self, *mocks):
-        lyrics = factories.LyricsFactory(
-            url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
-
-        lyrics.fetch_content()
-        self.assertIn(
-            'Grab a brush and put on a little makeup',
-            lyrics.content,
-        )
-
-    def test_clean_content(self):
-        c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>"""
-        d = lyrics_utils.extract_content(c)
-        d = lyrics_utils.clean_content(d)
-
-        expected = """Hello
-Is it me you're looking for?
-"""
-        self.assertEqual(d, expected)
-
-    def test_markdown_rendering(self):
-        content = """Hello
-Is it me you're looking for?"""
-
-        l = factories.LyricsFactory(content=content)
-
-        expected = "<p>Hello<br />Is it me you're looking for?</p>"
-        self.assertHTMLEqual(expected, l.content_rendered)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
-                         return_value=api_data.tracks['get']['chop_suey'])
-    @unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
-                         return_value=lyricswiki.content)
-    def test_works_import_lyrics_if_any(self, *mocks):
-        track = factories.TrackFactory(
-            work=None,
-            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
-
-        url = reverse('api:v1:tracks-lyrics', kwargs={'pk': track.pk})
-        user = User.objects.create_user(
-            username='test', email='test@test.com', password='test')
-        self.client.login(username=user.username, password='test')
-        response = self.client.get(url)
-
-        self.assertEqual(response.status_code, 200)
-
-        track.refresh_from_db()
-        lyrics = models.Lyrics.objects.latest('id')
-        work = models.Work.objects.latest('id')
-
-        self.assertEqual(track.work, work)
-        self.assertEqual(lyrics.work, work)
diff --git a/api/funkwhale_api/music/tests/test_metadata.py b/api/funkwhale_api/music/tests/test_metadata.py
deleted file mode 100644
index 9b8c7665337597c04d5770efb4bdc829f18ba380..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_metadata.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import unittest
-import os
-import datetime
-from test_plus.test import TestCase
-from funkwhale_api.music import metadata
-
-DATA_DIR = os.path.dirname(os.path.abspath(__file__))
-
-
-class TestMetadata(TestCase):
-
-    def test_can_get_metadata_from_ogg_file(self, *mocks):
-        path = os.path.join(DATA_DIR, 'test.ogg')
-        data = metadata.Metadata(path)
-
-        self.assertEqual(
-            data.get('title'),
-            'Peer Gynt Suite no. 1, op. 46: I. Morning'
-        )
-        self.assertEqual(
-            data.get('artist'),
-            'Edvard Grieg'
-        )
-        self.assertEqual(
-            data.get('album'),
-            'Peer Gynt Suite no. 1, op. 46'
-        )
-        self.assertEqual(
-            data.get('date'),
-            datetime.date(2012, 8, 15),
-        )
-        self.assertEqual(
-            data.get('track_number'),
-            1
-        )
-
-        self.assertEqual(
-            data.get('musicbrainz_albumid'),
-            'a766da8b-8336-47aa-a3ee-371cc41ccc75')
-        self.assertEqual(
-            data.get('musicbrainz_recordingid'),
-            'bd21ac48-46d8-4e78-925f-d9cc2a294656')
-        self.assertEqual(
-            data.get('musicbrainz_artistid'),
-            '013c8e5b-d72a-4cd3-8dee-6c64d6125823')
-
-    def test_can_get_metadata_from_id3_mp3_file(self, *mocks):
-        path = os.path.join(DATA_DIR, 'test.mp3')
-        data = metadata.Metadata(path)
-
-        self.assertEqual(
-            data.get('title'),
-            'Bend'
-        )
-        self.assertEqual(
-            data.get('artist'),
-            'Binärpilot'
-        )
-        self.assertEqual(
-            data.get('album'),
-            'You Can\'t Stop Da Funk'
-        )
-        self.assertEqual(
-            data.get('date'),
-            datetime.date(2006, 2, 7),
-        )
-        self.assertEqual(
-            data.get('track_number'),
-            1
-        )
-
-        self.assertEqual(
-            data.get('musicbrainz_albumid'),
-            'ce40cdb1-a562-4fd8-a269-9269f98d4124')
-        self.assertEqual(
-            data.get('musicbrainz_recordingid'),
-            'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb')
-        self.assertEqual(
-            data.get('musicbrainz_artistid'),
-            '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13')
diff --git a/api/funkwhale_api/music/tests/test_music.py b/api/funkwhale_api/music/tests/test_music.py
deleted file mode 100644
index 5cf9d0cf96d53c6060759b6a8c65cf1aaef6917f..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_music.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from test_plus.test import TestCase
-import unittest.mock
-from funkwhale_api.music import models
-import datetime
-
-from . import factories
-from . import data as api_data
-from .cover import binary_data
-
-
-class TestMusic(TestCase):
-
-    @unittest.mock.patch('musicbrainzngs.search_artists', return_value=api_data.artists['search']['adhesive_wombat'])
-    def test_can_create_artist_from_api(self, *mocks):
-        artist = models.Artist.create_from_api(query="Adhesive wombat")
-        data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
-
-        self.assertEqual(int(data['ext:score']), 100)
-        self.assertEqual(data['id'], '62c3befb-6366-4585-b256-809472333801')
-        self.assertEqual(artist.mbid, data['id'])
-        self.assertEqual(artist.name, 'Adhesive Wombat')
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.search', return_value=api_data.albums['search']['hypnotize'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
-    def test_can_create_album_from_api(self, *mocks):
-        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]
-
-        self.assertEqual(album.mbid, data['id'])
-        self.assertEqual(album.title, 'Hypnotize')
-        with self.assertRaises(ValueError):
-            self.assertFalse(album.cover.path is None)
-        self.assertEqual(album.release_date, datetime.date(2005, 1, 1))
-        self.assertEqual(album.artist.name, 'System of a Down')
-        self.assertEqual(album.artist.mbid, data['artist-credit'][0]['artist']['id'])
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures'])
-    def test_can_create_track_from_api(self, *mocks):
-        track = models.Track.create_from_api(query="8-bit adventure")
-        data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
-        self.assertEqual(int(data['ext:score']), 100)
-        self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed')
-        self.assertEqual(track.mbid, data['id'])
-        self.assertTrue(track.artist.pk is not None)
-        self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801')
-        self.assertEqual(track.artist.name, 'Adhesive Wombat')
-        self.assertEqual(str(track.album.mbid), 'a50d2a81-2a50-484d-9cb4-b9f6833f583e')
-        self.assertEqual(track.album.title, 'Marsupial Madness')
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures'])
-    def test_can_create_track_from_api_with_corresponding_tags(self, *mocks):
-        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()]
-        for tag in expected_tags:
-            self.assertIn(tag, track_tags)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures'])
-    def test_can_get_or_create_track_from_api(self, *mocks):
-        track = models.Track.create_from_api(query="8-bit adventure")
-        data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
-        self.assertEqual(int(data['ext:score']), 100)
-        self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed')
-        self.assertEqual(track.mbid, data['id'])
-        self.assertTrue(track.artist.pk is not None)
-        self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801')
-        self.assertEqual(track.artist.name, 'Adhesive Wombat')
-
-        track2, created = models.Track.get_or_create_from_api(mbid=data['id'])
-        self.assertFalse(created)
-        self.assertEqual(track, track2)
-
-    def test_album_tags_deduced_from_tracks_tags(self):
-        tag = factories.TagFactory()
-        album = factories.AlbumFactory()
-        tracks = factories.TrackFactory.create_batch(album=album, size=5)
-
-        for track in tracks:
-            track.tags.add(tag)
-
-        album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk)
-
-        with self.assertNumQueries(0):
-            self.assertIn(tag, album.tags)
-
-    def test_artist_tags_deduced_from_album_tags(self):
-        tag = factories.TagFactory()
-        artist = factories.ArtistFactory()
-        album = factories.AlbumFactory(artist=artist)
-        tracks = factories.TrackFactory.create_batch(album=album, size=5)
-
-        for track in tracks:
-            track.tags.add(tag)
-
-        artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk)
-
-        with self.assertNumQueries(0):
-            self.assertIn(tag, artist.tags)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=binary_data)
-    def test_can_download_image_file_for_album(self, *mocks):
-        # client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
-        album = factories.AlbumFactory(mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
-        album.get_image()
-        album.save()
-
-        self.assertEqual(album.cover.file.read(), binary_data)
diff --git a/api/funkwhale_api/music/tests/test_works.py b/api/funkwhale_api/music/tests/test_works.py
deleted file mode 100644
index 55714bce2c142c36b86f52c9361292df79689695..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/tests/test_works.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.music import models
-from funkwhale_api.musicbrainz import api
-from funkwhale_api.music import serializers
-from funkwhale_api.music.tests import factories
-from funkwhale_api.users.models import User
-
-from . import data as api_data
-
-
-class TestWorks(TestCase):
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    def test_can_import_work(self, *mocks):
-        recording = factories.TrackFactory(
-            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
-        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
-        work = models.Work.create_from_api(id=mbid)
-
-        self.assertEqual(work.title, 'Chop Suey!')
-        self.assertEqual(work.nature, 'song')
-        self.assertEqual(work.language, 'eng')
-        self.assertEqual(work.mbid, mbid)
-
-        # a imported work should also be linked to corresponding recordings
-
-        recording.refresh_from_db()
-        self.assertEqual(recording.work, work)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
-                         return_value=api_data.tracks['get']['chop_suey'])
-    def test_can_get_work_from_recording(self, *mocks):
-        recording = factories.TrackFactory(
-            work=None,
-            mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
-        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
-
-        self.assertEqual(recording.work, None)
-
-        work = recording.get_work()
-
-        self.assertEqual(work.title, 'Chop Suey!')
-        self.assertEqual(work.nature, 'song')
-        self.assertEqual(work.language, 'eng')
-        self.assertEqual(work.mbid, mbid)
-
-        recording.refresh_from_db()
-        self.assertEqual(recording.work, work)
-
-    @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
-                         return_value=api_data.works['get']['chop_suey'])
-    def test_works_import_lyrics_if_any(self, *mocks):
-        mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
-        work = models.Work.create_from_api(id=mbid)
-
-        lyrics = models.Lyrics.objects.latest('id')
-        self.assertEqual(lyrics.work, work)
-        self.assertEqual(
-            lyrics.url, 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index c32fa8f7ff49caa1ef9fbfff533e12a32835373e..532942e2e651c0262d96b61464b3411473f90fce 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -47,16 +47,15 @@ class TagViewSetMixin(object):
 class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     queryset = (
         models.Artist.objects.all()
-                             .order_by('name')
                              .prefetch_related(
                                 'albums__tracks__files',
+                                'albums__tracks__artist',
                                 'albums__tracks__tags'))
     serializer_class = serializers.ArtistSerializerNested
     permission_classes = [ConditionalAuthentication]
     search_fields = ['name']
-    ordering_fields = ('creation_date', 'name')
     filter_class = filters.ArtistFilter
-
+    ordering_fields = ('id', 'name', 'creation_date')
 
 class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     queryset = (
@@ -96,7 +95,12 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
     serializer_class = serializers.TrackSerializerNested
     permission_classes = [ConditionalAuthentication]
     search_fields = ['title', 'artist__name']
-    ordering_fields = ('creation_date',)
+    ordering_fields = (
+        'creation_date',
+        'title',
+        'album__title',
+        'artist__name',
+    )
 
     def get_queryset(self):
         queryset = super().get_queryset()
diff --git a/api/funkwhale_api/musicbrainz/tests/__init__.py b/api/funkwhale_api/musicbrainz/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/musicbrainz/tests/test_api.py b/api/funkwhale_api/musicbrainz/tests/test_api.py
deleted file mode 100644
index b0911f1c54983fd0f41eda12027ea0c836d587a0..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/musicbrainz/tests/test_api.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import json
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-
-from funkwhale_api.musicbrainz import api
-from . import data as api_data
-
-
-class TestAPI(TestCase):
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.recordings.search',
-        return_value=api_data.recordings['search']['brontide matador'])
-    def test_can_search_recording_in_musicbrainz_api(self, *mocks):
-        query = 'brontide matador'
-        url = reverse('api:v1:providers:musicbrainz:search-recordings')
-        expected = api_data.recordings['search']['brontide matador']
-        response = self.client.get(url, data={'query': query})
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.releases.search',
-        return_value=api_data.releases['search']['brontide matador'])
-    def test_can_search_release_in_musicbrainz_api(self, *mocks):
-        query = 'brontide matador'
-        url = reverse('api:v1:providers:musicbrainz:search-releases')
-        expected = api_data.releases['search']['brontide matador']
-        response = self.client.get(url, data={'query': query})
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.artists.search',
-        return_value=api_data.artists['search']['lost fingers'])
-    def test_can_search_artists_in_musicbrainz_api(self, *mocks):
-        query = 'lost fingers'
-        url = reverse('api:v1:providers:musicbrainz:search-artists')
-        expected = api_data.artists['search']['lost fingers']
-        response = self.client.get(url, data={'query': query})
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.artists.get',
-        return_value=api_data.artists['get']['lost fingers'])
-    def test_can_get_artist_in_musicbrainz_api(self, *mocks):
-        uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
-        url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={
-            'uuid': uuid,
-        })
-        response = self.client.get(url)
-        expected = api_data.artists['get']['lost fingers']
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.release_groups.browse',
-        return_value=api_data.release_groups['browse']['lost fingers'])
-    def test_can_broswe_release_group_using_musicbrainz_api(self, *mocks):
-        uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
-        url = reverse(
-            'api:v1:providers:musicbrainz:release-group-browse',
-            kwargs={
-                'artist_uuid': uuid,
-            }
-        )
-        response = self.client.get(url)
-        expected = api_data.release_groups['browse']['lost fingers']
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
-
-    @unittest.mock.patch(
-        'funkwhale_api.musicbrainz.api.releases.browse',
-        return_value=api_data.releases['browse']['Lost in the 80s'])
-    def test_can_broswe_releases_using_musicbrainz_api(self, *mocks):
-        uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
-        url = reverse(
-            'api:v1:providers:musicbrainz:release-browse',
-            kwargs={
-                'release_group_uuid': uuid,
-            }
-        )
-        response = self.client.get(url)
-        expected = api_data.releases['browse']['Lost in the 80s']
-
-        self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
diff --git a/api/funkwhale_api/musicbrainz/tests/test_cache.py b/api/funkwhale_api/musicbrainz/tests/test_cache.py
deleted file mode 100644
index d2d1260ecb1cb36dc95e5268d269b0d8dbcf774c..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/musicbrainz/tests/test_cache.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import unittest
-from test_plus.test import TestCase
-
-from funkwhale_api.musicbrainz import client
-
-
-class TestAPI(TestCase):
-    def test_can_search_recording_in_musicbrainz_api(self, *mocks):
-        r = {'hello': 'world'}
-        mocked = 'funkwhale_api.musicbrainz.client._api.search_artists'
-        with unittest.mock.patch(mocked, return_value=r) as m:
-            self.assertEqual(client.api.artists.search('test'), r)
-            # now call from cache
-            self.assertEqual(client.api.artists.search('test'), r)
-            self.assertEqual(client.api.artists.search('test'), r)
-
-            self.assertEqual(m.call_count, 1)
diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..19e4770cfae15fd6d790063026dbdb04770b2e1b
--- /dev/null
+++ b/api/funkwhale_api/playlists/factories.py
@@ -0,0 +1,13 @@
+import factory
+
+from funkwhale_api.factories import registry
+from funkwhale_api.users.factories import UserFactory
+
+
+@registry.register
+class PlaylistFactory(factory.django.DjangoModelFactory):
+    name = factory.Faker('name')
+    user = factory.SubFactory(UserFactory)
+
+    class Meta:
+        model = 'playlists.Playlist'
diff --git a/api/funkwhale_api/playlists/tests/__init__.py b/api/funkwhale_api/playlists/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/playlists/tests/test_playlists.py b/api/funkwhale_api/playlists/tests/test_playlists.py
deleted file mode 100644
index 2f61889ee13d5afbc949ba36a06e9b30b6dbfad7..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/playlists/tests/test_playlists.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-from django.core.exceptions import ValidationError
-from django.utils import timezone
-
-from funkwhale_api.music.tests import factories
-from funkwhale_api.users.models import User
-from funkwhale_api.playlists import models
-from funkwhale_api.playlists.serializers import PlaylistSerializer
-
-
-class TestPlayLists(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_can_create_playlist(self):
-        tracks = factories.TrackFactory.create_batch(size=5)
-        playlist = models.Playlist.objects.create(user=self.user, name="test")
-
-        previous = None
-        for i in range(len(tracks)):
-            previous = playlist.add_track(tracks[i], previous=previous)
-
-        playlist_tracks = list(playlist.playlist_tracks.all())
-
-        previous = None
-        for idx, track in enumerate(tracks):
-            plt = playlist_tracks[idx]
-            self.assertEqual(plt.position, idx)
-            self.assertEqual(plt.track, track)
-            if previous:
-                self.assertEqual(playlist_tracks[idx + 1], previous)
-            self.assertEqual(plt.playlist, playlist)
-
-    def test_can_create_playlist_via_api(self):
-        self.client.login(username=self.user.username, password='test')
-        url = reverse('api:v1:playlists-list')
-        data = {
-            'name': 'test',
-        }
-
-        response = self.client.post(url, data)
-
-        playlist = self.user.playlists.latest('id')
-        self.assertEqual(playlist.name, 'test')
-
-    def test_can_add_playlist_track_via_api(self):
-        tracks = factories.TrackFactory.create_batch(size=5)
-        playlist = models.Playlist.objects.create(user=self.user, name="test")
-
-        self.client.login(username=self.user.username, password='test')
-
-        url = reverse('api:v1:playlist-tracks-list')
-        data = {
-            'playlist': playlist.pk,
-            'track': tracks[0].pk
-        }
-
-        response = self.client.post(url, data)
-        plts = self.user.playlists.latest('id').playlist_tracks.all()
-        self.assertEqual(plts.first().track, tracks[0])
diff --git a/api/funkwhale_api/providers/audiofile/tests/__init__.py b/api/funkwhale_api/providers/audiofile/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py b/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py
deleted file mode 100644
index f8d36986a3170fea19b6be1b9030b354fddcba29..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import os
-import datetime
-import unittest
-from test_plus.test import TestCase
-
-from funkwhale_api.providers.audiofile import tasks
-
-DATA_DIR = os.path.dirname(os.path.abspath(__file__))
-
-
-class TestAudioFile(TestCase):
-    def test_can_import_single_audio_file(self, *mocks):
-        metadata = {
-            'artist': ['Test artist'],
-            'album': ['Test album'],
-            'title': ['Test track'],
-            'TRACKNUMBER': ['4'],
-            'date': ['2012-08-15'],
-            'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'],
-            'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'],
-            'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'],
-        }
-
-        m1 = unittest.mock.patch('mutagen.File', return_value=metadata)
-        m2 = unittest.mock.patch(
-            'funkwhale_api.music.metadata.Metadata.get_file_type',
-            return_value='OggVorbis',
-        )
-        with m1, m2:
-            track_file = tasks.from_path(
-                os.path.join(DATA_DIR, 'dummy_file.ogg'))
-
-        self.assertEqual(
-            track_file.track.title, metadata['title'][0])
-        self.assertEqual(
-            track_file.track.mbid, metadata['musicbrainz_trackid'][0])
-        self.assertEqual(
-            track_file.track.position, 4)
-        self.assertEqual(
-            track_file.track.album.title, metadata['album'][0])
-        self.assertEqual(
-            track_file.track.album.mbid,
-            metadata['musicbrainz_albumid'][0])
-        self.assertEqual(
-            track_file.track.album.release_date, datetime.date(2012, 8, 15))
-        self.assertEqual(
-            track_file.track.artist.name, metadata['artist'][0])
-        self.assertEqual(
-            track_file.track.artist.mbid,
-            metadata['musicbrainz_artistid'][0])
diff --git a/api/funkwhale_api/providers/youtube/tests/__init__.py b/api/funkwhale_api/providers/youtube/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/providers/youtube/tests/test_youtube.py b/api/funkwhale_api/providers/youtube/tests/test_youtube.py
deleted file mode 100644
index 8a1dd1eb70a601e56082ec7dce8fe3cae32c7095..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/youtube/tests/test_youtube.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import json
-from collections import OrderedDict
-import unittest
-from test_plus.test import TestCase
-from django.urls import reverse
-from funkwhale_api.providers.youtube.client import client
-
-from . import data as api_data
-
-class TestAPI(TestCase):
-    maxDiff = None
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        return_value=api_data.search['8 bit adventure'])
-    def test_can_get_search_results_from_youtube(self, *mocks):
-        query = '8 bit adventure'
-
-        results = client.search(query)
-        self.assertEqual(results[0]['id']['videoId'], '0HxZn6CzOIo')
-        self.assertEqual(results[0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure')
-        self.assertEqual(results[0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo')
-
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        return_value=api_data.search['8 bit adventure'])
-    def test_can_get_search_results_from_funkwhale(self, *mocks):
-        query = '8 bit adventure'
-        url = self.reverse('api:v1:providers:youtube:search')
-        response = self.client.get(url + '?query={0}'.format(query))
-        # we should cast the youtube result to something more generic
-        expected = {
-            "id": "0HxZn6CzOIo",
-            "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
-            "type": "youtube#video",
-            "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
-            "channelId": "UCps63j3krzAG4OyXeEyuhFw",
-            "title": "AdhesiveWombat - 8 Bit Adventure",
-            "channelTitle": "AdhesiveWombat",
-            "publishedAt": "2012-08-22T18:41:03.000Z",
-            "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
-        }
-
-        self.assertEqual(
-            json.loads(response.content.decode('utf-8'))[0], expected)
-
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        side_effect=[
-            api_data.search['8 bit adventure'],
-            api_data.search['system of a down toxicity'],
-        ]
-    )
-    def test_can_send_multiple_queries_at_once(self, *mocks):
-        queries = OrderedDict()
-        queries['1'] = {
-            'q': '8 bit adventure',
-        }
-        queries['2'] = {
-            'q': 'system of a down toxicity',
-        }
-
-        results = client.search_multiple(queries)
-
-        self.assertEqual(results['1'][0]['id']['videoId'], '0HxZn6CzOIo')
-        self.assertEqual(results['1'][0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure')
-        self.assertEqual(results['1'][0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo')
-        self.assertEqual(results['2'][0]['id']['videoId'], 'BorYwGi2SJc')
-        self.assertEqual(results['2'][0]['snippet']['title'], 'System of a Down: Toxicity')
-        self.assertEqual(results['2'][0]['full_url'], 'https://www.youtube.com/watch?v=BorYwGi2SJc')
-
-    @unittest.mock.patch(
-        'funkwhale_api.providers.youtube.client._do_search',
-        return_value=api_data.search['8 bit adventure'],
-    )
-    def test_can_send_multiple_queries_at_once_from_funwkhale(self, *mocks):
-        queries = OrderedDict()
-        queries['1'] = {
-            'q': '8 bit adventure',
-        }
-
-        expected = {
-            "id": "0HxZn6CzOIo",
-            "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
-            "type": "youtube#video",
-            "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
-            "channelId": "UCps63j3krzAG4OyXeEyuhFw",
-            "title": "AdhesiveWombat - 8 Bit Adventure",
-            "channelTitle": "AdhesiveWombat",
-            "publishedAt": "2012-08-22T18:41:03.000Z",
-            "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
-        }
-
-        url = self.reverse('api:v1:providers:youtube:searchs')
-        response = self.client.post(
-            url, json.dumps(queries), content_type='application/json')
-
-        self.assertEqual(
-            expected,
-            json.loads(response.content.decode('utf-8'))['1'][0])
diff --git a/api/funkwhale_api/radios/tests/__init__.py b/api/funkwhale_api/radios/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/radios/tests/test_radios.py b/api/funkwhale_api/radios/tests/test_radios.py
deleted file mode 100644
index ab27d45166a343c3bb731608db750312b934d241..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/radios/tests/test_radios.py
+++ /dev/null
@@ -1,196 +0,0 @@
-import random
-import json
-from test_plus.test import TestCase
-from django.urls import reverse
-from django.core.exceptions import ValidationError
-
-
-from funkwhale_api.radios import radios
-from funkwhale_api.radios import models
-from funkwhale_api.favorites.models import TrackFavorite
-from funkwhale_api.users.models import User
-from funkwhale_api.music.models import Artist
-from funkwhale_api.music.tests import factories
-from funkwhale_api.history.tests.factories import ListeningFactory
-
-
-class TestRadios(TestCase):
-
-    def setUp(self):
-        super().setUp()
-        self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
-
-    def test_can_pick_track_from_choices(self):
-        choices = [1, 2, 3, 4, 5]
-
-        radio = radios.SimpleRadio()
-
-        first_pick = radio.pick(choices=choices)
-
-        self.assertIn(first_pick, choices)
-
-        previous_choices = [first_pick]
-        for remaining_choice in choices:
-            pick = radio.pick(choices=choices, previous_choices=previous_choices)
-            self.assertIn(pick, set(choices).difference(previous_choices))
-
-    def test_can_pick_by_weight(self):
-        choices_with_weight = [
-            # choice, weight
-            (1, 1),
-            (2, 2),
-            (3, 3),
-            (4, 4),
-            (5, 5),
-        ]
-
-        picks = {choice: 0 for choice, weight in choices_with_weight}
-
-        for i in range(1000):
-            radio = radios.SimpleRadio()
-            pick = radio.weighted_pick(choices=choices_with_weight)
-            picks[pick] = picks[pick] + 1
-
-        self.assertTrue(picks[5] > picks[4])
-        self.assertTrue(picks[4] > picks[3])
-        self.assertTrue(picks[3] > picks[2])
-        self.assertTrue(picks[2] > picks[1])
-
-    def test_can_get_choices_for_favorites_radio(self):
-        tracks = factories.TrackFactory.create_batch(size=100)
-
-        for i in range(20):
-            TrackFavorite.add(track=random.choice(tracks), user=self.user)
-
-        radio = radios.FavoritesRadio()
-        choices = radio.get_choices(user=self.user)
-
-        self.assertEqual(choices.count(), self.user.track_favorites.all().count())
-
-        for favorite in self.user.track_favorites.all():
-            self.assertIn(favorite.track, choices)
-
-        for i in range(20):
-            pick = radio.pick(user=self.user)
-            self.assertIn(pick, choices)
-
-    def test_can_use_radio_session_to_filter_choices(self):
-        tracks = factories.TrackFactory.create_batch(size=30)
-        radio = radios.RandomRadio()
-        session = radio.start_session(self.user)
-
-        for i in range(30):
-            p = radio.pick()
-
-        # ensure 30 differents tracks have been suggested
-        tracks_id = [session_track.track.pk for session_track in session.session_tracks.all()]
-        self.assertEqual(len(set(tracks_id)), 30)
-
-    def test_can_restore_radio_from_previous_session(self):
-        tracks = factories.TrackFactory.create_batch(size=30)
-
-        radio = radios.RandomRadio()
-        session = radio.start_session(self.user)
-
-        restarted_radio = radios.RandomRadio(session)
-        self.assertEqual(radio.session, restarted_radio.session)
-
-    def test_can_get_start_radio_from_api(self):
-        url = reverse('api:v1:radios:sessions-list')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-        self.assertEqual(session.radio_type, 'random')
-        self.assertEqual(session.user, None)
-
-        self.client.login(username=self.user.username, password='test')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-        self.assertEqual(session.radio_type, 'random')
-        self.assertEqual(session.user, self.user)
-
-    def test_can_start_radio_for_anonymous_user(self):
-        url = reverse('api:v1:radios:sessions-list')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-
-        self.assertIsNone(session.user)
-        self.assertIsNotNone(session.session_key)
-
-    def test_can_get_track_for_session_from_api(self):
-        tracks = factories.TrackFactory.create_batch(size=1)
-
-        self.client.login(username=self.user.username, password='test')
-        url = reverse('api:v1:radios:sessions-list')
-        response = self.client.post(url, {'radio_type': 'random'})
-        session = models.RadioSession.objects.latest('id')
-
-        url = reverse('api:v1:radios:tracks-list')
-        response = self.client.post(url, {'session': session.pk})
-        data = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(data['track']['id'], tracks[0].id)
-        self.assertEqual(data['position'], 1)
-
-        next_track = factories.TrackFactory()
-        response = self.client.post(url, {'session': session.pk})
-        data = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(data['track']['id'], next_track.id)
-        self.assertEqual(data['position'], 2)
-
-    def test_related_object_radio_validate_related_object(self):
-        # cannot start without related object
-        radio = radios.ArtistRadio()
-        with self.assertRaises(ValidationError):
-            radio.start_session(self.user)
-
-        # cannot start with bad related object type
-        radio = radios.ArtistRadio()
-        with self.assertRaises(ValidationError):
-            radio.start_session(self.user, related_object=self.user)
-
-    def test_can_start_artist_radio(self):
-        artist = factories.ArtistFactory()
-        wrong_tracks = factories.TrackFactory.create_batch(size=30)
-        good_tracks = factories.TrackFactory.create_batch(
-            artist=artist, size=5)
-
-        radio = radios.ArtistRadio()
-        session = radio.start_session(self.user, related_object=artist)
-        self.assertEqual(session.radio_type, 'artist')
-        for i in range(5):
-            self.assertIn(radio.pick(), good_tracks)
-
-    def test_can_start_tag_radio(self):
-        tag = factories.TagFactory()
-        wrong_tracks = factories.TrackFactory.create_batch(size=30)
-        good_tracks = factories.TrackFactory.create_batch(size=5)
-        for track in good_tracks:
-            track.tags.add(tag)
-
-        radio = radios.TagRadio()
-        session = radio.start_session(self.user, related_object=tag)
-        self.assertEqual(session.radio_type, 'tag')
-        for i in range(5):
-            self.assertIn(radio.pick(), good_tracks)
-
-    def test_can_start_artist_radio_from_api(self):
-        artist = factories.ArtistFactory()
-        url = reverse('api:v1:radios:sessions-list')
-
-        response = self.client.post(url, {'radio_type': 'artist', 'related_object_id': artist.id})
-        session = models.RadioSession.objects.latest('id')
-        self.assertEqual(session.radio_type, 'artist')
-        self.assertEqual(session.related_object, artist)
-
-    def test_can_start_less_listened_radio(self):
-        history = ListeningFactory.create_batch(size=5, user=self.user)
-        wrong_tracks = [h.track for h in history]
-
-        good_tracks = factories.TrackFactory.create_batch(size=30)
-
-        radio = radios.LessListenedRadio()
-        session = radio.start_session(self.user)
-        self.assertEqual(session.related_object, self.user)
-        for i in range(5):
-            self.assertIn(radio.pick(), good_tracks)
diff --git a/api/funkwhale_api/users/tests/factories.py b/api/funkwhale_api/users/factories.py
similarity index 82%
rename from api/funkwhale_api/users/tests/factories.py
rename to api/funkwhale_api/users/factories.py
index 351884ff4543a6625cb24dba904ff6c2b415bae3..0af155e77339177323d46f32f2ad9a7e6cf99f5c 100644
--- a/api/funkwhale_api/users/tests/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -1,12 +1,14 @@
 import factory
 
+from funkwhale_api.factories import registry
 from django.contrib.auth.models import Permission
 
 
+@registry.register
 class UserFactory(factory.django.DjangoModelFactory):
     username = factory.Sequence(lambda n: 'user-{0}'.format(n))
     email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
-    password = factory.PostGenerationMethodCall('set_password', 'password')
+    password = factory.PostGenerationMethodCall('set_password', 'test')
 
     class Meta:
         model = 'users.User'
@@ -28,3 +30,9 @@ class UserFactory(factory.django.DjangoModelFactory):
             ]
             # A list of permissions were passed in, use them
             self.user_permissions.add(*perms)
+
+
+@registry.register(name='users.SuperUser')
+class SuperUserFactory(UserFactory):
+    is_staff = True
+    is_superuser = True
diff --git a/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd75795d3fae3b63cb1e5f8830d1aaec7acf2118
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.0 on 2017-12-26 13:57
+
+from django.db import migrations, models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0002_auto_20171214_2205'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='secret_key',
+            field=models.UUIDField(default=uuid.uuid4, null=True),
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='last_name',
+            field=models.CharField(blank=True, max_length=150, verbose_name='last name'),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index c8d0b534c8b84706fcd050eee819e889e186f525..3a0baf11a30c73397b2c31d14fe1ec29d9557a7c 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -1,6 +1,8 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals, absolute_import
 
+import uuid
+
 from django.contrib.auth.models import AbstractUser
 from django.urls import reverse
 from django.db import models
@@ -15,6 +17,8 @@ class User(AbstractUser):
     # around the globe.
     name = models.CharField(_("Name of User"), blank=True, max_length=255)
 
+    # updated on logout or password change, to invalidate JWT
+    secret_key = models.UUIDField(default=uuid.uuid4, null=True)
     # permissions that are used for API access and that worth serializing
     relevant_permissions = {
         # internal_codename : {external_codename}
@@ -31,3 +35,11 @@ class User(AbstractUser):
 
     def get_absolute_url(self):
         return reverse('users:detail', kwargs={'username': self.username})
+
+    def update_secret_key(self):
+        self.secret_key = uuid.uuid4()
+        return self.secret_key
+
+    def set_password(self, raw_password):
+        super().set_password(raw_password)
+        self.update_secret_key()
diff --git a/api/funkwhale_api/users/rest_auth_urls.py b/api/funkwhale_api/users/rest_auth_urls.py
index 9770e69e467cfb6ed377ff09ccbf6e81975be213..31f5384aa7f2a750bcaa4fc9063658876fbbd968 100644
--- a/api/funkwhale_api/users/rest_auth_urls.py
+++ b/api/funkwhale_api/users/rest_auth_urls.py
@@ -2,11 +2,15 @@ from django.views.generic import TemplateView
 from django.conf.urls import url
 
 from rest_auth.registration.views import VerifyEmailView
+from rest_auth.views import PasswordChangeView
+
 from .views import RegisterView
 
+
 urlpatterns = [
     url(r'^$', RegisterView.as_view(), name='rest_register'),
     url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'),
+    url(r'^change-password/$', PasswordChangeView.as_view(), name='change_password'),
 
     # This url is used by django-allauth and empty TemplateView is
     # defined just to allow reverse() call inside app, for example when email
diff --git a/api/funkwhale_api/users/tests/__init__.py b/api/funkwhale_api/users/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/users/tests/test_admin.py b/api/funkwhale_api/users/tests/test_admin.py
deleted file mode 100644
index 10b07b749dc56476687502d86cb9fd6e99d71274..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/users/tests/test_admin.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from test_plus.test import TestCase
-
-from ..admin import MyUserCreationForm
-
-
-class TestMyUserCreationForm(TestCase):
-
-    def setUp(self):
-        self.user = self.make_user()
-
-    def test_clean_username_success(self):
-        # Instantiate the form with a new username
-        form = MyUserCreationForm({
-            'username': 'alamode',
-            'password1': '123456',
-            'password2': '123456',
-        })
-        # Run is_valid() to trigger the validation
-        valid = form.is_valid()
-        self.assertTrue(valid)
-
-        # Run the actual clean_username method
-        username = form.clean_username()
-        self.assertEqual('alamode', username)
-
-    def test_clean_username_false(self):
-        # Instantiate the form with the same username as self.user
-        form = MyUserCreationForm({
-            'username': self.user.username,
-            'password1': '123456',
-            'password2': '123456',
-        })
-        # Run is_valid() to trigger the validation, which is going to fail
-        # because the username is already taken
-        valid = form.is_valid()
-        self.assertFalse(valid)
-
-        # The form.errors dict should contain a single error called 'username'
-        self.assertTrue(len(form.errors) == 1)
-        self.assertTrue('username' in form.errors)
diff --git a/api/funkwhale_api/users/tests/test_models.py b/api/funkwhale_api/users/tests/test_models.py
deleted file mode 100644
index fbc7eb5f9a944c82be63d3beb9fe4a2ef958afff..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/users/tests/test_models.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from test_plus.test import TestCase
-
-
-class TestUser(TestCase):
-
-    def setUp(self):
-        self.user = self.make_user()
-
-    def test__str__(self):
-        self.assertEqual(
-            self.user.__str__(),
-            "testuser"  # This is the default username for self.make_user()
-        )
diff --git a/api/funkwhale_api/users/tests/test_views.py b/api/funkwhale_api/users/tests/test_views.py
deleted file mode 100644
index 52826cfa4b53da83487d86bea8ca0899d5e2e97c..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/users/tests/test_views.py
+++ /dev/null
@@ -1,73 +0,0 @@
-import json
-
-from django.test import RequestFactory
-
-from test_plus.test import TestCase
-from funkwhale_api.users.models import User
-
-from . factories import UserFactory
-
-
-class UserTestCase(TestCase):
-
-    def setUp(self):
-        self.user = self.make_user()
-        self.factory = RequestFactory()
-
-    def test_can_create_user_via_api(self):
-        url = self.reverse('rest_register')
-        data = {
-            'username': 'test1',
-            'email': 'test1@test.com',
-            'password1': 'testtest',
-            'password2': 'testtest',
-        }
-        with self.settings(REGISTRATION_MODE="public"):
-            response = self.client.post(url, data)
-        self.assertEqual(response.status_code, 201)
-
-        u = User.objects.get(email='test1@test.com')
-        self.assertEqual(u.username, 'test1')
-
-    def test_can_disable_registration_view(self):
-        url = self.reverse('rest_register')
-        data = {
-            'username': 'test1',
-            'email': 'test1@test.com',
-            'password1': 'testtest',
-            'password2': 'testtest',
-        }
-        with self.settings(REGISTRATION_MODE="disabled"):
-            response = self.client.post(url, data)
-        self.assertEqual(response.status_code, 403)
-
-    def test_can_fetch_data_from_api(self):
-        url = self.reverse('api:v1:users:users-me')
-        response = self.client.get(url)
-        # login required
-        self.assertEqual(response.status_code, 401)
-
-        user = UserFactory(
-            is_staff=True,
-            perms=[
-                'music.add_importbatch',
-                'dynamic_preferences.change_globalpreferencemodel',
-            ]
-        )
-        self.assertTrue(user.has_perm('music.add_importbatch'))
-        self.login(user)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-        payload = json.loads(response.content.decode('utf-8'))
-
-        self.assertEqual(payload['username'], user.username)
-        self.assertEqual(payload['is_staff'], user.is_staff)
-        self.assertEqual(payload['is_superuser'], user.is_superuser)
-        self.assertEqual(payload['email'], user.email)
-        self.assertEqual(payload['name'], user.name)
-        self.assertEqual(
-            payload['permissions']['import.launch']['status'], True)
-        self.assertEqual(
-            payload['permissions']['settings.change']['status'], True)
diff --git a/api/funkwhale_api/utils/tests.py b/api/funkwhale_api/utils/tests.py
deleted file mode 100644
index 2605d3b4c9ff0c5b78393ca515afa9efde6b5308..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/utils/tests.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import tempfile
-import shutil
-
-
-class TMPDirTestCaseMixin(object):
-    def setUp(self):
-        super().tearDown()
-        self.download_dir = tempfile.mkdtemp()
-
-    def tearDown(self):
-        super().tearDown()
-        shutil.rmtree(self.download_dir)
diff --git a/api/pytest.ini b/api/pytest.ini
index 4ab907403097c796663f02d6a878ccd197970a23..9be63d3531626526f46f196b840c02a9cae1c8b3 100644
--- a/api/pytest.ini
+++ b/api/pytest.ini
@@ -3,3 +3,4 @@ DJANGO_SETTINGS_MODULE=config.settings.test
 
 # -- recommended but optional:
 python_files = tests.py test_*.py *_tests.py
+testpatsh = tests
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index aee1222591882c83062e190a2a7c8b36a10dc7d7..7e56b6cfda24f977288de6ed3b91a5df42f1fde0 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -49,7 +49,7 @@ mutagen>=1.39,<1.40
 
 # Until this is merged
 #django-taggit>=0.22,<0.23
-git+https://github.com/jdufresne/django-taggit.git@e8f7f216f04c9781bebc84363ab24d575f948ede
+git+https://github.com/alex/django-taggit.git@95776ac66948ed7ba7c12e35c1170551e3be66a5
 # Until this is merged
 git+https://github.com/EliotBerriot/PyMemoize.git@django
 # Until this is merged
diff --git a/api/requirements/local.txt b/api/requirements/local.txt
index d8a1561e0a31705150d00bdf48ffeaf3431ee4da..b466b20fdfc20c5815fb2f7be8daedccc3f360ac 100644
--- a/api/requirements/local.txt
+++ b/api/requirements/local.txt
@@ -5,7 +5,6 @@ django_coverage_plugin>=1.5,<1.6
 Sphinx>=1.6,<1.7
 django-extensions>=1.9,<1.10
 Werkzeug>=0.13,<0.14
-django-test-plus>=1.0.20
 factory_boy>=2.8.1
 
 # django-debug-toolbar that works with Django 1.5+
diff --git a/api/requirements/test.txt b/api/requirements/test.txt
index bde5a2df97b8b21f2a1d7dfe20ad4e18d38eea1c..c12b44827ebbf9440e180de7d686b8f2e190118c 100644
--- a/api/requirements/test.txt
+++ b/api/requirements/test.txt
@@ -2,7 +2,10 @@
 
 flake8
 pytest
-pytest-django
+# pytest-django until a new release containing django_assert_num_queries
+# is deployed to pypi
+git+https://github.com/pytest-dev/pytest-django.git@d3d9bb3ef6f0377cb5356eb368992a0834692378
+
 pytest-mock
 pytest-sugar
 pytest-xdist
diff --git a/api/funkwhale_api/downloader/tests/__init__.py b/api/tests/__init__.py
similarity index 100%
rename from api/funkwhale_api/downloader/tests/__init__.py
rename to api/tests/__init__.py
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..37f3dae3afd72f88db2d7f1c2a9c5a40a6f43cec
--- /dev/null
+++ b/api/tests/conftest.py
@@ -0,0 +1,42 @@
+import tempfile
+import shutil
+import pytest
+
+
+@pytest.fixture(scope="session", autouse=True)
+def factories_autodiscover():
+    from django.apps import apps
+    from funkwhale_api import factories
+    app_names = [app.name for app in apps.app_configs.values()]
+    factories.registry.autodiscover(app_names)
+
+
+@pytest.fixture
+def factories(db):
+    from funkwhale_api import factories
+    yield factories.registry
+
+
+@pytest.fixture
+def tmpdir():
+    d = tempfile.mkdtemp()
+    yield d
+    shutil.rmtree(d)
+
+
+@pytest.fixture
+def logged_in_client(db, factories, client):
+    user = factories['users.User']()
+    assert client.login(username=user.username, password='test')
+    setattr(client, 'user', user)
+    yield client
+    delattr(client, 'user')
+
+
+@pytest.fixture
+def superuser_client(db, factories, client):
+    user = factories['users.SuperUser']()
+    assert client.login(username=user.username, password='test')
+    setattr(client, 'user', user)
+    yield client
+    delattr(client, 'user')
diff --git a/api/funkwhale_api/providers/youtube/tests/data.py b/api/tests/data/youtube.py
similarity index 100%
rename from api/funkwhale_api/providers/youtube/tests/data.py
rename to api/tests/data/youtube.py
diff --git a/api/funkwhale_api/providers/audiofile/tests/dummy_file.ogg b/api/tests/files/dummy_file.ogg
similarity index 100%
rename from api/funkwhale_api/providers/audiofile/tests/dummy_file.ogg
rename to api/tests/files/dummy_file.ogg
diff --git a/api/funkwhale_api/favorites/tests/__init__.py b/api/tests/music/__init__.py
similarity index 100%
rename from api/funkwhale_api/favorites/tests/__init__.py
rename to api/tests/music/__init__.py
diff --git a/api/funkwhale_api/music/tests/cover.py b/api/tests/music/cover.py
similarity index 100%
rename from api/funkwhale_api/music/tests/cover.py
rename to api/tests/music/cover.py
diff --git a/api/funkwhale_api/music/tests/data.py b/api/tests/music/data.py
similarity index 100%
rename from api/funkwhale_api/music/tests/data.py
rename to api/tests/music/data.py
diff --git a/api/funkwhale_api/music/tests/mocking/lyricswiki.py b/api/tests/music/mocking/lyricswiki.py
similarity index 100%
rename from api/funkwhale_api/music/tests/mocking/lyricswiki.py
rename to api/tests/music/mocking/lyricswiki.py
diff --git a/api/funkwhale_api/music/tests/test.mp3 b/api/tests/music/test.mp3
similarity index 100%
rename from api/funkwhale_api/music/tests/test.mp3
rename to api/tests/music/test.mp3
diff --git a/api/funkwhale_api/music/tests/test.ogg b/api/tests/music/test.ogg
similarity index 100%
rename from api/funkwhale_api/music/tests/test.ogg
rename to api/tests/music/test.ogg
diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..e29aaf107c922f117e63c2f67c5ca613bce6733b
--- /dev/null
+++ b/api/tests/music/test_api.py
@@ -0,0 +1,253 @@
+import json
+import pytest
+from django.urls import reverse
+
+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_submit_youtube_url_for_track_import(mocker, superuser_client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.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')
+    response = superuser_client.post(
+        url,
+        {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
+         'mbid': mbid})
+    track = models.Track.objects.get(mbid=mbid)
+    assert track.artist.name == 'Adhesive Wombat'
+    assert track.album.title == 'Marsupial Madness'
+
+
+def test_import_creates_an_import_with_correct_data(superuser_client, settings):
+    mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
+    video_id = 'tPEE9ZwTmy0'
+    url = reverse('api:v1:submit-single')
+    settings.CELERY_ALWAYS_EAGER = False
+    response = superuser_client.post(
+        url,
+        {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
+         'mbid': mbid})
+
+    batch = models.ImportBatch.objects.latest('id')
+    assert batch.jobs.count() == 1
+    assert batch.submitted_by == superuser_client.user
+    assert batch.status == 'pending'
+    job = batch.jobs.first()
+    assert str(job.mbid) == mbid
+    assert job.status == 'pending'
+    assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id)
+
+
+def test_can_import_whole_album(mocker, superuser_client, settings):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.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'])
+    payload = {
+        'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
+        'tracks': [
+            {
+            'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
+            'source': 'https://www.youtube.com/watch?v=1111111111',
+            },
+            {
+            'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
+            'source': 'https://www.youtube.com/watch?v=2222222222',
+            },
+            {
+            'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
+            'source': 'https://www.youtube.com/watch?v=3333333333',
+            },
+        ]
+    }
+    url = reverse('api:v1:submit-album')
+    settings.CELERY_ALWAYS_EAGER = False
+    response = superuser_client.post(
+        url, json.dumps(payload), content_type="application/json")
+
+    batch = models.ImportBatch.objects.latest('id')
+    assert batch.jobs.count() == 3
+    assert batch.submitted_by == superuser_client.user
+    assert batch.status == 'pending'
+
+    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]
+    assert int(medium_data['track-count']) == album.tracks.all().count()
+
+    for track in medium_data['track-list']:
+        instance = models.Track.objects.get(mbid=track['recording']['id'])
+        assert instance.title == track['recording']['title']
+        assert instance.position == int(track['position'])
+        assert instance.title == track['recording']['title']
+
+    for row in payload['tracks']:
+        job = models.ImportJob.objects.get(mbid=row['mbid'])
+        assert str(job.mbid) == row['mbid']
+        assert job.status == 'pending'
+        assert job.source == row['source']
+
+
+def test_can_import_whole_artist(mocker, superuser_client, settings):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.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'])
+    payload = {
+        'artistId': 'mbid',
+        'albums': [
+            {
+                'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
+                'tracks': [
+                    {
+                    'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
+                    'source': 'https://www.youtube.com/watch?v=1111111111',
+                    },
+                    {
+                    'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
+                    'source': 'https://www.youtube.com/watch?v=2222222222',
+                    },
+                    {
+                    'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
+                    'source': 'https://www.youtube.com/watch?v=3333333333',
+                    },
+                ]
+            }
+        ]
+    }
+    url = reverse('api:v1:submit-artist')
+    settings.CELERY_ALWAYS_EAGER = False
+    response = superuser_client.post(
+        url, json.dumps(payload), content_type="application/json")
+
+    batch = models.ImportBatch.objects.latest('id')
+    assert batch.jobs.count() == 3
+    assert batch.submitted_by == superuser_client.user
+    assert batch.status == 'pending'
+
+    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]
+    assert int(medium_data['track-count']) == album.tracks.all().count()
+
+    for track in medium_data['track-list']:
+        instance = models.Track.objects.get(mbid=track['recording']['id'])
+        assert instance.title == track['recording']['title']
+        assert instance.position == int(track['position'])
+        assert instance.title == track['recording']['title']
+
+    for row in payload['albums'][0]['tracks']:
+        job = models.ImportJob.objects.get(mbid=row['mbid'])
+        assert str(job.mbid) == row['mbid']
+        assert job.status == 'pending'
+        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)
+    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'))
+    assert results['count'] == 1
+    assert results['results'][0]['jobs'][0]['mbid'] == job.mbid
+
+
+def test_can_search_artist(factories, 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
+
+
+def test_can_search_artist_by_name_start(factories, client):
+    artist1 = factories['music.Artist'](name='alpha')
+    artist2 = factories['music.Artist'](name='beta')
+    expected = {
+        'next': None,
+        'previous': None,
+        'count': 1,
+        'results': [serializers.ArtistSerializerNested(artist1).data]
+    }
+    url = reverse('api:v1:artists-list')
+    response = client.get(url, {'name__startswith': 'a'})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_search_tracks(factories, 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})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+@pytest.mark.parametrize('route,method', [
+    ('api:v1:tags-list', 'get'),
+    ('api:v1:tracks-list', 'get'),
+    ('api:v1:artists-list', 'get'),
+    ('api:v1:albums-list', 'get'),
+])
+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):
+    settings.API_AUTHENTICATION_REQUIRED = True
+    f = factories['music.TrackFile']()
+    assert f.audio_file is not None
+    url = f.path
+    response = client.get(url)
+    assert response.status_code == 401
+
+    user = factories['users.SuperUser']()
+    client.login(username=user.username, password='test')
+    response = 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_lyrics.py b/api/tests/music/test_lyrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..3670a2e5c1bfb1e24b673c86613fe16073f0d201
--- /dev/null
+++ b/api/tests/music/test_lyrics.py
@@ -0,0 +1,73 @@
+import json
+from django.urls import reverse
+
+from funkwhale_api.music import models
+from funkwhale_api.musicbrainz import api
+from funkwhale_api.music import serializers
+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):
+    mocker.patch(
+        'funkwhale_api.music.lyrics._get_html',
+        return_value=lyricswiki.content)
+    lyrics = factories['music.Lyrics'](
+        url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
+
+    lyrics.fetch_content()
+    self.assertIn(
+        'Grab a brush and put on a little makeup',
+        lyrics.content,
+    )
+
+
+def test_clean_content():
+    c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>"""
+    d = lyrics_utils.extract_content(c)
+    d = lyrics_utils.clean_content(d)
+
+    expected = """Hello
+Is it me you're looking for?
+"""
+    assert d == expected
+
+
+def test_markdown_rendering(factories):
+    content = """Hello
+Is it me you're looking for?"""
+
+    l = factories['music.Lyrics'](content=content)
+
+    expected = "<p>Hello<br />\nIs it me you're looking for?</p>"
+    assert expected == l.content_rendered
+
+
+def test_works_import_lyrics_if_any(mocker, factories, logged_in_client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.tracks['get']['chop_suey'])
+    mocker.patch(
+        'funkwhale_api.music.lyrics._get_html',
+        return_value=lyricswiki.content)
+    track = factories['music.Track'](
+        work=None,
+        mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+
+    url = reverse('api:v1:tracks-lyrics', kwargs={'pk': track.pk})
+    response = logged_in_client.get(url)
+
+    assert response.status_code == 200
+
+    track.refresh_from_db()
+    lyrics = models.Lyrics.objects.latest('id')
+    work = models.Work.objects.latest('id')
+
+    assert track.work == work
+    assert lyrics.work == work
diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py
new file mode 100644
index 0000000000000000000000000000000000000000..5df2dbcf117965cf41563a6d028cbe804c3e0fd8
--- /dev/null
+++ b/api/tests/music/test_metadata.py
@@ -0,0 +1,41 @@
+import datetime
+import os
+import pytest
+
+from funkwhale_api.music import metadata
+
+DATA_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+@pytest.mark.parametrize('field,value', [
+    ('title', 'Peer Gynt Suite no. 1, op. 46: I. Morning'),
+    ('artist', 'Edvard Grieg'),
+    ('album', 'Peer Gynt Suite no. 1, op. 46'),
+    ('date', datetime.date(2012, 8, 15)),
+    ('track_number', 1),
+    ('musicbrainz_albumid', 'a766da8b-8336-47aa-a3ee-371cc41ccc75'),
+    ('musicbrainz_recordingid', 'bd21ac48-46d8-4e78-925f-d9cc2a294656'),
+    ('musicbrainz_artistid', '013c8e5b-d72a-4cd3-8dee-6c64d6125823'),
+])
+def test_can_get_metadata_from_ogg_file(field, value):
+    path = os.path.join(DATA_DIR, 'test.ogg')
+    data = metadata.Metadata(path)
+
+    assert data.get(field) == value
+
+
+@pytest.mark.parametrize('field,value', [
+    ('title', 'Bend'),
+    ('artist', 'Binärpilot'),
+    ('album', 'You Can\'t Stop Da Funk'),
+    ('date', datetime.date(2006, 2, 7)),
+    ('track_number', 1),
+    ('musicbrainz_albumid', 'ce40cdb1-a562-4fd8-a269-9269f98d4124'),
+    ('musicbrainz_recordingid', 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb'),
+    ('musicbrainz_artistid', '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13'),
+])
+def test_can_get_metadata_from_id3_mp3_file(field, value):
+    path = os.path.join(DATA_DIR, 'test.mp3')
+    data = metadata.Metadata(path)
+
+    assert data.get(field) == value
diff --git a/api/funkwhale_api/music/tests/test_models.py b/api/tests/music/test_models.py
similarity index 78%
rename from api/funkwhale_api/music/tests/test_models.py
rename to api/tests/music/test_models.py
index 4b43e46382ad28d4bbf382fef854c39b8cae5d48..2ec192517bbf7d060baf37bbcbb5670f1bb2635a 100644
--- a/api/funkwhale_api/music/tests/test_models.py
+++ b/api/tests/music/test_models.py
@@ -2,16 +2,14 @@ import pytest
 
 from funkwhale_api.music import models
 from funkwhale_api.music import importers
-from . import factories
 
 
-def test_can_store_release_group_id_on_album(db):
-    album = factories.AlbumFactory()
+def test_can_store_release_group_id_on_album(factories):
+    album = factories['music.Album']()
     assert album.release_group_id is not None
 
 
-def test_import_album_stores_release_group(db):
-
+def test_import_album_stores_release_group(factories):
     album_data = {
         "artist-credit": [
             {
@@ -31,7 +29,7 @@ def test_import_album_stores_release_group(db):
         "title": "Marsupial Madness",
         'release-group': {'id': '447b4979-2178-405c-bfe6-46bf0b09e6c7'}
     }
-    artist = factories.ArtistFactory(
+    artist = factories['music.Artist'](
         mbid=album_data['artist-credit'][0]['artist']['id']
     )
     cleaned_data = models.Album.clean_musicbrainz_data(album_data)
@@ -41,9 +39,9 @@ def test_import_album_stores_release_group(db):
     assert album.artist == artist
 
 
-def test_import_job_is_bound_to_track_file(db, mocker):
-    track = factories.TrackFactory()
-    job = factories.ImportJobFactory(mbid=track.mbid)
+def test_import_job_is_bound_to_track_file(factories, mocker):
+    track = factories['music.Track']()
+    job = factories['music.ImportJob'](mbid=track.mbid)
 
     mocker.patch('funkwhale_api.music.models.TrackFile.download_file')
     job.run()
diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py
new file mode 100644
index 0000000000000000000000000000000000000000..076ad2bd05cb714c6436592666dffeeae61396ba
--- /dev/null
+++ b/api/tests/music/test_music.py
@@ -0,0 +1,138 @@
+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):
+    mocker.patch(
+        'musicbrainzngs.search_artists',
+        return_value=api_data.artists['search']['adhesive_wombat'])
+    artist = models.Artist.create_from_api(query="Adhesive wombat")
+    data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
+
+    assert int(data['ext:score']), 100
+    assert data['id'], '62c3befb-6366-4585-b256-809472333801'
+    assert artist.mbid, data['id']
+    assert artist.name, 'Adhesive Wombat'
+
+
+def test_can_create_album_from_api(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.search',
+        return_value=api_data.albums['search']['hypnotize'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.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]
+
+    assert album.mbid, data['id']
+    assert album.title, 'Hypnotize'
+    with pytest.raises(ValueError):
+        assert album.cover.path is not None
+    assert album.release_date, datetime.date(2005, 1, 1)
+    assert album.artist.name, 'System of a Down'
+    assert album.artist.mbid, data['artist-credit'][0]['artist']['id']
+
+
+def test_can_create_track_from_api(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.search',
+        return_value=api_data.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
+    assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed'
+    assert track.mbid == data['id']
+    assert track.artist.pk is not None
+    assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801'
+    assert track.artist.name == 'Adhesive Wombat'
+    assert str(track.album.mbid) == 'a50d2a81-2a50-484d-9cb4-b9f6833f583e'
+    assert track.album.title == 'Marsupial Madness'
+
+
+def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.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()]
+    for tag in expected_tags:
+        assert tag in track_tags
+
+
+def test_can_get_or_create_track_from_api(mocker, db):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['adhesive_wombat'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get']['marsupial'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.search',
+        return_value=api_data.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
+    assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed'
+    assert track.mbid == data['id']
+    assert track.artist.pk is not None
+    assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801'
+    assert track.artist.name == 'Adhesive Wombat'
+
+    track2, created = models.Track.get_or_create_from_api(mbid=data['id'])
+    assert not created
+    assert track == track2
+
+
+def test_album_tags_deduced_from_tracks_tags(factories, django_assert_num_queries):
+    tag = factories['taggit.Tag']()
+    album = factories['music.Album']()
+    tracks = factories['music.Track'].create_batch(
+        5, album=album, tags=[tag])
+
+    album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk)
+
+    with django_assert_num_queries(0):
+        assert tag in album.tags
+
+
+def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_queries):
+    tag = factories['taggit.Tag']()
+    album = factories['music.Album']()
+    artist = album.artist
+    tracks = factories['music.Track'].create_batch(
+        5, album=album, tags=[tag])
+
+    artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk)
+
+    with django_assert_num_queries(0):
+        assert tag in artist.tags
+
+
+def test_can_download_image_file_for_album(mocker, factories):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.images.get_front',
+        return_value=binary_data)
+    # 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
diff --git a/api/tests/music/test_works.py b/api/tests/music/test_works.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b72768ad07bf05adae848eef73784866de83c7d
--- /dev/null
+++ b/api/tests/music/test_works.py
@@ -0,0 +1,65 @@
+import json
+from django.urls import reverse
+
+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):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    recording = factories['music.Track'](
+        mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+    mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+    work = models.Work.create_from_api(id=mbid)
+
+    assert work.title == 'Chop Suey!'
+    assert work.nature == 'song'
+    assert work.language == 'eng'
+    assert work.mbid == mbid
+
+    # a imported work should also be linked to corresponding recordings
+
+    recording.refresh_from_db()
+    assert recording.work == work
+
+
+def test_can_get_work_from_recording(factories, mocker):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.get',
+        return_value=api_data.tracks['get']['chop_suey'])
+    recording = factories['music.Track'](
+        work=None,
+        mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
+    mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+
+    assert recording.work == None
+
+    work = recording.get_work()
+
+    assert work.title == 'Chop Suey!'
+    assert work.nature == 'song'
+    assert work.language == 'eng'
+    assert work.mbid == mbid
+
+    recording.refresh_from_db()
+    assert recording.work == work
+
+
+def test_works_import_lyrics_if_any(db, mocker):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.works.get',
+        return_value=api_data.works['get']['chop_suey'])
+    mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
+    work = models.Work.create_from_api(id=mbid)
+
+    lyrics = models.Lyrics.objects.latest('id')
+    assert lyrics.work == work
+    assert lyrics.url == 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!'
diff --git a/api/funkwhale_api/history/tests/__init__.py b/api/tests/musicbrainz/__init__.py
similarity index 100%
rename from api/funkwhale_api/history/tests/__init__.py
rename to api/tests/musicbrainz/__init__.py
diff --git a/api/funkwhale_api/musicbrainz/tests/data.py b/api/tests/musicbrainz/data.py
similarity index 100%
rename from api/funkwhale_api/musicbrainz/tests/data.py
rename to api/tests/musicbrainz/data.py
diff --git a/api/tests/musicbrainz/test_api.py b/api/tests/musicbrainz/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..bbade340060dae4cbc4f2a64d296eb86e6c59e79
--- /dev/null
+++ b/api/tests/musicbrainz/test_api.py
@@ -0,0 +1,90 @@
+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):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.recordings.search',
+        return_value=api_data.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})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_search_release_in_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.search',
+        return_value=api_data.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})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_search_artists_in_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.search',
+        return_value=api_data.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})
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_get_artist_in_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.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']
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.release_groups.browse',
+        return_value=api_data.release_groups['browse']['lost fingers'])
+    uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
+    url = reverse(
+        'api:v1:providers:musicbrainz:release-group-browse',
+        kwargs={
+            'artist_uuid': uuid,
+        }
+    )
+    response = client.get(url)
+    expected = api_data.release_groups['browse']['lost fingers']
+
+    assert expected == json.loads(response.content.decode('utf-8'))
+
+
+def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.browse',
+        return_value=api_data.releases['browse']['Lost in the 80s'])
+    uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
+    url = reverse(
+        'api:v1:providers:musicbrainz:release-browse',
+        kwargs={
+            'release_group_uuid': uuid,
+        }
+    )
+    response = client.get(url)
+    expected = api_data.releases['browse']['Lost in the 80s']
+
+    assert expected == json.loads(response.content.decode('utf-8'))
diff --git a/api/tests/musicbrainz/test_cache.py b/api/tests/musicbrainz/test_cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe0d5677302b08bb3f75c45a31f23f3238d034ff
--- /dev/null
+++ b/api/tests/musicbrainz/test_cache.py
@@ -0,0 +1,13 @@
+from funkwhale_api.musicbrainz import client
+
+
+def test_can_search_recording_in_musicbrainz_api(mocker):
+    r = {'hello': 'world'}
+    m = mocker.patch(
+        'funkwhale_api.musicbrainz.client._api.search_artists',
+        return_value=r)
+    assert client.api.artists.search('test') == r
+    # now call from cache
+    assert client.api.artists.search('test') == r
+    assert client.api.artists.search('test') == r
+    assert m.call_count == 1
diff --git a/api/tests/test_disk_import.py b/api/tests/test_disk_import.py
new file mode 100644
index 0000000000000000000000000000000000000000..9aaf399750e3c756c9087d58e6e61931834ab19a
--- /dev/null
+++ b/api/tests/test_disk_import.py
@@ -0,0 +1,39 @@
+import os
+import datetime
+
+from funkwhale_api.providers.audiofile import tasks
+
+DATA_DIR = os.path.join(
+    os.path.dirname(os.path.abspath(__file__)),
+    'files'
+)
+
+
+def test_can_import_single_audio_file(db, mocker):
+    metadata = {
+        'artist': ['Test artist'],
+        'album': ['Test album'],
+        'title': ['Test track'],
+        'TRACKNUMBER': ['4'],
+        'date': ['2012-08-15'],
+        'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'],
+        'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'],
+        'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'],
+    }
+
+    m1 = mocker.patch('mutagen.File', return_value=metadata)
+    m2 = mocker.patch(
+        'funkwhale_api.music.metadata.Metadata.get_file_type',
+        return_value='OggVorbis',
+    )
+    track_file = tasks.from_path(os.path.join(DATA_DIR, 'dummy_file.ogg'))
+    track = track_file.track
+
+    assert track.title == metadata['title'][0]
+    assert track.mbid == metadata['musicbrainz_trackid'][0]
+    assert track.position == 4
+    assert track.album.title == metadata['album'][0]
+    assert track.album.mbid == metadata['musicbrainz_albumid'][0]
+    assert track.album.release_date == datetime.date(2012, 8, 15)
+    assert track.artist.name == metadata['artist'][0]
+    assert track.artist.mbid == metadata['musicbrainz_artistid'][0]
diff --git a/api/tests/test_downloader.py b/api/tests/test_downloader.py
new file mode 100644
index 0000000000000000000000000000000000000000..ede7bb16cc9571607f0b3c73114010f02e411349
--- /dev/null
+++ b/api/tests/test_downloader.py
@@ -0,0 +1,11 @@
+import os
+
+from funkwhale_api import downloader
+
+
+def test_can_download_audio_from_youtube_url_to_vorbis(tmpdir):
+    data = downloader.download(
+        'https://www.youtube.com/watch?v=tPEE9ZwTmy0',
+        target_directory=tmpdir)
+    assert data['audio_file_path'] == os.path.join(tmpdir, 'tPEE9ZwTmy0.ogg')
+    assert os.path.exists(data['audio_file_path'])
diff --git a/api/tests/test_favorites.py b/api/tests/test_favorites.py
new file mode 100644
index 0000000000000000000000000000000000000000..418166d8e0c11aa133e87a44a55f7b1713a3ad2f
--- /dev/null
+++ b/api/tests/test_favorites.py
@@ -0,0 +1,92 @@
+import json
+import pytest
+from django.urls import reverse
+
+from funkwhale_api.music.models import Track, Artist
+from funkwhale_api.favorites.models import TrackFavorite
+
+
+
+def test_user_can_add_favorite(factories):
+    track = factories['music.Track']()
+    user = factories['users.User']()
+    f = TrackFavorite.add(track, user)
+
+    assert f.track == track
+    assert f.user == user
+
+
+def test_user_can_get_his_favorites(factories, logged_in_client, client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+    url = reverse('api:v1:favorites:tracks-list')
+    response = logged_in_client.get(url)
+
+    expected = [
+        {
+            'track': favorite.track.pk,
+            'id': favorite.id,
+            'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
+        }
+    ]
+    parsed_json = json.loads(response.content.decode('utf-8'))
+
+    assert expected == parsed_json['results']
+
+
+def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
+    track = factories['music.Track']()
+    url = reverse('api:v1:favorites:tracks-list')
+    response = logged_in_client.post(url, {'track': track.pk})
+
+    favorite = TrackFavorite.objects.latest('id')
+    expected = {
+        'track': track.pk,
+        'id': favorite.id,
+        'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
+    }
+    parsed_json = json.loads(response.content.decode('utf-8'))
+
+    assert expected == parsed_json
+    assert favorite.track == track
+    assert favorite.user == logged_in_client.user
+
+
+def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+    url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
+    response = client.delete(url, {'track': favorite.track.pk})
+    assert response.status_code == 204
+    assert TrackFavorite.objects.count() == 0
+
+def test_user_can_remove_favorite_via_api_using_track_id(factories, logged_in_client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+
+    url = reverse('api:v1:favorites:tracks-remove')
+    response = logged_in_client.delete(
+        url, json.dumps({'track': favorite.track.pk}),
+        content_type='application/json'
+    )
+
+    assert response.status_code == 204
+    assert TrackFavorite.objects.count() == 0
+
+
+@pytest.mark.parametrize('url,method', [
+    ('api:v1:favorites:tracks-list', 'get'),
+])
+def test_url_require_auth(url, method, db, settings, client):
+    settings.API_AUTHENTICATION_REQUIRED = True
+    url = reverse(url)
+    response = getattr(client, method)(url)
+    assert response.status_code == 401
+
+
+def test_can_filter_tracks_by_favorites(factories, logged_in_client):
+    favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
+
+    url = reverse('api:v1:tracks-list')
+    response = logged_in_client.get(url, data={'favorites': True})
+
+    parsed_json = json.loads(response.content.decode('utf-8'))
+    assert parsed_json['count'] == 1
+    assert parsed_json['results'][0]['id'] == favorite.track.id
diff --git a/api/tests/test_history.py b/api/tests/test_history.py
new file mode 100644
index 0000000000000000000000000000000000000000..113e5ff6405f60665305f04b379997e8b9343d9c
--- /dev/null
+++ b/api/tests/test_history.py
@@ -0,0 +1,42 @@
+import random
+import json
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+
+from funkwhale_api.history import models
+
+
+def test_can_create_listening(factories):
+    track = factories['music.Track']()
+    user = factories['users.User']()
+    now = timezone.now()
+    l = models.Listening.objects.create(user=user, track=track)
+
+
+def test_anonymous_user_can_create_listening_via_api(client, factories, settings):
+    settings.API_AUTHENTICATION_REQUIRED = False
+    track = factories['music.Track']()
+    url = reverse('api:v1:history:listenings-list')
+    response = client.post(url, {
+        'track': track.pk,
+    })
+
+    listening = models.Listening.objects.latest('id')
+
+    assert listening.track == track
+    assert listening.session_key == client.session.session_key
+
+
+def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories):
+    track = factories['music.Track']()
+
+    url = reverse('api:v1:history:listenings-list')
+    response = logged_in_client.post(url, {
+        'track': track.pk,
+    })
+
+    listening = models.Listening.objects.latest('id')
+
+    assert listening.track == track
+    assert listening.user == logged_in_client.user
diff --git a/api/tests/test_jwt_querystring.py b/api/tests/test_jwt_querystring.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd07e1dc3212476f01dd7c60f341e8718c60351c
--- /dev/null
+++ b/api/tests/test_jwt_querystring.py
@@ -0,0 +1,21 @@
+from django.urls import reverse
+from rest_framework_jwt.settings import api_settings
+
+jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
+jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
+
+
+def test_can_authenticate_using_token_param_in_url(factories, settings, client):
+    user = factories['users.User']()
+    settings.API_AUTHENTICATION_REQUIRED = True
+    url = reverse('api:v1:tracks-list')
+    response = client.get(url)
+
+    assert response.status_code == 401
+
+    payload = jwt_payload_handler(user)
+    token = jwt_encode_handler(payload)
+    response = client.get(url, data={
+        'jwt': token
+    })
+    assert response.status_code == 200
diff --git a/api/tests/test_playlists.py b/api/tests/test_playlists.py
new file mode 100644
index 0000000000000000000000000000000000000000..f496a64cb5d93a14aacc8bb73ddc2a58e3c7b50e
--- /dev/null
+++ b/api/tests/test_playlists.py
@@ -0,0 +1,54 @@
+import json
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+
+from funkwhale_api.playlists import models
+from funkwhale_api.playlists.serializers import PlaylistSerializer
+
+
+
+def test_can_create_playlist(factories):
+    tracks = factories['music.Track'].create_batch(5)
+    playlist = factories['playlists.Playlist']()
+
+    previous = None
+    for track in tracks:
+        previous = playlist.add_track(track, previous=previous)
+
+    playlist_tracks = list(playlist.playlist_tracks.all())
+
+    previous = None
+    for idx, track in enumerate(tracks):
+        plt = playlist_tracks[idx]
+        assert plt.position == idx
+        assert plt.track == track
+        if previous:
+            assert playlist_tracks[idx + 1] == previous
+        assert plt.playlist == playlist
+
+
+def test_can_create_playlist_via_api(logged_in_client):
+    url = reverse('api:v1:playlists-list')
+    data = {
+        'name': 'test',
+    }
+
+    response = logged_in_client.post(url, data)
+
+    playlist = logged_in_client.user.playlists.latest('id')
+    assert playlist.name == 'test'
+
+
+def test_can_add_playlist_track_via_api(factories, logged_in_client):
+    tracks = factories['music.Track'].create_batch(5)
+    playlist = factories['playlists.Playlist'](user=logged_in_client.user)
+    url = reverse('api:v1:playlist-tracks-list')
+    data = {
+        'playlist': playlist.pk,
+        'track': tracks[0].pk
+    }
+
+    response = logged_in_client.post(url, data)
+    plts = logged_in_client.user.playlists.latest('id').playlist_tracks.all()
+    assert plts.first().track == tracks[0]
diff --git a/api/tests/test_radios.py b/api/tests/test_radios.py
new file mode 100644
index 0000000000000000000000000000000000000000..d67611123ce0febb2623345d4fe388304b86f7bf
--- /dev/null
+++ b/api/tests/test_radios.py
@@ -0,0 +1,195 @@
+import json
+import random
+import pytest
+
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+
+
+from funkwhale_api.radios import radios
+from funkwhale_api.radios import models
+from funkwhale_api.favorites.models import TrackFavorite
+
+
+def test_can_pick_track_from_choices():
+    choices = [1, 2, 3, 4, 5]
+
+    radio = radios.SimpleRadio()
+
+    first_pick = radio.pick(choices=choices)
+
+    assert first_pick in choices
+
+    previous_choices = [first_pick]
+    for remaining_choice in choices:
+        pick = radio.pick(choices=choices, previous_choices=previous_choices)
+        assert pick in set(choices).difference(previous_choices)
+
+
+def test_can_pick_by_weight():
+    choices_with_weight = [
+        # choice, weight
+        (1, 1),
+        (2, 2),
+        (3, 3),
+        (4, 4),
+        (5, 5),
+    ]
+
+    picks = {choice: 0 for choice, weight in choices_with_weight}
+
+    for i in range(1000):
+        radio = radios.SimpleRadio()
+        pick = radio.weighted_pick(choices=choices_with_weight)
+        picks[pick] = picks[pick] + 1
+
+    assert picks[5] > picks[4]
+    assert picks[4] > picks[3]
+    assert picks[3] > picks[2]
+    assert picks[2] > picks[1]
+
+
+def test_can_get_choices_for_favorites_radio(factories):
+    tracks = factories['music.Track'].create_batch(100)
+    user = factories['users.User']()
+    for i in range(20):
+        TrackFavorite.add(track=random.choice(tracks), user=user)
+
+    radio = radios.FavoritesRadio()
+    choices = radio.get_choices(user=user)
+
+    assert choices.count() == user.track_favorites.all().count()
+
+    for favorite in user.track_favorites.all():
+        assert favorite.track in choices
+
+    for i in range(20):
+        pick = radio.pick(user=user)
+        assert pick in choices
+
+
+def test_can_use_radio_session_to_filter_choices(factories):
+    tracks = factories['music.Track'].create_batch(30)
+    user = factories['users.User']()
+    radio = radios.RandomRadio()
+    session = radio.start_session(user)
+
+    for i in range(30):
+        p = radio.pick()
+
+    # ensure 30 differents tracks have been suggested
+    tracks_id = [
+        session_track.track.pk
+        for session_track in session.session_tracks.all()]
+    assert len(set(tracks_id)) == 30
+
+
+def test_can_restore_radio_from_previous_session(factories):
+    user = factories['users.User']()
+    radio = radios.RandomRadio()
+    session = radio.start_session(user)
+
+    restarted_radio = radios.RandomRadio(session)
+    assert radio.session == restarted_radio.session
+
+
+def test_can_start_radio_for_logged_in_user(logged_in_client):
+    url = reverse('api:v1:radios:sessions-list')
+    response = logged_in_client.post(url, {'radio_type': 'random'})
+    session = models.RadioSession.objects.latest('id')
+    assert session.radio_type == 'random'
+    assert session.user == logged_in_client.user
+
+
+def test_can_start_radio_for_anonymous_user(client, db):
+    url = reverse('api:v1:radios:sessions-list')
+    response = client.post(url, {'radio_type': 'random'})
+    session = models.RadioSession.objects.latest('id')
+
+    assert session.radio_type == 'random'
+    assert session.user is None
+    assert session.session_key == client.session.session_key
+
+
+def test_can_get_track_for_session_from_api(factories, logged_in_client):
+    tracks = factories['music.Track'].create_batch(size=1)
+
+    url = reverse('api:v1:radios:sessions-list')
+    response = logged_in_client.post(url, {'radio_type': 'random'})
+    session = models.RadioSession.objects.latest('id')
+
+    url = reverse('api:v1:radios:tracks-list')
+    response = logged_in_client.post(url, {'session': session.pk})
+    data = json.loads(response.content.decode('utf-8'))
+
+    assert data['track']['id'] == tracks[0].id
+    assert data['position'] == 1
+
+    next_track = factories['music.Track']()
+    response = logged_in_client.post(url, {'session': session.pk})
+    data = json.loads(response.content.decode('utf-8'))
+
+    assert data['track']['id'] == next_track.id
+    assert data['position'] == 2
+
+
+def test_related_object_radio_validate_related_object(factories):
+    user = factories['users.User']()
+    # cannot start without related object
+    radio = radios.ArtistRadio()
+    with pytest.raises(ValidationError):
+        radio.start_session(user)
+
+    # cannot start with bad related object type
+    radio = radios.ArtistRadio()
+    with pytest.raises(ValidationError):
+        radio.start_session(user, related_object=user)
+
+
+def test_can_start_artist_radio(factories):
+    user = factories['users.User']()
+    artist = factories['music.Artist']()
+    wrong_tracks = factories['music.Track'].create_batch(5)
+    good_tracks = factories['music.Track'].create_batch(5, artist=artist)
+
+    radio = radios.ArtistRadio()
+    session = radio.start_session(user, related_object=artist)
+    assert session.radio_type == 'artist'
+    for i in range(5):
+        assert radio.pick() in good_tracks
+
+
+def test_can_start_tag_radio(factories):
+    user = factories['users.User']()
+    tag = factories['taggit.Tag']()
+    wrong_tracks = factories['music.Track'].create_batch(5)
+    good_tracks = factories['music.Track'].create_batch(5, tags=[tag])
+
+    radio = radios.TagRadio()
+    session = radio.start_session(user, related_object=tag)
+    assert session.radio_type =='tag'
+    for i in range(5):
+        assert radio.pick() in good_tracks
+
+
+def test_can_start_artist_radio_from_api(client, factories):
+    artist = factories['music.Artist']()
+    url = reverse('api:v1:radios:sessions-list')
+
+    response = client.post(
+        url, {'radio_type': 'artist', 'related_object_id': artist.id})
+    session = models.RadioSession.objects.latest('id')
+    assert session.radio_type, 'artist'
+    assert session.related_object, artist
+
+
+def test_can_start_less_listened_radio(factories):
+    user = factories['users.User']()
+    history = factories['history.Listening'].create_batch(5, user=user)
+    wrong_tracks = [h.track for h in history]
+    good_tracks = factories['music.Track'].create_batch(size=5)
+    radio = radios.LessListenedRadio()
+    session = radio.start_session(user)
+    assert session.related_object == user
+    for i in range(5):
+        assert radio.pick() in good_tracks
diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py
new file mode 100644
index 0000000000000000000000000000000000000000..017d742ef834562f33425cbc84046fdf7d580bb4
--- /dev/null
+++ b/api/tests/test_youtube.py
@@ -0,0 +1,95 @@
+import json
+from collections import OrderedDict
+from django.urls import reverse
+from funkwhale_api.providers.youtube.client import client
+
+from .data import youtube as api_data
+
+
+def test_can_get_search_results_from_youtube(mocker):
+    mocker.patch(
+        'funkwhale_api.providers.youtube.client._do_search',
+        return_value=api_data.search['8 bit adventure'])
+    query = '8 bit adventure'
+    results = client.search(query)
+    assert results[0]['id']['videoId'] == '0HxZn6CzOIo'
+    assert results[0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure'
+    assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo'
+
+
+def test_can_get_search_results_from_funkwhale(mocker, client, db):
+    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})
+    # we should cast the youtube result to something more generic
+    expected = {
+        "id": "0HxZn6CzOIo",
+        "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
+        "type": "youtube#video",
+        "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
+        "channelId": "UCps63j3krzAG4OyXeEyuhFw",
+        "title": "AdhesiveWombat - 8 Bit Adventure",
+        "channelTitle": "AdhesiveWombat",
+        "publishedAt": "2012-08-22T18:41:03.000Z",
+        "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
+    }
+
+    assert json.loads(response.content.decode('utf-8'))[0] == expected
+
+
+def test_can_send_multiple_queries_at_once(mocker):
+    mocker.patch(
+        'funkwhale_api.providers.youtube.client._do_search',
+        side_effect=[
+            api_data.search['8 bit adventure'],
+            api_data.search['system of a down toxicity'],
+        ]
+    )
+
+    queries = OrderedDict()
+    queries['1'] = {
+        'q': '8 bit adventure',
+    }
+    queries['2'] = {
+        'q': 'system of a down toxicity',
+    }
+
+    results = client.search_multiple(queries)
+
+    assert results['1'][0]['id']['videoId'] == '0HxZn6CzOIo'
+    assert results['1'][0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure'
+    assert results['1'][0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo'
+    assert results['2'][0]['id']['videoId'] == 'BorYwGi2SJc'
+    assert results['2'][0]['snippet']['title'] == 'System of a Down: Toxicity'
+    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):
+    mocker.patch(
+        'funkwhale_api.providers.youtube.client._do_search',
+        return_value=api_data.search['8 bit adventure'])
+    queries = OrderedDict()
+    queries['1'] = {
+        'q': '8 bit adventure',
+    }
+
+    expected = {
+        "id": "0HxZn6CzOIo",
+        "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
+        "type": "youtube#video",
+        "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
+        "channelId": "UCps63j3krzAG4OyXeEyuhFw",
+        "title": "AdhesiveWombat - 8 Bit Adventure",
+        "channelTitle": "AdhesiveWombat",
+        "publishedAt": "2012-08-22T18:41:03.000Z",
+        "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
+    }
+
+    url = reverse('api:v1:providers:youtube:searchs')
+    response = client.post(
+        url, json.dumps(queries), content_type='application/json')
+
+    assert expected == json.loads(response.content.decode('utf-8'))['1'][0]
diff --git a/api/funkwhale_api/music/tests/__init__.py b/api/tests/users/__init__.py
similarity index 100%
rename from api/funkwhale_api/music/tests/__init__.py
rename to api/tests/users/__init__.py
diff --git a/api/tests/users/test_admin.py b/api/tests/users/test_admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..7645a02953b1caa44b6002a741fadf48c406449f
--- /dev/null
+++ b/api/tests/users/test_admin.py
@@ -0,0 +1,35 @@
+from funkwhale_api.users.admin import MyUserCreationForm
+
+
+def test_clean_username_success(db):
+    # Instantiate the form with a new username
+    form = MyUserCreationForm({
+        'username': 'alamode',
+        'password1': '123456',
+        'password2': '123456',
+    })
+    # Run is_valid() to trigger the validation
+    valid = form.is_valid()
+    assert valid
+
+    # Run the actual clean_username method
+    username = form.clean_username()
+    assert 'alamode' == username
+
+
+def test_clean_username_false(factories):
+    user = factories['users.User']()
+    # Instantiate the form with the same username as self.user
+    form = MyUserCreationForm({
+        'username': user.username,
+        'password1': '123456',
+        'password2': '123456',
+    })
+    # Run is_valid() to trigger the validation, which is going to fail
+    # because the username is already taken
+    valid = form.is_valid()
+    assert not valid
+
+    # The form.errors dict should contain a single error called 'username'
+    assert len(form.errors) == 1
+    assert 'username' in form.errors
diff --git a/api/tests/users/test_jwt.py b/api/tests/users/test_jwt.py
new file mode 100644
index 0000000000000000000000000000000000000000..d264494e59bfb06f358e7dd83225cf4eef187c0f
--- /dev/null
+++ b/api/tests/users/test_jwt.py
@@ -0,0 +1,27 @@
+import pytest
+import uuid
+
+from jwt.exceptions import DecodeError
+from rest_framework_jwt.settings import api_settings
+
+from funkwhale_api.users.models import User
+
+def test_can_invalidate_token_when_changing_user_secret_key(factories):
+    user = factories['users.User']()
+    u1 = user.secret_key
+    jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
+    jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
+    payload = jwt_payload_handler(user)
+    payload = jwt_encode_handler(payload)
+
+    # this should work
+    api_settings.JWT_DECODE_HANDLER(payload)
+
+    # now we update the secret key
+    user.update_secret_key()
+    user.save()
+    assert user.secret_key != u1
+
+    # token should be invalid
+    with pytest.raises(DecodeError):
+        api_settings.JWT_DECODE_HANDLER(payload)
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..57793f494bcc59a6994d2f014cf9ae7090495cd6
--- /dev/null
+++ b/api/tests/users/test_models.py
@@ -0,0 +1,4 @@
+
+def test__str__(factories):
+    user = factories['users.User'](username='hello')
+    assert user.__str__() == 'hello'
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..1eb8ef222a79d68f41d40c8555c0c7cb9d931680
--- /dev/null
+++ b/api/tests/users/test_views.py
@@ -0,0 +1,118 @@
+import json
+
+from django.test import RequestFactory
+from django.urls import reverse
+
+from funkwhale_api.users.models import User
+
+
+def test_can_create_user_via_api(settings, client, db):
+    url = reverse('rest_register')
+    data = {
+        'username': 'test1',
+        'email': 'test1@test.com',
+        'password1': 'testtest',
+        'password2': 'testtest',
+    }
+    settings.REGISTRATION_MODE = "public"
+    response = client.post(url, data)
+    assert response.status_code == 201
+
+    u = User.objects.get(email='test1@test.com')
+    assert u.username == 'test1'
+
+
+def test_can_disable_registration_view(settings, client, db):
+    url = reverse('rest_register')
+    data = {
+        'username': 'test1',
+        'email': 'test1@test.com',
+        'password1': 'testtest',
+        'password2': 'testtest',
+    }
+    settings.REGISTRATION_MODE = "disabled"
+    response = client.post(url, data)
+    assert response.status_code == 403
+
+
+def test_can_fetch_data_from_api(client, factories):
+    url = reverse('api:v1:users:users-me')
+    response = client.get(url)
+    # login required
+    assert response.status_code == 401
+
+    user = factories['users.User'](
+        is_staff=True,
+        perms=[
+            'music.add_importbatch',
+            'dynamic_preferences.change_globalpreferencemodel',
+        ]
+    )
+    assert user.has_perm('music.add_importbatch')
+    client.login(username=user.username, password='test')
+    response = client.get(url)
+    assert response.status_code == 200
+
+    payload = json.loads(response.content.decode('utf-8'))
+
+    assert payload['username'] == user.username
+    assert payload['is_staff'] == user.is_staff
+    assert payload['is_superuser'] == user.is_superuser
+    assert payload['email'] == user.email
+    assert payload['name'] == user.name
+    assert payload['permissions']['import.launch']['status']
+    assert payload['permissions']['settings.change']['status']
+
+
+def test_can_get_token_via_api(client, factories):
+    user = factories['users.User']()
+    url = reverse('api:v1:token')
+    payload = {
+        'username': user.username,
+        'password': 'test'
+    }
+
+    response = client.post(url, payload)
+    assert response.status_code == 200
+    assert '"token":' in response.content.decode('utf-8')
+
+
+def test_can_refresh_token_via_api(client, factories):
+    # first, we get a token
+    user = factories['users.User']()
+    url = reverse('api:v1:token')
+    payload = {
+        'username': user.username,
+        'password': 'test'
+    }
+
+    response = client.post(url, payload)
+    assert response.status_code == 200
+
+    token = json.loads(response.content.decode('utf-8'))['token']
+    url = reverse('api:v1:token_refresh')
+    response = client.post(url,{'token': token})
+
+    assert response.status_code == 200
+    assert '"token":' in response.content.decode('utf-8')
+    # a different token should be returned
+    assert token in response.content.decode('utf-8')
+
+
+def test_changing_password_updates_secret_key(logged_in_client):
+    user = logged_in_client.user
+    password = user.password
+    secret_key = user.secret_key
+    payload = {
+        'old_password': 'test',
+        'new_password1': 'new',
+        'new_password2': 'new',
+    }
+    url = reverse('change_password')
+
+    response = logged_in_client.post(url, payload)
+
+    user.refresh_from_db()
+
+    assert user.secret_key != secret_key
+    assert user.password != password
diff --git a/dev.yml b/dev.yml
index c71298cfc8932c1b7874a906b582fe85a59ee4bd..44e38e32671073cbeb4d3c9d697a216c2b99b541 100644
--- a/dev.yml
+++ b/dev.yml
@@ -13,7 +13,6 @@ services:
       - "8080:8080"
     volumes:
       - './front:/app'
-      - /app/node_modules
 
   postgres:
     env_file: .env.dev
diff --git a/front/package.json b/front/package.json
index bad90430f4144be99d4b17ac11e82073a6705ff0..9af43238767258ddb082b17659b6b9d278dd7952 100644
--- a/front/package.json
+++ b/front/package.json
@@ -16,13 +16,16 @@
   "dependencies": {
     "dateformat": "^2.0.0",
     "js-logger": "^1.3.0",
+    "jwt-decode": "^2.2.0",
     "lodash": "^4.17.4",
     "semantic-ui-css": "^2.2.10",
     "vue": "^2.3.3",
     "vue-lazyload": "^1.1.4",
     "vue-resource": "^1.3.4",
     "vue-router": "^2.3.1",
-    "vuedraggable": "^2.14.1"
+    "vuedraggable": "^2.14.1",
+    "vuex": "^3.0.1",
+    "vuex-persistedstate": "^2.4.2"
   },
   "devDependencies": {
     "autoprefixer": "^6.7.2",
diff --git a/front/src/App.vue b/front/src/App.vue
index f81d7d3daae22224f9d32450844a9677302c111e..d1d63e65143df782703d035a14c8ca0f21da78e1 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -1,8 +1,8 @@
 <template>
   <div id="app">
     <sidebar></sidebar>
-    <router-view></router-view>
-    <div class="ui divider"></div>
+    <router-view :key="$route.fullPath"></router-view>
+    <div class="ui fitted divider"></div>
     <div id="footer" class="ui vertical footer segment">
       <div class="ui container">
         <div class="ui stackable equal height stackable grid">
@@ -55,7 +55,7 @@ export default {
   padding: 1.5rem 0;
 }
 #footer {
-  padding: 1.5rem;
+  padding: 4em;
 }
 .ui.stripe.segment {
   padding: 4em;
diff --git a/front/src/audio/index.js b/front/src/audio/index.js
deleted file mode 100644
index 4896b83b0f895c8bca9269aebf9141d16ab53087..0000000000000000000000000000000000000000
--- a/front/src/audio/index.js
+++ /dev/null
@@ -1,184 +0,0 @@
-import logger from '@/logging'
-import time from '@/utils/time'
-
-const Cov = {
-  on (el, type, func) {
-    el.addEventListener(type, func)
-  },
-  off (el, type, func) {
-    el.removeEventListener(type, func)
-  }
-}
-
-class Audio {
-  constructor (src, options = {}) {
-    let preload = true
-    if (options.preload !== undefined && options.preload === false) {
-      preload = false
-    }
-    this.tmp = {
-      src: src,
-      options: options
-    }
-    this.onEnded = function (e) {
-      logger.default.info('track ended')
-    }
-    if (options.onEnded) {
-      this.onEnded = options.onEnded
-    }
-    this.onError = options.onError
-
-    this.state = {
-      preload: preload,
-      startLoad: false,
-      failed: false,
-      try: 3,
-      tried: 0,
-      playing: false,
-      paused: false,
-      playbackRate: 1.0,
-      progress: 0,
-      currentTime: 0,
-      volume: 0.5,
-      duration: 0,
-      loaded: '0',
-      durationTimerFormat: '00:00',
-      currentTimeFormat: '00:00',
-      lastTimeFormat: '00:00'
-    }
-    if (options.volume !== undefined) {
-      this.state.volume = options.volume
-    }
-    this.hook = {
-      playState: [],
-      loadState: []
-    }
-    if (preload) {
-      this.init(src, options)
-    }
-  }
-
-  init (src, options = {}) {
-    if (!src) throw Error('src must be required')
-    this.state.startLoad = true
-    if (this.state.tried >= this.state.try) {
-      this.state.failed = true
-      logger.default.error('Cannot fetch audio', src)
-      if (this.onError) {
-        this.onError(src)
-      }
-      return
-    }
-    this.$Audio = new window.Audio(src)
-    Cov.on(this.$Audio, 'error', () => {
-      this.state.tried++
-      this.init(src, options)
-    })
-    if (options.autoplay) {
-      this.play()
-    }
-    if (options.rate) {
-      this.$Audio.playbackRate = options.rate
-    }
-    if (options.loop) {
-      this.$Audio.loop = true
-    }
-    if (options.volume) {
-      this.setVolume(options.volume)
-    }
-    this.loadState()
-  }
-
-  loadState () {
-    if (this.$Audio.readyState >= 2) {
-      Cov.on(this.$Audio, 'progress', this.updateLoadState.bind(this))
-    } else {
-      Cov.on(this.$Audio, 'loadeddata', () => {
-        this.loadState()
-      })
-    }
-  }
-
-  updateLoadState (e) {
-    if (!this.$Audio) return
-    this.hook.loadState.forEach(func => {
-      func(this.state)
-    })
-    this.state.duration = Math.round(this.$Audio.duration * 100) / 100
-    this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100
-    this.state.durationTimerFormat = time.parse(this.state.duration)
-  }
-
-  updatePlayState (e) {
-    this.state.currentTime = Math.round(this.$Audio.currentTime * 100) / 100
-    this.state.duration = Math.round(this.$Audio.duration * 100) / 100
-    this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100
-
-    this.state.durationTimerFormat = time.parse(this.state.duration)
-    this.state.currentTimeFormat = time.parse(this.state.currentTime)
-    this.state.lastTimeFormat = time.parse(this.state.duration - this.state.currentTime)
-
-    this.hook.playState.forEach(func => {
-      func(this.state)
-    })
-  }
-
-  updateHook (type, func) {
-    if (!(type in this.hook)) throw Error('updateHook: type should be playState or loadState')
-    this.hook[type].push(func)
-  }
-
-  play () {
-    if (this.state.startLoad) {
-      if (!this.state.playing && this.$Audio.readyState >= 2) {
-        logger.default.info('Playing track')
-        this.$Audio.play()
-        this.state.paused = false
-        this.state.playing = true
-        Cov.on(this.$Audio, 'timeupdate', this.updatePlayState.bind(this))
-        Cov.on(this.$Audio, 'ended', this.onEnded)
-      } else {
-        Cov.on(this.$Audio, 'loadeddata', () => {
-          this.play()
-        })
-      }
-    } else {
-      this.init(this.tmp.src, this.tmp.options)
-      Cov.on(this.$Audio, 'loadeddata', () => {
-        this.play()
-      })
-    }
-  }
-
-  destroyed () {
-    this.$Audio.pause()
-    Cov.off(this.$Audio, 'timeupdate', this.updatePlayState)
-    Cov.off(this.$Audio, 'progress', this.updateLoadState)
-    Cov.off(this.$Audio, 'ended', this.onEnded)
-    this.$Audio.remove()
-  }
-
-  pause () {
-    logger.default.info('Pausing track')
-    this.$Audio.pause()
-    this.state.paused = true
-    this.state.playing = false
-    this.$Audio.removeEventListener('timeupdate', this.updatePlayState)
-  }
-
-  setVolume (number) {
-    if (number > -0.01 && number <= 1) {
-      this.state.volume = Math.round(number * 100) / 100
-      this.$Audio.volume = this.state.volume
-    }
-  }
-
-  setTime (time) {
-    if (time < 0 && time > this.state.duration) {
-      return false
-    }
-    this.$Audio.currentTime = time
-  }
-}
-
-export default Audio
diff --git a/front/src/audio/queue.js b/front/src/audio/queue.js
deleted file mode 100644
index 8c69638e80ceb46adfdd182010ced55898b6850f..0000000000000000000000000000000000000000
--- a/front/src/audio/queue.js
+++ /dev/null
@@ -1,304 +0,0 @@
-import logger from '@/logging'
-import cache from '@/cache'
-import config from '@/config'
-import Audio from '@/audio'
-import backend from '@/audio/backend'
-import radios from '@/radios'
-import Vue from 'vue'
-import url from '@/utils/url'
-import auth from '@/auth'
-
-class Queue {
-  constructor (options = {}) {
-    logger.default.info('Instanciating queue')
-    this.previousQueue = cache.get('queue')
-    this.tracks = []
-    this.currentIndex = -1
-    this.currentTrack = null
-    this.ended = true
-    this.state = {
-      volume: cache.get('volume', 0.5)
-    }
-    this.audio = {
-      state: {
-        startLoad: false,
-        failed: false,
-        try: 3,
-        tried: 0,
-        playing: false,
-        paused: false,
-        playbackRate: 1.0,
-        progress: 0,
-        currentTime: 0,
-        duration: 0,
-        volume: this.state.volume,
-        loaded: '0',
-        durationTimerFormat: '00:00',
-        currentTimeFormat: '00:00',
-        lastTimeFormat: '00:00'
-      }
-    }
-  }
-
-  cache () {
-    let cached = {
-      tracks: this.tracks.map(track => {
-        // we keep only valuable fields to make the cache lighter and avoid
-        // cyclic value serialization errors
-        let artist = {
-          id: track.artist.id,
-          mbid: track.artist.mbid,
-          name: track.artist.name
-        }
-        return {
-          id: track.id,
-          title: track.title,
-          mbid: track.mbid,
-          album: {
-            id: track.album.id,
-            title: track.album.title,
-            mbid: track.album.mbid,
-            cover: track.album.cover,
-            artist: artist
-          },
-          artist: artist,
-          files: track.files
-        }
-      }),
-      currentIndex: this.currentIndex
-    }
-    cache.set('queue', cached)
-  }
-
-  restore () {
-    let cached = cache.get('queue')
-    if (!cached) {
-      return false
-    }
-    logger.default.info('Restoring previous queue...')
-    this.tracks = cached.tracks
-    this.play(cached.currentIndex)
-    this.previousQueue = null
-    return true
-  }
-  removePrevious () {
-    this.previousQueue = undefined
-    cache.remove('queue')
-  }
-  setVolume (newValue) {
-    newValue = Math.min(newValue, 1)
-    newValue = Math.max(newValue, 0)
-    this.state.volume = newValue
-    if (this.audio.setVolume) {
-      this.audio.setVolume(newValue)
-    } else {
-      this.audio.state.volume = newValue
-    }
-    cache.set('volume', newValue)
-  }
-  incrementVolume (value) {
-    this.setVolume(this.state.volume + value)
-  }
-  reorder (oldIndex, newIndex) {
-    // called when the user uses drag / drop to reorder
-    // tracks in queue
-    if (oldIndex === this.currentIndex) {
-      this.currentIndex = newIndex
-      return
-    }
-    if (oldIndex < this.currentIndex && newIndex >= this.currentIndex) {
-      // item before was moved after
-      this.currentIndex -= 1
-    }
-    if (oldIndex > this.currentIndex && newIndex <= this.currentIndex) {
-      // item after was moved before
-      this.currentIndex += 1
-    }
-  }
-
-  append (track, index, skipPlay) {
-    this.previousQueue = null
-    index = index || this.tracks.length
-    if (index > this.tracks.length - 1) {
-      // we simply push to the end
-      this.tracks.push(track)
-    } else {
-      // we insert the track at given position
-      this.tracks.splice(index, 0, track)
-    }
-    if (!skipPlay) {
-      this.resumeQueue()
-    }
-    this.cache()
-  }
-
-  appendMany (tracks, index) {
-    logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title }))
-    let self = this
-    if (this.tracks.length === 0) {
-      index = 0
-    } else {
-      index = index || this.tracks.length
-    }
-    tracks.forEach((t) => {
-      self.append(t, index, true)
-      index += 1
-    })
-    this.resumeQueue()
-  }
-
-  resumeQueue () {
-    if (this.ended | this.errored) {
-      this.next()
-    }
-  }
-
-  populateFromRadio () {
-    if (!radios.running) {
-      return
-    }
-    var self = this
-    radios.fetch().then((response) => {
-      logger.default.info('Adding track to queue from radio')
-      self.append(response.data.track)
-    }, (response) => {
-      logger.default.error('Error while adding track to queue from radio')
-    })
-  }
-
-  clean () {
-    this.stop()
-    radios.stop()
-    this.tracks = []
-    this.currentIndex = -1
-    this.currentTrack = null
-    // so we replay automatically on next track append
-    this.ended = true
-  }
-
-  cleanTrack (index) {
-    // are we removing current playin track
-    let current = index === this.currentIndex
-    if (current) {
-      this.stop()
-    }
-    if (index < this.currentIndex) {
-      this.currentIndex -= 1
-    }
-    this.tracks.splice(index, 1)
-    if (current) {
-      // we play next track, which now have the same index
-      this.play(index)
-    }
-    if (this.currentIndex === this.tracks.length - 1) {
-      this.populateFromRadio()
-    }
-  }
-
-  stop () {
-    if (this.audio.pause) {
-      this.audio.pause()
-    }
-    if (this.audio.destroyed) {
-      this.audio.destroyed()
-    }
-  }
-  play (index) {
-    let self = this
-    let currentIndex = index
-    let currentTrack = this.tracks[index]
-
-    if (this.audio.destroyed) {
-      logger.default.debug('Destroying previous audio...', index - 1)
-      this.audio.destroyed()
-    }
-
-    if (!currentTrack) {
-      return
-    }
-
-    this.currentIndex = currentIndex
-    this.currentTrack = currentTrack
-
-    this.ended = false
-    this.errored = false
-    let file = this.currentTrack.files[0]
-    if (!file) {
-      this.errored = true
-      return this.next()
-    }
-    let path = backend.absoluteUrl(file.path)
-    if (auth.user.authenticated) {
-      // we need to send the token directly in url
-      // so authentication can be checked by the backend
-      // because for audio files we cannot use the regular Authentication
-      // header
-      path = url.updateQueryString(path, 'jwt', auth.getAuthToken())
-    }
-
-    let audio = new Audio(path, {
-      preload: true,
-      autoplay: true,
-      rate: 1,
-      loop: false,
-      volume: this.state.volume,
-      onEnded: this.handleAudioEnded.bind(this),
-      onError: function (src) {
-        self.errored = true
-        self.next()
-      }
-    })
-    this.audio = audio
-    audio.updateHook('playState', function (e) {
-      // in some situations, we may have a race condition, for example
-      // if the user spams the next / previous buttons, with multiple audios
-      // playing at the same time. To avoid that, we ensure the audio
-      // still matches de queue current audio
-      if (audio !== self.audio) {
-        logger.default.debug('Destroying duplicate audio')
-        audio.destroyed()
-      }
-    })
-    if (this.currentIndex === this.tracks.length - 1) {
-      this.populateFromRadio()
-    }
-    this.cache()
-  }
-
-  handleAudioEnded (e) {
-    this.recordListen(this.currentTrack)
-    if (this.currentIndex < this.tracks.length - 1) {
-      logger.default.info('Audio track ended, playing next one')
-      this.next()
-    } else {
-      logger.default.info('We reached the end of the queue')
-      this.ended = true
-    }
-  }
-
-  recordListen (track) {
-    let url = config.API_URL + 'history/listenings/'
-    let resource = Vue.resource(url)
-    resource.save({}, {'track': track.id}).then((response) => {}, (response) => {
-      logger.default.error('Could not record track in history')
-    })
-  }
-
-  previous () {
-    if (this.currentIndex > 0) {
-      this.play(this.currentIndex - 1)
-    }
-  }
-
-  next () {
-    if (this.currentIndex < this.tracks.length - 1) {
-      logger.default.debug('Playing next track')
-      this.play(this.currentIndex + 1)
-    }
-  }
-
-}
-
-let queue = new Queue()
-
-export default queue
diff --git a/front/src/auth/index.js b/front/src/auth/index.js
deleted file mode 100644
index 80236942858440d517d2fe80b7bb43c71b3ea7c0..0000000000000000000000000000000000000000
--- a/front/src/auth/index.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import logger from '@/logging'
-import config from '@/config'
-import cache from '@/cache'
-import Vue from 'vue'
-
-import favoriteTracks from '@/favorites/tracks'
-
-// URL and endpoint constants
-const LOGIN_URL = config.API_URL + 'token/'
-const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
-// const SIGNUP_URL = API_URL + 'users/'
-
-let userData = {
-  authenticated: false,
-  username: '',
-  availablePermissions: {},
-  profile: {}
-}
-let auth = {
-
-  // Send a request to the login URL and save the returned JWT
-  login (context, creds, redirect, onError) {
-    return context.$http.post(LOGIN_URL, creds).then(response => {
-      logger.default.info('Successfully logged in as', creds.username)
-      cache.set('token', response.data.token)
-      cache.set('username', creds.username)
-
-      this.user.authenticated = true
-      this.user.username = creds.username
-      this.connect()
-      // Redirect to a specified route
-      if (redirect) {
-        context.$router.push(redirect)
-      }
-    }, response => {
-      logger.default.error('Error while logging in', response.data)
-      if (onError) {
-        onError(response)
-      }
-    })
-  },
-
-  // To log out, we just need to remove the token
-  logout () {
-    cache.clear()
-    this.user.authenticated = false
-    logger.default.info('Log out, goodbye!')
-  },
-
-  checkAuth () {
-    logger.default.info('Checking authentication...')
-    var jwt = this.getAuthToken()
-    var username = cache.get('username')
-    if (jwt) {
-      this.user.authenticated = true
-      this.user.username = username
-      logger.default.info('Logged back in as ' + username)
-      this.connect()
-    } else {
-      logger.default.info('Anonymous user')
-      this.user.authenticated = false
-    }
-  },
-
-  getAuthToken () {
-    return cache.get('token')
-  },
-
-  // The object to be passed as a header for authenticated requests
-  getAuthHeader () {
-    return 'JWT ' + this.getAuthToken()
-  },
-
-  fetchProfile () {
-    let resource = Vue.resource(USER_PROFILE_URL)
-    return resource.get({}).then((response) => {
-      logger.default.info('Successfully fetched user profile')
-      return response.data
-    }, (response) => {
-      logger.default.info('Error while fetching user profile')
-    })
-  },
-  connect () {
-    // called once user has logged in successfully / reauthenticated
-    // e.g. after a page refresh
-    let self = this
-    this.fetchProfile().then(data => {
-      Vue.set(self.user, 'profile', data)
-      Object.keys(data.permissions).forEach(function (key) {
-        // this makes it easier to check for permissions in templates
-        Vue.set(self.user.availablePermissions, key, data.permissions[String(key)].status)
-      })
-    })
-    favoriteTracks.fetch()
-  }
-}
-
-Vue.set(auth, 'user', userData)
-export default auth
diff --git a/front/src/cache/index.js b/front/src/cache/index.js
deleted file mode 100644
index e039ee788078daa4e11e1df194982e33014240af..0000000000000000000000000000000000000000
--- a/front/src/cache/index.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import logger from '@/logging'
-export default {
-  get (key, d) {
-    let v = localStorage.getItem(key)
-    if (v === null) {
-      return d
-    } else {
-      try {
-        return JSON.parse(v).value
-      } catch (e) {
-        logger.default.error('Removing unparsable cached value for key ' + key)
-        this.remove(key)
-        return d
-      }
-    }
-  },
-  set (key, value) {
-    return localStorage.setItem(key, JSON.stringify({value: value}))
-  },
-
-  remove (key) {
-    return localStorage.removeItem(key)
-  },
-
-  clear () {
-    localStorage.clear()
-  }
-
-}
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 68927a37b09a5e302a9dfb7ce9be44b9e7750b96..a315aab199c341a24db3baed09cdad32a813975b 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -28,8 +28,8 @@
   <div class="tabs">
     <div class="ui bottom attached active tab" data-tab="library">
       <div class="ui inverted vertical fluid menu">
-        <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link>
-        <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link>
+        <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i> Logged in as {{ $store.state.auth.username }}</router-link>
+        <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link>
         <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
         <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
         <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
@@ -51,7 +51,7 @@
     <div class="ui bottom attached tab" data-tab="queue">
       <table class="ui compact inverted very basic fixed single line table">
         <draggable v-model="queue.tracks" element="tbody" @update="reorder">
-          <tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
+          <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
               <td class="right aligned">{{ index + 1}}</td>
               <td class="center aligned">
                   <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
@@ -62,24 +62,24 @@
                   {{ track.artist.name }}
               </td>
               <td>
-                <template v-if="favoriteTracks.objects[track.id]">
-                  <i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i>
-                  </template
+                <template v-if="$store.getters['favorites/isFavorite'](track.id)">
+                  <i class="pink heart icon"></i>
+                </template
               </td>
               <td>
-                  <i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
+                  <i @click.stop="cleanTrack(index)" class="circular trash icon"></i>
               </td>
             </tr>
           </draggable>
       </table>
-      <div v-if="radios.running" class="ui black message">
+      <div v-if="$store.state.radios.running" class="ui black message">
 
         <div class="content">
           <div class="header">
             <i class="feed icon"></i> You have a radio playing
           </div>
           <p>New tracks will be appended here automatically.</p>
-          <div @click="radios.stop()" class="ui basic inverted red button">Stop radio</div>
+          <div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">Stop radio</div>
         </div>
       </div>
     </div>
@@ -87,24 +87,17 @@
   <div class="ui inverted segment player-wrapper">
     <player></player>
   </div>
-  <GlobalEvents
-    @keydown.r.stop="queue.restore"
-    />
 </div>
 </template>
 
 <script>
-import GlobalEvents from '@/components/utils/global-events'
+import {mapState, mapActions} from 'vuex'
 
 import Player from '@/components/audio/Player'
-import favoriteTracks from '@/favorites/tracks'
 import Logo from '@/components/Logo'
 import SearchBar from '@/components/audio/SearchBar'
-import auth from '@/auth'
-import queue from '@/audio/queue'
 import backend from '@/audio/backend'
 import draggable from 'vuedraggable'
-import radios from '@/radios'
 
 import $ from 'jquery'
 
@@ -114,24 +107,27 @@ export default {
     Player,
     SearchBar,
     Logo,
-    draggable,
-    GlobalEvents
+    draggable
   },
   data () {
     return {
-      auth: auth,
-      backend: backend,
-      queue: queue,
-      radios,
-      favoriteTracks
+      backend: backend
     }
   },
   mounted () {
     $(this.$el).find('.menu .item').tab()
   },
+  computed: {
+    ...mapState({
+      queue: state => state.queue
+    })
+  },
   methods: {
-    reorder (e) {
-      this.queue.reorder(e.oldIndex, e.newIndex)
+    ...mapActions({
+      cleanTrack: 'queue/cleanTrack'
+    }),
+    reorder: function (oldValue, newValue) {
+      this.$store.commit('queue/reorder', {oldValue, newValue})
     }
   }
 }
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index 240fa498032c05c2823f0aa3d53cf2e8a7991b0f..4767255ecae8b6bb27a872614f9a4e029146ded9 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -17,7 +17,6 @@
 
 <script>
 import logger from '@/logging'
-import queue from '@/audio/queue'
 import jQuery from 'jquery'
 
 export default {
@@ -40,19 +39,19 @@ export default {
   methods: {
     add () {
       if (this.track) {
-        queue.append(this.track)
+        this.$store.dispatch('queue/append', {track: this.track})
       } else {
-        queue.appendMany(this.tracks)
+        this.$store.dispatch('queue/appendMany', {tracks: this.tracks})
       }
     },
     addNext (next) {
       if (this.track) {
-        queue.append(this.track, queue.currentIndex + 1)
+        this.$store.dispatch('queue/append', {track: this.track, index: this.$store.state.queue.currentIndex + 1})
       } else {
-        queue.appendMany(this.tracks, queue.currentIndex + 1)
+        this.$store.dispatch('queue/appendMany', {tracks: this.tracks, index: this.$store.state.queue.currentIndex + 1})
       }
       if (next) {
-        queue.next()
+        this.$store.dispatch('queue/next')
       }
     }
   }
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 423c9d12f2d5cf89abf5b1f876a595925ee21370..500f4dc1d2b09e26cf036b871f842ca608b81d3b 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -1,130 +1,208 @@
 <template>
   <div class="player">
-    <div v-if="queue.currentTrack" class="track-area ui items">
+    <audio-track
+      ref="currentAudio"
+      v-if="currentTrack"
+      :key="(currentIndex, currentTrack.id)"
+      :is-current="true"
+      :start-time="$store.state.player.currentTime"
+      :autoplay="$store.state.player.playing"
+      :track="currentTrack">
+    </audio-track>
+
+    <div v-if="currentTrack" class="track-area ui items">
       <div class="ui inverted item">
         <div class="ui tiny image">
-          <img v-if="queue.currentTrack.album.cover" :src="Track.getCover(queue.currentTrack)">
+          <img v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)">
           <img v-else src="../../assets/audio/default-cover.png">
         </div>
         <div class="middle aligned content">
-          <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: queue.currentTrack.id }}">
-            {{ queue.currentTrack.title }}
+          <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
+            {{ currentTrack.title }}
           </router-link>
           <div class="meta">
-            <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: queue.currentTrack.artist.id }}">
-              {{ queue.currentTrack.artist.name }}
+            <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
+              {{ currentTrack.artist.name }}
             </router-link> /
-            <router-link class="album" :to="{name: 'library.albums.detail', params: {id: queue.currentTrack.album.id }}">
-              {{ queue.currentTrack.album.title }}
+            <router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
+              {{ currentTrack.album.title }}
             </router-link>
           </div>
           <div class="description">
-            <track-favorite-icon :track="queue.currentTrack"></track-favorite-icon>
+            <track-favorite-icon :track="currentTrack"></track-favorite-icon>
           </div>
         </div>
       </div>
     </div>
-    <div class="progress-area" v-if="queue.currentTrack">
+    <div class="progress-area" v-if="currentTrack">
       <div class="ui grid">
         <div class="left floated four wide column">
-          <p class="timer start" @click="queue.audio.setTime(0)">{{queue.audio.state.currentTimeFormat}}</p>
+          <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p>
         </div>
 
         <div class="right floated four wide column">
-          <p class="timer total">{{queue.audio.state.durationTimerFormat}}</p>
+          <p class="timer total">{{durationFormatted}}</p>
         </div>
       </div>
       <div ref="progress" class="ui small orange inverted progress" @click="touchProgress">
-        <div class="bar" :data-percent="queue.audio.state.progress" :style="{ 'width': queue.audio.state.progress + '%' }"></div>
+        <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
       </div>
     </div>
 
-    <div class="controls ui grid">
-      <div class="volume-control four wide center aligned column">
+    <div class="two wide column controls ui grid">
+      <div
+        @click="previous"
+        title="Previous track"
+        class="two wide column control"
+        :disabled="!hasPrevious">
+          <i :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i>
+      </div>
+      <div
+        v-if="!playing"
+        @click="togglePlay"
+        title="Play track"
+        class="two wide column control">
+          <i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i>
+      </div>
+      <div
+        v-else
+        @click="togglePlay"
+        title="Pause track"
+        class="two wide column control">
+          <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i>
+      </div>
+      <div
+        @click="next"
+        title="Next track"
+        class="two wide column control"
+        :disabled="!hasNext">
+          <i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i>
+      </div>
+      <div class="two wide column control volume-control">
+        <i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i>
+        <i title="Mute" @click="$store.commit('player/volume', 0)" v-else-if="volume < 0.5" class="volume down secondary icon"></i>
+        <i title="Mute" @click="$store.commit('player/volume', 0)" v-else class="volume up secondary icon"></i>
         <input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" />
-        <i title="Unmute" @click="queue.setVolume(1)" v-if="currentVolume === 0" class="volume off secondary icon"></i>
-        <i title="Mute" @click="queue.setVolume(0)" v-else-if="currentVolume < 0.5" class="volume down secondary icon"></i>
-        <i title="Mute" @click="queue.setVolume(0)" v-else class="volume up secondary icon"></i>
       </div>
-      <div class="eight wide center aligned column">
-        <i title="Previous track" @click="queue.previous()" :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" :disabled="!hasPrevious"></i>
-        <i title="Play track" v-if="!queue.audio.state.playing" :class="['ui', 'play', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i>
-        <i title="Pause track" v-else :class="['ui', 'pause', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i>
-        <i title="Next track" @click="queue.next()" :class="['ui', 'step', 'forward', {'disabled': !hasNext}, 'big', 'icon']" :disabled="!hasNext"></i>
+      <div class="two wide column control looping">
+        <i
+          title="Looping disabled. Click to switch to single-track looping."
+          v-if="looping === 0"
+          @click="$store.commit('player/looping', 1)"
+          :disabled="!currentTrack"
+          :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'secondary', 'icon']"></i>
+        <i
+          title="Looping on a single track. Click to switch to whole queue looping."
+          v-if="looping === 1"
+          @click="$store.commit('player/looping', 2)"
+          :disabled="!currentTrack"
+          class="repeat secondary icon">
+          <span class="ui circular tiny orange label">1</span>
+        </i>
+        <i
+          title="Looping on whole queue. Click to disable looping."
+          v-if="looping === 2"
+          @click="$store.commit('player/looping', 0)"
+          :disabled="!currentTrack"
+          class="repeat orange secondary icon">
+        </i>
       </div>
-      <div class="four wide center aligned column">
-        <i title="Clear your queue" @click="queue.clean()" :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" :disabled="queue.tracks.length === 0"></i>
+      <div
+        @click="shuffle()"
+        :disabled="queue.tracks.length === 0"
+        title="Shuffle your queue"
+        class="two wide column control">
+        <i :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
+      </div>
+      <div class="one wide column"></div>
+      <div
+        @click="clean()"
+        :disabled="queue.tracks.length === 0"
+        title="Clear your queue"
+        class="two wide column control">
+        <i :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
       </div>
     </div>
     <GlobalEvents
-      @keydown.space.prevent="pauseOrPlay"
-      @keydown.ctrl.left.prevent="queue.previous"
-      @keydown.ctrl.right.prevent="queue.next"
-      @keydown.ctrl.down.prevent="queue.incrementVolume(-0.1)"
-      @keydown.ctrl.up.prevent="queue.incrementVolume(0.1)"
+      @keydown.space.prevent.exact="togglePlay"
+      @keydown.ctrl.left.prevent.exact="previous"
+      @keydown.ctrl.right.prevent.exact="next"
+      @keydown.ctrl.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)"
+      @keydown.ctrl.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)"
+      @keydown.f.prevent.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
+      @keydown.l.prevent.exact="$store.commit('player/toggleLooping')"
+      @keydown.s.prevent.exact="shuffle"
       />
 
   </div>
 </template>
 
 <script>
+import {mapState, mapGetters, mapActions} from 'vuex'
 import GlobalEvents from '@/components/utils/global-events'
 
-import queue from '@/audio/queue'
 import Track from '@/audio/track'
+import AudioTrack from '@/components/audio/Track'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
-import radios from '@/radios'
 
 export default {
   name: 'player',
   components: {
     TrackFavoriteIcon,
-    GlobalEvents
+    GlobalEvents,
+    AudioTrack
   },
   data () {
     return {
-      sliderVolume: this.currentVolume,
-      queue: queue,
-      Track: Track,
-      radios
+      sliderVolume: this.volume,
+      Track: Track
     }
   },
   mounted () {
     // we trigger the watcher explicitely it does not work otherwise
-    this.sliderVolume = this.currentVolume
+    this.sliderVolume = this.volume
   },
   methods: {
-    pauseOrPlay () {
-      if (this.queue.audio.state.playing) {
-        this.queue.audio.pause()
-      } else {
-        this.queue.audio.play()
-      }
-    },
+    ...mapActions({
+      pause: 'player/pause',
+      togglePlay: 'player/togglePlay',
+      clean: 'queue/clean',
+      next: 'queue/next',
+      previous: 'queue/previous',
+      shuffle: 'queue/shuffle',
+      updateProgress: 'player/updateProgress'
+    }),
     touchProgress (e) {
       let time
       let target = this.$refs.progress
-      time = e.layerX / target.offsetWidth * this.queue.audio.state.duration
-      this.queue.audio.setTime(time)
+      time = e.layerX / target.offsetWidth * this.duration
+      this.$refs.currentAudio.setCurrentTime(time)
     }
   },
   computed: {
-    hasPrevious () {
-      return this.queue.currentIndex > 0
-    },
-    hasNext () {
-      return this.queue.currentIndex < this.queue.tracks.length - 1
-    },
-    currentVolume () {
-      return this.queue.audio.state.volume
-    }
+    ...mapState({
+      currentIndex: state => state.queue.currentIndex,
+      playing: state => state.player.playing,
+      volume: state => state.player.volume,
+      looping: state => state.player.looping,
+      duration: state => state.player.duration,
+      queue: state => state.queue
+    }),
+    ...mapGetters({
+      currentTrack: 'queue/currentTrack',
+      hasNext: 'queue/hasNext',
+      hasPrevious: 'queue/hasPrevious',
+      durationFormatted: 'player/durationFormatted',
+      currentTimeFormatted: 'player/currentTimeFormatted',
+      progress: 'player/progress'
+    })
   },
   watch: {
-    currentVolume (newValue) {
+    volume (newValue) {
       this.sliderVolume = newValue
     },
     sliderVolume (newValue) {
-      this.queue.setVolume(parseFloat(newValue))
+      this.$store.commit('player/volume', newValue)
     }
   }
 }
@@ -184,14 +262,32 @@ export default {
 .volume-control {
   position: relative;
   .icon {
-    margin: 0;
+    // margin: 0;
   }
   [type="range"] {
-    max-width: 75%;
+    max-width: 100%;
     position: absolute;
     bottom: 5px;
     left: 10%;
     cursor: pointer;
+    display: none;
+  }
+  &:hover {
+    [type="range"] {
+      display: block;
+    }
+  }
+}
+
+.looping.control {
+  i {
+    position: relative;
+  }
+  .label {
+    position: absolute;
+    font-size: 0.7rem;
+    bottom: -0.7rem;
+    right: -0.7rem;
   }
 }
 .ui.feed.icon {
diff --git a/front/src/components/audio/Search.vue b/front/src/components/audio/Search.vue
index 5c902e5e5abba8e1472feff9cfae3af269e1641e..2811c2b5c4f58ca71f8c5c55c239ffcf41c0e5d6 100644
--- a/front/src/components/audio/Search.vue
+++ b/front/src/components/audio/Search.vue
@@ -30,7 +30,6 @@
 
 <script>
 import logger from '@/logging'
-import queue from '@/audio/queue'
 import backend from '@/audio/backend'
 import AlbumCard from '@/components/audio/album/Card'
 import ArtistCard from '@/components/audio/artist/Card'
@@ -54,8 +53,7 @@ export default {
         artists: []
       },
       backend: backend,
-      isLoading: false,
-      queue: queue
+      isLoading: false
     }
   },
   mounted () {
diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue
index 2324c88392f258f95b7915cce04ea1b9166d5e80..9d8b39f870cac19021fc56d9df3368d8f05f6ddf 100644
--- a/front/src/components/audio/SearchBar.vue
+++ b/front/src/components/audio/SearchBar.vue
@@ -12,13 +12,13 @@
 <script>
 import jQuery from 'jquery'
 import config from '@/config'
-import auth from '@/auth'
 import router from '@/router'
 
 const SEARCH_URL = config.API_URL + 'search?query={query}'
 
 export default {
   mounted () {
+    let self = this
     jQuery(this.$el).search({
       type: 'category',
       minCharacters: 3,
@@ -27,7 +27,7 @@ export default {
       },
       apiSettings: {
         beforeXHR: function (xhrObject) {
-          xhrObject.setRequestHeader('Authorization', auth.getAuthHeader())
+          xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
           return xhrObject
         },
         onResponse: function (initialResponse) {
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c8627925ebdd404e4d8dc9887d8a0307c22705fd
--- /dev/null
+++ b/front/src/components/audio/Track.vue
@@ -0,0 +1,109 @@
+<template>
+  <audio
+    ref="audio"
+    :src="url"
+    @error="errored"
+    @progress="updateLoad"
+    @loadeddata="loaded"
+    @timeupdate="updateProgress"
+    @ended="ended"
+    preload>
+
+  </audio>
+</template>
+
+<script>
+import {mapState} from 'vuex'
+import backend from '@/audio/backend'
+import url from '@/utils/url'
+
+// import logger from '@/logging'
+
+export default {
+  props: {
+    track: {type: Object},
+    isCurrent: {type: Boolean, default: false},
+    startTime: {type: Number, default: 0},
+    autoplay: {type: Boolean, default: false}
+  },
+  computed: {
+    ...mapState({
+      playing: state => state.player.playing,
+      currentTime: state => state.player.currentTime,
+      duration: state => state.player.duration,
+      volume: state => state.player.volume,
+      looping: state => state.player.looping
+    }),
+    url: function () {
+      let file = this.track.files[0]
+      if (!file) {
+        this.$store.dispatch('player/trackErrored')
+        return null
+      }
+      let path = backend.absoluteUrl(file.path)
+      if (this.$store.state.auth.authenticated) {
+        // we need to send the token directly in url
+        // so authentication can be checked by the backend
+        // because for audio files we cannot use the regular Authentication
+        // header
+        path = url.updateQueryString(path, 'jwt', this.$store.state.auth.token)
+      }
+      return path
+    }
+  },
+  methods: {
+    errored: function () {
+      this.$store.dispatch('player/trackErrored')
+    },
+    updateLoad: function () {
+
+    },
+    loaded: function () {
+      if (this.isCurrent && this.autoplay) {
+        this.$store.commit('player/duration', this.$refs.audio.duration)
+        if (this.startTime) {
+          this.setCurrentTime(this.startTime)
+        }
+        this.$store.commit('player/playing', true)
+        this.$refs.audio.play()
+      }
+    },
+    updateProgress: function () {
+      if (this.$refs.audio) {
+        this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime)
+      }
+    },
+    ended: function () {
+      if (this.looping === 1) {
+        this.setCurrentTime(0)
+        this.$refs.audio.play()
+      } else {
+        this.$store.dispatch('player/trackEnded', this.track)
+      }
+    },
+    setCurrentTime (t) {
+      if (t < 0 | t > this.duration) {
+        return
+      }
+      this.updateProgress(t)
+      this.$refs.audio.currentTime = t
+    }
+  },
+  watch: {
+    playing: function (newValue) {
+      if (newValue === true) {
+        this.$refs.audio.play()
+      } else {
+        this.$refs.audio.pause()
+      }
+    },
+    volume: function (newValue) {
+      this.$refs.audio.volume = newValue
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue
index ce5e832e2794a4288a673460244434f30945615f..4c803b29cc704fbc7e05258bfe595ee9b6908d99 100644
--- a/front/src/components/audio/album/Card.vue
+++ b/front/src/components/audio/album/Card.vue
@@ -51,7 +51,6 @@
 </template>
 
 <script>
-import queue from '@/audio/queue'
 import backend from '@/audio/backend'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
 import PlayButton from '@/components/audio/PlayButton'
@@ -68,7 +67,6 @@ export default {
   data () {
     return {
       backend: backend,
-      queue: queue,
       initialTracks: 4,
       showAllTracks: false
     }
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index efb98e382804ecb2623d4f96f5e6b9935559052b..8a591d3bd05e9542484a2b8bf8ef9b931363a837 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -58,9 +58,9 @@
                   Keep your PRIVATE_TOKEN secret as it gives access to your account.
                 </div>
                 <pre>
-export PRIVATE_TOKEN="{{ auth.getAuthToken ()}}"
+export PRIVATE_TOKEN="{{ $store.state.auth.token }}"
 <template v-for="track in tracks"><template v-if="track.files.length > 0">
-curl -G -o "{{ track.files[0].filename }}" <template v-if="auth.user.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template>
+curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template>
 </pre>
               </div>
             </div>
@@ -83,7 +83,6 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="auth.user.authenticat
 
 <script>
 import backend from '@/audio/backend'
-import auth from '@/auth'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
 import PlayButton from '@/components/audio/PlayButton'
 
@@ -102,7 +101,6 @@ export default {
   data () {
     return {
       backend: backend,
-      auth: auth,
       showDownloadModal: false
     }
   }
diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue
index 54e7b82e096433aacd18d02053e66c164e51ca60..99b439af8b3e25c821ddb1dbc0c86c6419961d2c 100644
--- a/front/src/components/auth/Login.vue
+++ b/front/src/components/auth/Login.vue
@@ -39,12 +39,11 @@
 </template>
 
 <script>
-import auth from '@/auth'
 
 export default {
   name: 'login',
   props: {
-    next: {type: String}
+    next: {type: String, default: '/'}
   },
   data () {
     return {
@@ -72,14 +71,17 @@ export default {
       }
       // We need to pass the component's this context
       // to properly make use of http in the auth service
-      auth.login(this, credentials, {path: this.next}, function (response) {
-        // error callback
-        if (response.status === 400) {
-          self.error = 'invalid_credentials'
-        } else {
-          self.error = 'unknown_error'
+      this.$store.dispatch('auth/login', {
+        credentials,
+        next: this.next,
+        onError: response => {
+          if (response.status === 400) {
+            self.error = 'invalid_credentials'
+          } else {
+            self.error = 'unknown_error'
+          }
         }
-      }).then((response) => {
+      }).then(e => {
         self.isLoading = false
       })
     }
diff --git a/front/src/components/auth/Logout.vue b/front/src/components/auth/Logout.vue
index f4b2979bc05394dc04f7644e88700a08b13989d5..fbacca70338ed295dc284664d443c9b239b25da0 100644
--- a/front/src/components/auth/Logout.vue
+++ b/front/src/components/auth/Logout.vue
@@ -3,8 +3,8 @@
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
         <h2>Are you sure you want to log out?</h2>
-        <p>You are currently logged in as {{ auth.user.username }}</p>
-        <button class="ui button" @click="logout">Yes, log me out!</button>
+        <p>You are currently logged in as {{ $store.state.auth.username }}</p>
+        <button class="ui button" @click="$store.dispatch('auth/logout')">Yes, log me out!</button>
         </form>
       </div>
     </div>
@@ -12,23 +12,8 @@
 </template>
 
 <script>
-import auth from '@/auth'
-
 export default {
-  name: 'logout',
-  data () {
-    return {
-      // We need to initialize the component with any
-      // properties that will be used in it
-      auth: auth
-    }
-  },
-  methods: {
-    logout () {
-      auth.logout()
-      this.$router.push({name: 'index'})
-    }
-  }
+  name: 'logout'
 }
 </script>
 
diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue
index 2aaae9e2df34d292f5cc66ae04dee5898bc9d335..54af5a11c4c0027fc68f81e9c6eca283d4ed8aee 100644
--- a/front/src/components/auth/Profile.vue
+++ b/front/src/components/auth/Profile.vue
@@ -3,55 +3,45 @@
     <div v-if="isLoading" class="ui vertical segment">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
-    <template v-if="profile">
+    <template v-if="$store.state.auth.profile">
       <div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
         <h2 class="ui center aligned icon header">
           <i class="circular inverted user green icon"></i>
           <div class="content">
-            {{ profile.username }}
+            {{ $store.state.auth.profile.username }}
             <div class="sub header">Registered since {{ signupDate }}</div>
           </div>
         </h2>
         <div class="ui basic green label">this is you!</div>
-        <div v-if="profile.is_staff" class="ui yellow label">
+        <div v-if="$store.state.auth.profile.is_staff" class="ui yellow label">
           <i class="star icon"></i>
           Staff member
         </div>
+        <router-link class="ui tiny basic button" :to="{path: '/settings'}">
+          <i class="setting icon"> </i>Settings...
+        </router-link>
+
       </div>
     </template>
   </div>
 </template>
 
 <script>
-import auth from '@/auth'
-var dateFormat = require('dateformat')
+const dateFormat = require('dateformat')
 
 export default {
   name: 'login',
   props: ['username'],
-  data () {
-    return {
-      profile: null
-    }
-  },
   created () {
-    this.fetchProfile()
-  },
-  methods: {
-    fetchProfile () {
-      let self = this
-      auth.fetchProfile().then(data => {
-        self.profile = data
-      })
-    }
+    this.$store.dispatch('auth/fetchProfile')
   },
   computed: {
     signupDate () {
-      let d = new Date(this.profile.date_joined)
+      let d = new Date(this.$store.state.auth.profile.date_joined)
       return dateFormat(d, 'longDate')
     },
     isLoading () {
-      return !this.profile
+      return !this.$store.state.auth.profile
     }
   }
 }
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d93373a1496937380eab3531855696289617996a
--- /dev/null
+++ b/front/src/components/auth/Settings.vue
@@ -0,0 +1,84 @@
+<template>
+  <div class="main pusher">
+    <div class="ui vertical stripe segment">
+      <div class="ui small text container">
+        <h2>Change my password</h2>
+        <form class="ui form" @submit.prevent="submit()">
+          <div v-if="error" class="ui negative message">
+            <div class="header">Cannot change your password</div>
+            <ul class="list">
+              <li v-if="error == 'invalid_credentials'">Please double-check your password is correct</li>
+            </ul>
+          </div>
+          <div class="field">
+            <label>Old password</label>
+            <input
+            required
+            type="password"
+            autofocus
+            placeholder="Enter your old password"
+            v-model="old_password">
+          </div>
+          <div class="field">
+            <label>New password</label>
+            <input
+            required
+            type="password"
+            autofocus
+            placeholder="Enter your new password"
+            v-model="new_password">
+          </div>
+          <button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Change password</button>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import Vue from 'vue'
+import config from '@/config'
+import logger from '@/logging'
+
+export default {
+  data () {
+    return {
+      // We need to initialize the component with any
+      // properties that will be used in it
+      old_password: '',
+      new_password: '',
+      error: '',
+      isLoading: false
+    }
+  },
+  methods: {
+    submit () {
+      var self = this
+      self.isLoading = true
+      this.error = ''
+      var credentials = {
+        old_password: this.old_password,
+        new_password1: this.new_password,
+        new_password2: this.new_password
+      }
+      let resource = Vue.resource(config.BACKEND_URL + 'api/auth/registration/change-password/')
+      return resource.save({}, credentials).then(response => {
+        logger.default.info('Password successfully changed')
+        self.$router.push('/profile/me')
+      }, response => {
+        if (response.status === 400) {
+          self.error = 'invalid_credentials'
+        } else {
+          self.error = 'unknown_error'
+        }
+        self.isLoading = false
+      })
+    }
+  }
+
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue
index 63c3ba79d18c7360cdef97f8739614cfb1f1f163..8577e84ca5d339e7ed3ad0b10a4e99ef69411c83 100644
--- a/front/src/components/favorites/List.vue
+++ b/front/src/components/favorites/List.vue
@@ -6,12 +6,39 @@
       </div>
       <h2 v-if="results" class="ui center aligned icon header">
         <i class="circular inverted heart pink icon"></i>
-        {{ favoriteTracks.count }} favorites
+        {{ $store.state.favorites.count }} favorites
       </h2>
       <radio-button type="favorites"></radio-button>
-
     </div>
     <div class="ui vertical stripe segment">
+      <div :class="['ui', {'loading': isLoading}, 'form']">
+        <div class="fields">
+          <div class="field">
+            <label>Ordering</label>
+            <select class="ui dropdown" v-model="ordering">
+              <option v-for="option in orderingOptions" :value="option[0]">
+                {{ option[1] }}
+              </option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Ordering direction</label>
+            <select class="ui dropdown" v-model="orderingDirection">
+              <option value="">Ascending</option>
+              <option value="-">Descending</option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Results per page</label>
+            <select class="ui dropdown" v-model="paginateBy">
+              <option :value="parseInt(12)">12</option>
+              <option :value="parseInt(25)">25</option>
+              <option :value="parseInt(50)">50</option>
+            </select>
+          </div>
+        </div>
+      </div>
+
       <track-table v-if="results" :tracks="results.results"></track-table>
       <div class="ui center aligned basic segment">
         <pagination
@@ -27,54 +54,73 @@
 </template>
 
 <script>
-import Vue from 'vue'
+import $ from 'jquery'
 import logger from '@/logging'
 import config from '@/config'
-import favoriteTracks from '@/favorites/tracks'
 import TrackTable from '@/components/audio/track/Table'
 import RadioButton from '@/components/radios/Button'
 import Pagination from '@/components/Pagination'
-
+import OrderingMixin from '@/components/mixins/Ordering'
+import PaginationMixin from '@/components/mixins/Pagination'
 const FAVORITES_URL = config.API_URL + 'tracks/'
 
 export default {
+  mixins: [OrderingMixin, PaginationMixin],
   components: {
     TrackTable,
     RadioButton,
     Pagination
   },
   data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || 'artist__name')
     return {
       results: null,
       isLoading: false,
       nextLink: null,
       previousLink: null,
-      page: 1,
-      paginateBy: 25,
-      favoriteTracks
+      page: parseInt(this.defaultPage),
+      paginateBy: parseInt(this.defaultPaginateBy || 25),
+      orderingDirection: defaultOrdering.direction,
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['title', 'Track name'],
+        ['album__title', 'Album name'],
+        ['artist__name', 'Artist name']
+      ]
     }
   },
   created () {
     this.fetchFavorites(FAVORITES_URL)
   },
+  mounted () {
+    $('.ui.dropdown').dropdown()
+  },
   methods: {
+    updateQueryString: function () {
+      this.$router.replace({
+        query: {
+          page: this.page,
+          paginateBy: this.paginateBy,
+          ordering: this.getOrderingAsString()
+        }
+      })
+    },
     fetchFavorites (url) {
       var self = this
       this.isLoading = true
       let params = {
         favorites: 'true',
         page: this.page,
-        page_size: this.paginateBy
+        page_size: this.paginateBy,
+        ordering: this.getOrderingAsString()
       }
       logger.default.time('Loading user favorites')
       this.$http.get(url, {params: params}).then((response) => {
         self.results = response.data
         self.nextLink = response.data.next
         self.previousLink = response.data.previous
-        Vue.set(favoriteTracks, 'count', response.data.count)
-        favoriteTracks.count = response.data.count
         self.results.results.forEach((track) => {
-          Vue.set(favoriteTracks.objects, track.id, true)
+          self.$store.commit('favorites/track', {id: track.id, value: true})
         })
         logger.default.timeEnd('Loading user favorites')
         self.isLoading = false
@@ -86,6 +132,19 @@ export default {
   },
   watch: {
     page: function () {
+      this.updateQueryString()
+      this.fetchFavorites(FAVORITES_URL)
+    },
+    paginateBy: function () {
+      this.updateQueryString()
+      this.fetchFavorites(FAVORITES_URL)
+    },
+    orderingDirection: function () {
+      this.updateQueryString()
+      this.fetchFavorites(FAVORITES_URL)
+    },
+    ordering: function () {
+      this.updateQueryString()
       this.fetchFavorites(FAVORITES_URL)
     }
   }
diff --git a/front/src/components/favorites/TrackFavoriteIcon.vue b/front/src/components/favorites/TrackFavoriteIcon.vue
index ef490da9b1caa56340926887b05ce8f4de05c4d1..d4838ba5f336ff0375145ed5925029a19bf68587 100644
--- a/front/src/components/favorites/TrackFavoriteIcon.vue
+++ b/front/src/components/favorites/TrackFavoriteIcon.vue
@@ -1,5 +1,5 @@
-<template>
-  <button @click="favoriteTracks.set(track.id, !isFavorite)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']">
+ <template>
+  <button @click="$store.dispatch('favorites/toggle', track.id)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']">
     <i class="heart icon"></i>
     <template v-if="isFavorite">
       In favorites
@@ -8,27 +8,15 @@
       Add to favorites
     </template>
   </button>
-  <i v-else @click="favoriteTracks.set(track.id, !isFavorite)" :class="['favorite-icon', 'heart', {'pink': isFavorite}, {'favorited': isFavorite}, 'link', 'icon']" :title="title"></i>
+  <i v-else @click="$store.dispatch('favorites/toggle', track.id)" :class="['favorite-icon', 'heart', {'pink': isFavorite}, {'favorited': isFavorite}, 'link', 'icon']" :title="title"></i>
 </template>
 
 <script>
-import favoriteTracks from '@/favorites/tracks'
-
 export default {
   props: {
     track: {type: Object},
     button: {type: Boolean, default: false}
   },
-  data () {
-    return {
-      favoriteTracks
-    }
-  },
-  methods: {
-    toggleFavorite () {
-      this.isFavorite = !this.isFavorite
-    }
-  },
   computed: {
     title () {
       if (this.isFavorite) {
@@ -38,7 +26,7 @@ export default {
       }
     },
     isFavorite () {
-      return favoriteTracks.objects[this.track.id]
+      return this.$store.getters['favorites/isFavorite'](this.track.id)
     }
   }
 
diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue
index 2f0fb0a9236197d725191d4d1106037f855d3955..23a30e7711fda31fcbb61f7860fa70b93807c7c6 100644
--- a/front/src/components/library/Artists.vue
+++ b/front/src/components/library/Artists.vue
@@ -1,11 +1,40 @@
 <template>
   <div>
-    <div v-if="isLoading" class="ui vertical segment">
-      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-    </div>
-    <div v-if="result" class="ui vertical stripe segment">
+    <div class="ui vertical stripe segment">
       <h2 class="ui header">Browsing artists</h2>
-      <div class="ui stackable three column grid">
+      <div :class="['ui', {'loading': isLoading}, 'form']">
+        <div class="fields">
+          <div class="field">
+            <label>Search</label>
+            <input type="text" v-model="query" placeholder="Enter an artist name..."/>
+          </div>
+          <div class="field">
+            <label>Ordering</label>
+            <select class="ui dropdown" v-model="ordering">
+              <option v-for="option in orderingOptions" :value="option[0]">
+                {{ option[1] }}
+              </option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Ordering direction</label>
+            <select class="ui dropdown" v-model="orderingDirection">
+              <option value="">Ascending</option>
+              <option value="-">Descending</option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Results per page</label>
+            <select class="ui dropdown" v-model="paginateBy">
+              <option :value="parseInt(12)">12</option>
+              <option :value="parseInt(25)">25</option>
+              <option :value="parseInt(50)">50</option>
+            </select>
+          </div>
+        </div>
+      </div>
+      <div class="ui hidden divider"></div>
+      <div v-if="result" class="ui stackable three column grid">
         <div
           v-if="result.results.length > 0"
           v-for="artist in result.results"
@@ -28,41 +57,71 @@
 </template>
 
 <script>
+import _ from 'lodash'
+import $ from 'jquery'
 
 import config from '@/config'
 import backend from '@/audio/backend'
 import logger from '@/logging'
+
+import OrderingMixin from '@/components/mixins/Ordering'
+import PaginationMixin from '@/components/mixins/Pagination'
 import ArtistCard from '@/components/audio/artist/Card'
 import Pagination from '@/components/Pagination'
 
 const FETCH_URL = config.API_URL + 'artists/'
 
 export default {
+  mixins: [OrderingMixin, PaginationMixin],
+  props: {
+    defaultQuery: {type: String, required: false, default: ''}
+  },
   components: {
     ArtistCard,
     Pagination
   },
   data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
     return {
       isLoading: true,
       result: null,
-      page: 1,
-      orderBy: 'name',
-      paginateBy: 12
+      page: parseInt(this.defaultPage),
+      query: this.defaultQuery,
+      paginateBy: parseInt(this.defaultPaginateBy || 12),
+      orderingDirection: defaultOrdering.direction,
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['creation_date', 'Creation date'],
+        ['name', 'Name']
+      ]
     }
   },
   created () {
     this.fetchData()
   },
+  mounted () {
+    $('.ui.dropdown').dropdown()
+  },
   methods: {
-    fetchData () {
+    updateQueryString: _.debounce(function () {
+      this.$router.replace({
+        query: {
+          query: this.query,
+          page: this.page,
+          paginateBy: this.paginateBy,
+          ordering: this.getOrderingAsString()
+        }
+      })
+    }, 500),
+    fetchData: _.debounce(function () {
       var self = this
       this.isLoading = true
       let url = FETCH_URL
       let params = {
         page: this.page,
         page_size: this.paginateBy,
-        order_by: 'name'
+        name__icontains: this.query,
+        ordering: this.getOrderingAsString()
       }
       logger.default.debug('Fetching artists')
       this.$http.get(url, {params: params}).then((response) => {
@@ -76,13 +135,30 @@ export default {
         })
         self.isLoading = false
       })
-    },
+    }, 500),
     selectPage: function (page) {
       this.page = page
     }
   },
   watch: {
     page () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    paginateBy () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    ordering () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    query () {
+      this.updateQueryString()
       this.fetchData()
     }
   }
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index da9ac19b3ad7cef127384b446296d76e9a39a638..f5303d88c8e14d8a959d541903e23e13ce100528 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -4,25 +4,18 @@
       <router-link class="ui item" to="/library" exact>Browse</router-link>
       <router-link class="ui item" to="/library/artists" exact>Artists</router-link>
       <div class="ui secondary right menu">
-        <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link>
-        <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link>
+        <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link>
+        <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link>
       </div>
     </div>
-    <router-view></router-view>
+    <router-view :key="$route.fullPath"></router-view>
   </div>
 </template>
 
 <script>
 
-import auth from '@/auth'
-
 export default {
-  name: 'library',
-  data: function () {
-    return {
-      auth
-    }
-  }
+  name: 'library'
 }
 </script>
 
@@ -30,6 +23,10 @@ export default {
 <style lang="scss">
 .library.pusher > .ui.secondary.menu {
   margin: 0 2.5rem;
+  .item {
+    padding-top: 1.5em;
+    padding-bottom: 1.5em;
+  }
 }
 
 .library {
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index 36a76e822c2ac0218d8c23a1fb2cca8f21db4f29..48cd801c3d8a96fc34b4904febbacb0d6243d66f 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -61,7 +61,6 @@
 
 <script>
 
-import auth from '@/auth'
 import url from '@/utils/url'
 import logger from '@/logging'
 import backend from '@/audio/backend'
@@ -124,8 +123,8 @@ export default {
     downloadUrl () {
       if (this.track.files.length > 0) {
         let u = backend.absoluteUrl(this.track.files[0].path)
-        if (auth.user.authenticated) {
-          u = url.updateQueryString(u, 'jwt', auth.getAuthToken())
+        if (this.$store.state.auth.authenticated) {
+          u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token)
         }
         return u
       }
diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue
index 41b94bd4eca31b81a2bf8b66accfd0def921717f..c78f1ea4e19a28195e573880e491baaf81147c7c 100644
--- a/front/src/components/library/import/BatchList.vue
+++ b/front/src/components/library/import/BatchList.vue
@@ -1,36 +1,38 @@
 <template>
   <div>
     <div class="ui vertical stripe segment">
-      <div v-if="isLoading" class="ui vertical segment">
-        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-      </div>
-    </div>
-    <div class="ui vertical stripe segment">
-        <button class="ui left floated labeled icon button" @click="fetchData(previousLink)" :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button>
-        <button class="ui right floated right labeled icon button" @click="fetchData(nextLink)" :disabled="!nextLink">Next <i class="right arrow icon"></i></button>
-        <div class="ui hidden clearing divider"></div>
-        <div class="ui hidden clearing divider"></div>
-        <table v-if="results.length > 0" class="ui table">
-          <thead>
-            <tr>
-              <th>ID</th>
-              <th>Launch date</th>
-              <th>Jobs</th>
-              <th>Status</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr v-for="result in results">
-              <td>{{ result.id }}</th>
-              <td>
-                <router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}">
-                  {{ result.creation_date }}
-                </router-link>
-              </td>
-              <td>{{ result.jobs.length }}</td>
-              <td>
-                <span
-                  :class="['ui', {'yellow': result.status === 'pending'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span>
+      <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+      <button
+        class="ui left floated labeled icon button"
+        @click="fetchData(previousLink)"
+        :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button>
+      <button
+        class="ui right floated right labeled icon button"
+        @click="fetchData(nextLink)"
+        :disabled="!nextLink">Next <i class="right arrow icon"></i></button>
+      <div class="ui hidden clearing divider"></div>
+      <div class="ui hidden clearing divider"></div>
+      <table v-if="results.length > 0" class="ui table">
+        <thead>
+          <tr>
+            <th>ID</th>
+            <th>Launch date</th>
+            <th>Jobs</th>
+            <th>Status</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="result in results">
+            <td>{{ result.id }}</th>
+            <td>
+              <router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}">
+                {{ result.creation_date }}
+              </router-link>
+            </td>
+            <td>{{ result.jobs.length }}</td>
+            <td>
+              <span
+                :class="['ui', {'yellow': result.status === 'pending'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span>
               </td>
             </tr>
           </tbody>
diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue
index 8a400cf7b0e5b488e794afb62a0ddab8d38ce59a..f2dea6cab9dfa45c0edc8fb3ff995961ca1b3dea 100644
--- a/front/src/components/metadata/Search.vue
+++ b/front/src/components/metadata/Search.vue
@@ -23,7 +23,6 @@
 <script>
 import jQuery from 'jquery'
 import config from '@/config'
-import auth from '@/auth'
 
 export default {
   props: {
@@ -66,7 +65,7 @@ export default {
         },
         apiSettings: {
           beforeXHR: function (xhrObject, s) {
-            xhrObject.setRequestHeader('Authorization', auth.getAuthHeader())
+            xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
             return xhrObject
           },
           onResponse: function (initialResponse) {
diff --git a/front/src/components/mixins/Ordering.vue b/front/src/components/mixins/Ordering.vue
new file mode 100644
index 0000000000000000000000000000000000000000..494dddcee15983d7449e477e9a7638d9befc2d1a
--- /dev/null
+++ b/front/src/components/mixins/Ordering.vue
@@ -0,0 +1,26 @@
+<script>
+export default {
+  props: {
+    defaultOrdering: {type: String, required: false}
+  },
+  methods: {
+    getOrderingFromString (s) {
+      let parts = s.split('-')
+      if (parts.length > 1) {
+        return {
+          direction: '-',
+          field: parts.slice(1).join('-')
+        }
+      } else {
+        return {
+          direction: '',
+          field: s
+        }
+      }
+    },
+    getOrderingAsString () {
+      return [this.orderingDirection, this.ordering].join('')
+    }
+  }
+}
+</script>
diff --git a/front/src/components/mixins/Pagination.vue b/front/src/components/mixins/Pagination.vue
new file mode 100644
index 0000000000000000000000000000000000000000..532faaaa3bc52872a6be104d5577f94b8f58d30c
--- /dev/null
+++ b/front/src/components/mixins/Pagination.vue
@@ -0,0 +1,8 @@
+<script>
+export default {
+  props: {
+    defaultPage: {required: false, default: 1},
+    defaultPaginateBy: {required: false}
+  }
+}
+</script>
diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue
index b334dce561183cb32a06ba8ce5b6955f202ef244..4bf4279890d05f39ac06a350164b9c2101747068 100644
--- a/front/src/components/radios/Button.vue
+++ b/front/src/components/radios/Button.vue
@@ -9,33 +9,28 @@
 
 <script>
 
-import radios from '@/radios'
-
 export default {
   props: {
     type: {type: String, required: true},
     objectId: {type: Number, default: null}
   },
-  data () {
-    return {
-      radios
-    }
-  },
   methods: {
     toggleRadio () {
       if (this.running) {
-        radios.stop()
+        this.$store.dispatch('radios/stop')
       } else {
-        radios.start(this.type, this.objectId)
+        this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId})
       }
     }
   },
   computed: {
     running () {
-      if (!radios.running) {
+      let state = this.$store.state.radios
+      let current = state.current
+      if (!state.running) {
         return false
       } else {
-        return radios.current.type === this.type & radios.current.objectId === this.objectId
+        return current.type === this.type & current.objectId === this.objectId
       }
     }
   }
diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue
index 1e496324aa893518ca2728fcf6f9f19c168bc9c1..dc8a24ff3c2e31d901ee11fb27eceb976ca7e819 100644
--- a/front/src/components/radios/Card.vue
+++ b/front/src/components/radios/Card.vue
@@ -13,7 +13,6 @@
 </template>
 
 <script>
-import radios from '@/radios'
 import RadioButton from './Button'
 
 export default {
@@ -25,7 +24,7 @@ export default {
   },
   computed: {
     radio () {
-      return radios.types[this.type]
+      return this.$store.getters['radios/types'][this.type]
     }
   }
 }
diff --git a/front/src/components/utils/global-events.vue b/front/src/components/utils/global-events.vue
index 2905e3a7d337dddc3b66ecc528e0d4f9c607dd9f..dd25865c902d1e0237a45149521279a403a11f66 100644
--- a/front/src/components/utils/global-events.vue
+++ b/front/src/components/utils/global-events.vue
@@ -27,7 +27,7 @@ export default {
       let wrapper = function (event) {
         // we check here the event is not triggered from an input
         // to avoid collisions
-        if (!$(event.target).is(':input, [contenteditable]')) {
+        if (!$(event.target).is('.field, :input, [contenteditable]')) {
           handler(event)
         }
       }
diff --git a/front/src/favorites/tracks.js b/front/src/favorites/tracks.js
deleted file mode 100644
index ac3cb5eaa2d41609d25b93e269de7622b3fabfad..0000000000000000000000000000000000000000
--- a/front/src/favorites/tracks.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import config from '@/config'
-import logger from '@/logging'
-import Vue from 'vue'
-
-const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/'
-const FAVORITES_URL = config.API_URL + 'favorites/tracks/'
-
-export default {
-  objects: {},
-  count: 0,
-  set (id, newValue) {
-    let self = this
-    Vue.set(self.objects, id, newValue)
-    if (newValue) {
-      Vue.set(self, 'count', self.count + 1)
-      let resource = Vue.resource(FAVORITES_URL)
-      resource.save({}, {'track': id}).then((response) => {
-        logger.default.info('Successfully added track to favorites')
-      }, (response) => {
-        logger.default.info('Error while adding track to favorites')
-        Vue.set(self.objects, id, !newValue)
-        Vue.set(self, 'count', self.count - 1)
-      })
-    } else {
-      Vue.set(self, 'count', self.count - 1)
-      let resource = Vue.resource(REMOVE_URL)
-      resource.delete({}, {'track': id}).then((response) => {
-        logger.default.info('Successfully removed track from favorites')
-      }, (response) => {
-        logger.default.info('Error while removing track from favorites')
-        Vue.set(self.objects, id, !newValue)
-        Vue.set(self, 'count', self.count + 1)
-      })
-    }
-  },
-  fetch (url) {
-    // will fetch favorites by batches from API to have them locally
-    var self = this
-    url = url || FAVORITES_URL
-    let resource = Vue.resource(url)
-    resource.get().then((response) => {
-      logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites')
-      Vue.set(self, 'count', response.data.count)
-      response.data.results.forEach(result => {
-        Vue.set(self.objects, result.track, true)
-      })
-      if (response.data.next) {
-        self.fetch(response.data.next)
-      }
-    })
-  }
-
-}
diff --git a/front/src/main.js b/front/src/main.js
index f153635121ececa77e909defc6defccc8d00c938..f7a6b65f4df7cdd2ddfc4b37914999b12b3e9186 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -9,8 +9,8 @@ import Vue from 'vue'
 import App from './App'
 import router from './router'
 import VueResource from 'vue-resource'
-import auth from './auth'
 import VueLazyload from 'vue-lazyload'
+import store from './store'
 
 window.$ = window.jQuery = require('jquery')
 
@@ -25,23 +25,25 @@ Vue.config.productionTip = false
 
 Vue.http.interceptors.push(function (request, next) {
   // modify headers
-  if (auth.user.authenticated) {
-    request.headers.set('Authorization', auth.getAuthHeader())
+  if (store.state.auth.authenticated) {
+    request.headers.set('Authorization', store.getters['auth/header'])
   }
   next(function (response) {
     // redirect to login form when we get unauthorized response from server
     if (response.status === 401) {
+      store.commit('auth/authenticated', false)
       logger.default.warn('Received 401 response from API, redirecting to login form')
       router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
     }
   })
 })
 
-auth.checkAuth()
+store.dispatch('auth/check')
 /* eslint-disable no-new */
 new Vue({
   el: '#app',
   router,
+  store,
   template: '<App/>',
   components: { App }
 })
diff --git a/front/src/radios/index.js b/front/src/radios/index.js
deleted file mode 100644
index b468830863f1a443a987149128495413b7b5f000..0000000000000000000000000000000000000000
--- a/front/src/radios/index.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import Vue from 'vue'
-import config from '@/config'
-import logger from '@/logging'
-import queue from '@/audio/queue'
-
-const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/'
-const GET_TRACK_URL = config.API_URL + 'radios/tracks/'
-
-var radios = {
-  types: {
-    random: {
-      name: 'Random',
-      description: "Totally random picks, maybe you'll discover new things?"
-    },
-    favorites: {
-      name: 'Favorites',
-      description: 'Play your favorites tunes in a never-ending happiness loop.'
-    },
-    'less-listened': {
-      name: 'Less listened',
-      description: "Listen to tracks you usually don't. It's time to restore some balance."
-    }
-  },
-  start (type, objectId) {
-    this.current.type = type
-    this.current.objectId = objectId
-    this.running = true
-    let resource = Vue.resource(CREATE_RADIO_URL)
-    var self = this
-    var params = {
-      radio_type: type,
-      related_object_id: objectId
-    }
-    resource.save({}, params).then((response) => {
-      logger.default.info('Successfully started radio ', type)
-      self.current.session = response.data.id
-      queue.populateFromRadio()
-    }, (response) => {
-      logger.default.error('Error while starting radio', type)
-    })
-  },
-  stop () {
-    this.current.type = null
-    this.current.objectId = null
-    this.running = false
-    this.session = null
-  },
-  fetch () {
-    let resource = Vue.resource(GET_TRACK_URL)
-    var self = this
-    var params = {
-      session: self.current.session
-    }
-    return resource.save({}, params)
-  }
-}
-
-Vue.set(radios, 'running', false)
-Vue.set(radios, 'current', {})
-Vue.set(radios.current, 'objectId', null)
-Vue.set(radios.current, 'type', null)
-Vue.set(radios.current, 'session', null)
-
-export default radios
diff --git a/front/src/router/index.js b/front/src/router/index.js
index d727276fc7b58f58d288bf4ae42282341cc65ce4..f4efc723f4abc2fb9dfdca2e062d433c09d4e91e 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -4,6 +4,7 @@ import PageNotFound from '@/components/PageNotFound'
 import Home from '@/components/Home'
 import Login from '@/components/auth/Login'
 import Profile from '@/components/auth/Profile'
+import Settings from '@/components/auth/Settings'
 import Logout from '@/components/auth/Logout'
 import Library from '@/components/library/Library'
 import LibraryHome from '@/components/library/Home'
@@ -39,6 +40,11 @@ export default new Router({
       name: 'logout',
       component: Logout
     },
+    {
+      path: '/settings',
+      name: 'settings',
+      component: Settings
+    },
     {
       path: '/@:username',
       name: 'profile',
@@ -47,14 +53,29 @@ export default new Router({
     },
     {
       path: '/favorites',
-      component: Favorites
+      component: Favorites,
+      props: (route) => ({
+        defaultOrdering: route.query.ordering,
+        defaultPage: route.query.page,
+        defaultPaginateBy: route.query.paginateBy
+      })
     },
     {
       path: '/library',
       component: Library,
       children: [
         { path: '', component: LibraryHome },
-        { path: 'artists/', name: 'library.artists.browse', component: LibraryArtists },
+        {
+          path: 'artists/',
+          name: 'library.artists.browse',
+          component: LibraryArtists,
+          props: (route) => ({
+            defaultOrdering: route.query.ordering,
+            defaultQuery: route.query.query,
+            defaultPaginateBy: route.query.paginateBy,
+            defaultPage: route.query.page
+          })
+        },
         { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
         { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
         { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
new file mode 100644
index 0000000000000000000000000000000000000000..d8bd197f33fabd1938fe65a5c71ca950314b663f
--- /dev/null
+++ b/front/src/store/auth.js
@@ -0,0 +1,119 @@
+import Vue from 'vue'
+import jwtDecode from 'jwt-decode'
+import config from '@/config'
+import logger from '@/logging'
+import router from '@/router'
+
+const LOGIN_URL = config.API_URL + 'token/'
+const REFRESH_TOKEN_URL = config.API_URL + 'token/refresh/'
+const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
+
+export default {
+  namespaced: true,
+  state: {
+    authenticated: false,
+    username: '',
+    availablePermissions: {},
+    profile: null,
+    token: '',
+    tokenData: {}
+  },
+  getters: {
+    header: state => {
+      return 'JWT ' + state.token
+    }
+  },
+  mutations: {
+    profile: (state, value) => {
+      state.profile = value
+    },
+    authenticated: (state, value) => {
+      state.authenticated = value
+      if (value === false) {
+        state.username = null
+        state.token = null
+        state.tokenData = null
+        state.profile = null
+        state.availablePermissions = {}
+      }
+    },
+    username: (state, value) => {
+      state.username = value
+    },
+    token: (state, value) => {
+      state.token = value
+      if (value) {
+        state.tokenData = jwtDecode(value)
+      } else {
+        state.tokenData = {}
+      }
+    },
+    permission: (state, {key, status}) => {
+      state.availablePermissions[key] = status
+    }
+  },
+  actions: {
+    // Send a request to the login URL and save the returned JWT
+    login ({commit, dispatch, state}, {next, credentials, onError}) {
+      let resource = Vue.resource(LOGIN_URL)
+      return resource.save({}, credentials).then(response => {
+        logger.default.info('Successfully logged in as', credentials.username)
+        commit('token', response.data.token)
+        commit('username', credentials.username)
+        commit('authenticated', true)
+        dispatch('fetchProfile')
+        // Redirect to a specified route
+        router.push(next)
+      }, response => {
+        logger.default.error('Error while logging in', response.data)
+        onError(response)
+      })
+    },
+    logout ({commit}) {
+      commit('authenticated', false)
+      logger.default.info('Log out, goodbye!')
+      router.push({name: 'index'})
+    },
+    check ({commit, dispatch, state}) {
+      logger.default.info('Checking authentication...')
+      var jwt = state.token
+      var username = state.username
+      if (jwt) {
+        commit('authenticated', true)
+        commit('username', username)
+        commit('token', jwt)
+        logger.default.info('Logged back in as ' + username)
+        dispatch('fetchProfile')
+        dispatch('refreshToken')
+      } else {
+        logger.default.info('Anonymous user')
+        commit('authenticated', false)
+      }
+    },
+    fetchProfile ({commit, dispatch, state}) {
+      let resource = Vue.resource(USER_PROFILE_URL)
+      return resource.get({}).then((response) => {
+        logger.default.info('Successfully fetched user profile')
+        let data = response.data
+        commit('profile', data)
+        dispatch('favorites/fetch', null, {root: true})
+        Object.keys(data.permissions).forEach(function (key) {
+          // this makes it easier to check for permissions in templates
+          commit('permission', {key, status: data.permissions[String(key)].status})
+        })
+        return response.data
+      }, (response) => {
+        logger.default.info('Error while fetching user profile')
+      })
+    },
+    refreshToken ({commit, dispatch, state}) {
+      let resource = Vue.resource(REFRESH_TOKEN_URL)
+      return resource.save({}, {token: state.token}).then(response => {
+        logger.default.info('Refreshed auth token')
+        commit('token', response.data.token)
+      }, response => {
+        logger.default.error('Error while refreshing token', response.data)
+      })
+    }
+  }
+}
diff --git a/front/src/store/favorites.js b/front/src/store/favorites.js
new file mode 100644
index 0000000000000000000000000000000000000000..9337966fdf68bc84202a8d2fe3e7add193d1fa20
--- /dev/null
+++ b/front/src/store/favorites.js
@@ -0,0 +1,73 @@
+import Vue from 'vue'
+import config from '@/config'
+import logger from '@/logging'
+
+const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/'
+const FAVORITES_URL = config.API_URL + 'favorites/tracks/'
+
+export default {
+  namespaced: true,
+  state: {
+    tracks: [],
+    count: 0
+  },
+  mutations: {
+    track: (state, {id, value}) => {
+      if (value) {
+        if (state.tracks.indexOf(id) === -1) {
+          state.tracks.push(id)
+        }
+      } else {
+        let i = state.tracks.indexOf(id)
+        if (i > -1) {
+          state.tracks.splice(i, 1)
+        }
+      }
+      state.count = state.tracks.length
+    }
+  },
+  getters: {
+    isFavorite: (state) => (id) => {
+      return state.tracks.indexOf(id) > -1
+    }
+  },
+  actions: {
+    set ({commit, state}, {id, value}) {
+      commit('track', {id, value})
+      if (value) {
+        let resource = Vue.resource(FAVORITES_URL)
+        resource.save({}, {'track': id}).then((response) => {
+          logger.default.info('Successfully added track to favorites')
+        }, (response) => {
+          logger.default.info('Error while adding track to favorites')
+          commit('track', {id, value: !value})
+        })
+      } else {
+        let resource = Vue.resource(REMOVE_URL)
+        resource.delete({}, {'track': id}).then((response) => {
+          logger.default.info('Successfully removed track from favorites')
+        }, (response) => {
+          logger.default.info('Error while removing track from favorites')
+          commit('track', {id, value: !value})
+        })
+      }
+    },
+    toggle ({getters, dispatch}, id) {
+      dispatch('set', {id, value: !getters['isFavorite'](id)})
+    },
+    fetch ({dispatch, state, commit}, url) {
+      // will fetch favorites by batches from API to have them locally
+      url = url || FAVORITES_URL
+      let resource = Vue.resource(url)
+      resource.get().then((response) => {
+        logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites')
+        response.data.results.forEach(result => {
+          commit('track', {id: result.track, value: true})
+        })
+        if (response.data.next) {
+          dispatch('fetch', response.data.next)
+        }
+      })
+    }
+  }
+}
diff --git a/front/src/store/index.js b/front/src/store/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..507f0b5876772364185fb6654e0b0a25fe95c913
--- /dev/null
+++ b/front/src/store/index.js
@@ -0,0 +1,100 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import createPersistedState from 'vuex-persistedstate'
+
+import favorites from './favorites'
+import auth from './auth'
+import queue from './queue'
+import radios from './radios'
+import player from './player'
+
+Vue.use(Vuex)
+
+export default new Vuex.Store({
+  modules: {
+    auth,
+    favorites,
+    queue,
+    radios,
+    player
+  },
+  plugins: [
+    createPersistedState({
+      key: 'auth',
+      paths: ['auth'],
+      filter: (mutation) => {
+        return mutation.type.startsWith('auth/')
+      }
+    }),
+    createPersistedState({
+      key: 'radios',
+      paths: ['radios'],
+      filter: (mutation) => {
+        return mutation.type.startsWith('radios/')
+      }
+    }),
+    createPersistedState({
+      key: 'player',
+      paths: [
+        'player.looping',
+        'player.playing',
+        'player.volume',
+        'player.duration',
+        'player.errored'],
+      filter: (mutation) => {
+        return mutation.type.startsWith('player/') && mutation.type !== 'player/currentTime'
+      }
+    }),
+    createPersistedState({
+      key: 'progress',
+      paths: ['player.currentTime'],
+      filter: (mutation) => {
+        let delay = 10
+        return mutation.type === 'player/currentTime' && parseInt(mutation.payload) % delay === 0
+      },
+      reducer: (state) => {
+        return {
+          player: {
+            currentTime: state.player.currentTime
+          }
+        }
+      }
+    }),
+    createPersistedState({
+      key: 'queue',
+      filter: (mutation) => {
+        return mutation.type.startsWith('queue/')
+      },
+      reducer: (state) => {
+        return {
+          queue: {
+            currentIndex: state.queue.currentIndex,
+            tracks: state.queue.tracks.map(track => {
+              // we keep only valuable fields to make the cache lighter and avoid
+              // cyclic value serialization errors
+              let artist = {
+                id: track.artist.id,
+                mbid: track.artist.mbid,
+                name: track.artist.name
+              }
+              return {
+                id: track.id,
+                title: track.title,
+                mbid: track.mbid,
+                album: {
+                  id: track.album.id,
+                  title: track.album.title,
+                  mbid: track.album.mbid,
+                  cover: track.album.cover,
+                  artist: artist
+                },
+                artist: artist,
+                files: track.files
+              }
+            })
+          }
+        }
+      }
+    })
+  ]
+})
diff --git a/front/src/store/player.js b/front/src/store/player.js
new file mode 100644
index 0000000000000000000000000000000000000000..74b0b9f9ea72dcbc38d0c3cefc6fa90d1647fc49
--- /dev/null
+++ b/front/src/store/player.js
@@ -0,0 +1,91 @@
+import Vue from 'vue'
+import config from '@/config'
+import logger from '@/logging'
+import time from '@/utils/time'
+
+export default {
+  namespaced: true,
+  state: {
+    playing: false,
+    volume: 0.5,
+    duration: 0,
+    currentTime: 0,
+    errored: false,
+    looping: 0 // 0 -> no, 1 -> on  track, 2 -> on queue
+  },
+  mutations: {
+    volume (state, value) {
+      value = parseFloat(value)
+      value = Math.min(value, 1)
+      value = Math.max(value, 0)
+      state.volume = value
+    },
+    incrementVolume (state, value) {
+      value = parseFloat(state.volume + value)
+      value = Math.min(value, 1)
+      value = Math.max(value, 0)
+      state.volume = value
+    },
+    duration (state, value) {
+      state.duration = value
+    },
+    errored (state, value) {
+      state.errored = value
+    },
+    currentTime (state, value) {
+      state.currentTime = value
+    },
+    looping (state, value) {
+      state.looping = value
+    },
+    playing (state, value) {
+      state.playing = value
+    },
+    toggleLooping (state) {
+      if (state.looping > 1) {
+        state.looping = 0
+      } else {
+        state.looping += 1
+      }
+    }
+  },
+  getters: {
+    durationFormatted: state => {
+      return time.parse(Math.round(state.duration))
+    },
+    currentTimeFormatted: state => {
+      return time.parse(Math.round(state.currentTime))
+    },
+    progress: state => {
+      return Math.round(state.currentTime / state.duration * 100)
+    }
+  },
+  actions: {
+    incrementVolume (context, value) {
+      context.commit('volume', context.state.volume + value)
+    },
+    stop (context) {
+    },
+    togglePlay ({commit, state}) {
+      commit('playing', !state.playing)
+    },
+    trackListened ({commit}, track) {
+      let url = config.API_URL + 'history/listenings/'
+      let resource = Vue.resource(url)
+      resource.save({}, {'track': track.id}).then((response) => {}, (response) => {
+        logger.default.error('Could not record track in history')
+      })
+    },
+    trackEnded ({dispatch}, track) {
+      dispatch('trackListened', track)
+      dispatch('queue/next', null, {root: true})
+    },
+    trackErrored ({commit, dispatch}) {
+      commit('errored', true)
+      dispatch('queue/next', null, {root: true})
+    },
+    updateProgress ({commit}, t) {
+      commit('currentTime', t)
+    }
+  }
+}
diff --git a/front/src/store/queue.js b/front/src/store/queue.js
new file mode 100644
index 0000000000000000000000000000000000000000..5dde19bd8e6f1665a826b522820e37ae3c14efaf
--- /dev/null
+++ b/front/src/store/queue.js
@@ -0,0 +1,150 @@
+import logger from '@/logging'
+import _ from 'lodash'
+
+export default {
+  namespaced: true,
+  state: {
+    tracks: [],
+    currentIndex: -1,
+    ended: true,
+    previousQueue: null
+  },
+  mutations: {
+    currentIndex (state, value) {
+      state.currentIndex = value
+    },
+    ended (state, value) {
+      state.ended = value
+    },
+    splice (state, {start, size}) {
+      state.tracks.splice(start, size)
+    },
+    tracks (state, value) {
+      state.tracks = value
+    },
+    insert (state, {track, index}) {
+      state.tracks.splice(index, 0, track)
+    },
+    reorder (state, {oldIndex, newIndex}) {
+      // called when the user uses drag / drop to reorder
+      // tracks in queue
+      if (oldIndex === state.currentIndex) {
+        state.currentIndex = newIndex
+        return
+      }
+      if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) {
+        // item before was moved after
+        state.currentIndex -= 1
+      }
+      if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) {
+        // item after was moved before
+        state.currentIndex += 1
+      }
+    }
+
+  },
+  getters: {
+    currentTrack: state => {
+      return state.tracks[state.currentIndex]
+    },
+    hasNext: state => {
+      return state.currentIndex < state.tracks.length - 1
+    },
+    hasPrevious: state => {
+      return state.currentIndex > 0
+    }
+  },
+  actions: {
+    append (context, {track, index, skipPlay}) {
+      index = index || context.state.tracks.length
+      if (index > context.state.tracks.length - 1) {
+        // we simply push to the end
+        context.commit('insert', {track, index: context.state.tracks.length})
+      } else {
+        // we insert the track at given position
+        context.commit('insert', {track, index})
+      }
+      if (!skipPlay) {
+        context.dispatch('resume')
+      }
+      // this.cache()
+    },
+
+    appendMany (context, {tracks, index}) {
+      logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title }))
+      if (context.state.tracks.length === 0) {
+        index = 0
+      } else {
+        index = index || context.state.tracks.length
+      }
+      tracks.forEach((t) => {
+        context.dispatch('append', {track: t, index: index, skipPlay: true})
+        index += 1
+      })
+      context.dispatch('resume')
+    },
+
+    cleanTrack ({state, dispatch, commit}, index) {
+      // are we removing current playin track
+      let current = index === state.currentIndex
+      if (current) {
+        dispatch('player/stop', null, {root: true})
+      }
+      if (index < state.currentIndex) {
+        dispatch('currentIndex', state.currentIndex - 1)
+      }
+      commit('splice', {start: index, size: 1})
+      if (current) {
+        // we play next track, which now have the same index
+        dispatch('currentIndex', index)
+      }
+    },
+
+    resume (context) {
+      if (context.state.ended | context.rootState.player.errored) {
+        context.dispatch('next')
+      }
+    },
+    previous (context) {
+      if (context.state.currentIndex > 0) {
+        context.dispatch('currentIndex', context.state.currentIndex - 1)
+      }
+    },
+    next ({state, dispatch, commit, rootState}) {
+      if (rootState.player.looping === 2 && state.currentIndex >= state.tracks.length - 1) {
+        logger.default.info('Going back to the beginning of the queue')
+        return dispatch('currentIndex', 0)
+      } else {
+        if (state.currentIndex < state.tracks.length - 1) {
+          logger.default.debug('Playing next track')
+          return dispatch('currentIndex', state.currentIndex + 1)
+        } else {
+          commit('ended', true)
+        }
+      }
+    },
+    currentIndex ({commit, state, rootState, dispatch}, index) {
+      commit('ended', false)
+      commit('player/currentTime', 0, {root: true})
+      commit('player/playing', true, {root: true})
+      commit('player/errored', false, {root: true})
+      commit('currentIndex', index)
+      if (state.tracks.length - index <= 2 && rootState.radios.running) {
+        dispatch('radios/populateQueue', null, {root: true})
+      }
+    },
+    clean ({dispatch, commit}) {
+      dispatch('player/stop', null, {root: true})
+      // radios.stop()
+      commit('tracks', [])
+      dispatch('currentIndex', -1)
+      // so we replay automatically on next track append
+      commit('ended', true)
+    },
+    shuffle ({dispatch, commit, state}) {
+      let shuffled = _.shuffle(state.tracks)
+      commit('tracks', [])
+      dispatch('appendMany', {tracks: shuffled})
+    }
+  }
+}
diff --git a/front/src/store/radios.js b/front/src/store/radios.js
new file mode 100644
index 0000000000000000000000000000000000000000..a9c429876a4ff974635de9f73062dd2597237209
--- /dev/null
+++ b/front/src/store/radios.js
@@ -0,0 +1,78 @@
+import Vue from 'vue'
+import config from '@/config'
+import logger from '@/logging'
+
+const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/'
+const GET_TRACK_URL = config.API_URL + 'radios/tracks/'
+
+export default {
+  namespaced: true,
+  state: {
+    current: null,
+    running: false
+  },
+  getters: {
+    types: state => {
+      return {
+        random: {
+          name: 'Random',
+          description: "Totally random picks, maybe you'll discover new things?"
+        },
+        favorites: {
+          name: 'Favorites',
+          description: 'Play your favorites tunes in a never-ending happiness loop.'
+        },
+        'less-listened': {
+          name: 'Less listened',
+          description: "Listen to tracks you usually don't. It's time to restore some balance."
+        }
+      }
+    }
+  },
+  mutations: {
+    current: (state, value) => {
+      state.current = value
+    },
+    running: (state, value) => {
+      state.running = value
+    }
+  },
+  actions: {
+    start ({commit, dispatch}, {type, objectId}) {
+      let resource = Vue.resource(CREATE_RADIO_URL)
+      var params = {
+        radio_type: type,
+        related_object_id: objectId
+      }
+      resource.save({}, params).then((response) => {
+        logger.default.info('Successfully started radio ', type)
+        commit('current', {type, objectId, session: response.data.id})
+        commit('running', true)
+        dispatch('populateQueue')
+      }, (response) => {
+        logger.default.error('Error while starting radio', type)
+      })
+    },
+    stop ({commit}) {
+      commit('current', null)
+      commit('running', false)
+    },
+    populateQueue ({state, dispatch}) {
+      if (!state.running) {
+        return
+      }
+      let resource = Vue.resource(GET_TRACK_URL)
+      var params = {
+        session: state.current.session
+      }
+      let promise = resource.save({}, params)
+      promise.then((response) => {
+        logger.default.info('Adding track to queue from radio')
+        dispatch('queue/append', {track: response.data.track}, {root: true})
+      }, (response) => {
+        logger.default.error('Error while adding track to queue from radio')
+      })
+    }
+  }
+
+}