diff --git a/.env.dev b/.env.dev
index de58e27583f7d520e29d997ccb0bb02f55823133..bc2d667b1d4bbb61b6e81583c981debb229d3204 100644
--- a/.env.dev
+++ b/.env.dev
@@ -1,3 +1,3 @@
-BACKEND_URL=http://localhost:12081
-YOUTUBE_API_KEY=
-API_AUTHENTICATION_REQUIRED=False
+BACKEND_URL=http://localhost:6001
+API_AUTHENTICATION_REQUIRED=True
+CACHALOT_ENABLED=False
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b85d6ac3b02895172619bee47aa50a6c791a724b..58602d296056e4c8fe131baf29252df9fa4c0d1f 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -70,6 +70,7 @@ docker_develop:
   stage: deploy
   before_script:
     - docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
+    - cp -r front/dist api/frontend
     - cd api
   script:
     - docker build -t $IMAGE .
@@ -83,6 +84,7 @@ docker_release:
   stage: deploy
   before_script:
     - docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
+    - cp -r front/dist api/frontend
     - cd api
   script:
     - docker build -t $IMAGE -t $IMAGE_LATEST .
diff --git a/api/compose/django/entrypoint.sh b/api/compose/django/entrypoint.sh
index a4060f65839ccdd40299edce6d51741fced4710e..98b3681e1cca70ee115d09f7cc2834e686de51bf 100755
--- a/api/compose/django/entrypoint.sh
+++ b/api/compose/django/entrypoint.sh
@@ -9,10 +9,15 @@ export REDIS_URL=redis://redis:6379/0
 # the official postgres image uses 'postgres' as default user if not set explictly.
 if [ -z "$POSTGRES_ENV_POSTGRES_USER" ]; then
     export POSTGRES_ENV_POSTGRES_USER=postgres
-fi 
+fi
 
 export DATABASE_URL=postgres://$POSTGRES_ENV_POSTGRES_USER:$POSTGRES_ENV_POSTGRES_PASSWORD@postgres:5432/$POSTGRES_ENV_POSTGRES_USER
 
 export CELERY_BROKER_URL=$REDIS_URL
 
-exec "$@"
\ No newline at end of file
+# we copy the frontend files, if any so we can serve them from the outside
+if [ -d "frontend" ]; then
+  mkdir -p /frontend
+  cp -r frontend/* /frontend/
+fi
+exec "$@"
diff --git a/api/compose/nginx/Dockerfile b/api/compose/nginx/Dockerfile
deleted file mode 100644
index 196395763a7b93c4842d68bf2bca8f9c9d1e635b..0000000000000000000000000000000000000000
--- a/api/compose/nginx/Dockerfile
+++ /dev/null
@@ -1,2 +0,0 @@
-FROM nginx:latest
-ADD nginx.conf /etc/nginx/nginx.conf
\ No newline at end of file
diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index b56944d4eed8b75faa0c51202c286ef4662d16ee..5ed4cffdd52b462480306176ed988798fcf99aa4 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -4,26 +4,40 @@ from funkwhale_api.music import views
 from funkwhale_api.playlists import views as playlists_views
 from rest_framework_jwt import views as jwt_views
 
+from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
+from dynamic_preferences.users.viewsets import UserPreferencesViewSet
 
 router = routers.SimpleRouter()
+router.register(r'settings', GlobalPreferencesViewSet, base_name='settings')
 router.register(r'tags', views.TagViewSet, 'tags')
 router.register(r'tracks', views.TrackViewSet, 'tracks')
+router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')
 router.register(r'artists', views.ArtistViewSet, 'artists')
 router.register(r'albums', views.AlbumViewSet, 'albums')
 router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches')
 router.register(r'submit', views.SubmitViewSet, 'submit')
 router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists')
-router.register(r'playlist-tracks', playlists_views.PlaylistTrackViewSet, 'playlist-tracks')
+router.register(
+    r'playlist-tracks',
+    playlists_views.PlaylistTrackViewSet,
+    'playlist-tracks')
 v1_patterns = router.urls
 
 v1_patterns += [
-    url(r'^providers/', include('funkwhale_api.providers.urls', namespace='providers')),
-    url(r'^favorites/', include('funkwhale_api.favorites.urls', namespace='favorites')),
-    url(r'^search$', views.Search.as_view(), name='search'),
-    url(r'^radios/', include('funkwhale_api.radios.urls', namespace='radios')),
-    url(r'^history/', include('funkwhale_api.history.urls', namespace='history')),
-    url(r'^users/', include('funkwhale_api.users.api_urls', namespace='users')),
-    url(r'^token/', jwt_views.obtain_jwt_token),
+    url(r'^providers/',
+        include('funkwhale_api.providers.urls', namespace='providers')),
+    url(r'^favorites/',
+        include('funkwhale_api.favorites.urls', namespace='favorites')),
+    url(r'^search$',
+        views.Search.as_view(), name='search'),
+    url(r'^radios/',
+        include('funkwhale_api.radios.urls', namespace='radios')),
+    url(r'^history/',
+        include('funkwhale_api.history.urls', namespace='history')),
+    url(r'^users/',
+        include('funkwhale_api.users.api_urls', namespace='users')),
+    url(r'^token/',
+        jwt_views.obtain_jwt_token),
     url(r'^token/refresh/', jwt_views.refresh_jwt_token),
 ]
 
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 93381c4f5399425e340961bbde35500884df88bc..70804c3c9dd2144783746c7a69596edb75be8350 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -53,6 +53,7 @@ THIRD_PARTY_APPS = (
     'rest_auth',
     'rest_auth.registration',
     'mptt',
+    'dynamic_preferences',
 )
 
 # Apps specific for this project go here.
@@ -65,6 +66,7 @@ LOCAL_APPS = (
     'funkwhale_api.history',
     'funkwhale_api.playlists',
     'funkwhale_api.providers.audiofile',
+    'funkwhale_api.providers.youtube',
 )
 
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
@@ -217,7 +219,6 @@ STATICFILES_FINDERS = (
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root
 MEDIA_ROOT = str(APPS_DIR('media'))
 
-USE_SAMPLE_TRACK = env.bool("USE_SAMPLE_TRACK", False)
 
 
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
@@ -261,7 +262,6 @@ BROKER_URL = env("CELERY_BROKER_URL", default='django://')
 
 # Location of root django.contrib.admin URL, use {% url 'admin:index' %}
 ADMIN_URL = r'^admin/'
-SESSION_SAVE_EVERY_REQUEST = True
 # Your common stuff: Below this line define 3rd party library settings
 CELERY_DEFAULT_RATE_LIMIT = 1
 CELERYD_TASK_TIME_LIMIT = 300
@@ -290,6 +290,7 @@ REST_FRAMEWORK = {
     'PAGE_SIZE': 25,
 
     'DEFAULT_AUTHENTICATION_CLASSES': (
+        'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
         'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
         'rest_framework.authentication.SessionAuthentication',
         'rest_framework.authentication.BasicAuthentication',
@@ -299,9 +300,24 @@ REST_FRAMEWORK = {
     )
 }
 
-FUNKWHALE_PROVIDERS = {
-    'youtube': {
-        'api_key': env('YOUTUBE_API_KEY', default='REPLACE_ME')
-    }
-}
 ATOMIC_REQUESTS = False
+
+# Wether we should check user permission before serving audio files (meaning
+# return an obfuscated url)
+# This require a special configuration on the reverse proxy side
+# See https://wellfire.co/learn/nginx-django-x-accel-redirects/ for example
+PROTECT_AUDIO_FILES = env.bool('PROTECT_AUDIO_FILES', default=True)
+
+# Which path will be used to process the internal redirection
+# **DO NOT** put a slash at the end
+PROTECT_FILES_PATH = env('PROTECT_FILES_PATH', default='/_protected')
+
+
+# use this setting to tweak for how long you want to cache
+# musicbrainz results. (value is in seconds)
+MUSICBRAINZ_CACHE_DURATION = env.int(
+    'MUSICBRAINZ_CACHE_DURATION',
+    default=300
+)
+
+CACHALOT_ENABLED = env.bool('CACHALOT_ENABLED', default=True)
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index 6b304975e3c17ceac78a7949e20ed41fb39c8f15..70d6b5ac1643438d12f9f87edc04735728a0dcfe 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.1.0'
+__version__ = '0.2.0'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/common/authentication.py b/api/funkwhale_api/common/authentication.py
new file mode 100644
index 0000000000000000000000000000000000000000..b75f3b516d5d2505f40900f7314f1f70e557ecb2
--- /dev/null
+++ b/api/funkwhale_api/common/authentication.py
@@ -0,0 +1,20 @@
+from rest_framework import exceptions
+from rest_framework_jwt import authentication
+from rest_framework_jwt.settings import api_settings
+
+
+class JSONWebTokenAuthenticationQS(
+        authentication.BaseJSONWebTokenAuthentication):
+
+    www_authenticate_realm = 'api'
+
+    def get_jwt_value(self, request):
+        token = request.query_params.get('jwt')
+        if 'jwt' in request.query_params and not token:
+            msg = _('Invalid Authorization header. No credentials provided.')
+            raise exceptions.AuthenticationFailed(msg)
+        return token
+
+    def authenticate_header(self, request):
+        return '{0} realm="{1}"'.format(
+            api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm)
diff --git a/api/funkwhale_api/common/tests/test_jwt_querystring.py b/api/funkwhale_api/common/tests/test_jwt_querystring.py
new file mode 100644
index 0000000000000000000000000000000000000000..90e63775d9ef7e5e2aeeaac6a144d3ff1812bd6e
--- /dev/null
+++ b/api/funkwhale_api/common/tests/test_jwt_querystring.py
@@ -0,0 +1,32 @@
+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/music/metadata.py b/api/funkwhale_api/music/metadata.py
index d1be9a4e1f879dd0b5328767c311f29141b85e4a..31d13d4957f6882b647b99388f334a1b03f092bb 100644
--- a/api/funkwhale_api/music/metadata.py
+++ b/api/funkwhale_api/music/metadata.py
@@ -37,13 +37,26 @@ def get_mp3_recording_id(f, k):
     except IndexError:
         raise TagNotFound(k)
 
+
+def convert_track_number(v):
+    try:
+        return int(v)
+    except ValueError:
+        # maybe the position is of the form "1/4"
+        pass
+
+    try:
+        return int(v.split('/')[0])
+    except (ValueError, AttributeError, IndexError):
+        pass
+
 CONF = {
     'OggVorbis': {
         'getter': lambda f, k: f[k][0],
         'fields': {
             'track_number': {
                 'field': 'TRACKNUMBER',
-                'to_application': int
+                'to_application': convert_track_number
             },
             'title': {
                 'field': 'title'
@@ -74,7 +87,7 @@ CONF = {
         'fields': {
             'track_number': {
                 'field': 'TPOS',
-                'to_application': lambda v: int(v.split('/')[0])
+                'to_application': convert_track_number
             },
             'title': {
                 'field': 'TIT2'
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 70230847700b9b6b788b8d36ea22b9774a963947..6a55dfc00c385f18da92c7f6c0da49458c16402e 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -8,7 +8,6 @@ import markdown
 
 from django.conf import settings
 from django.db import models
-from django.contrib.staticfiles.templatetags.staticfiles import static
 from django.core.files.base import ContentFile
 from django.core.files import File
 from django.core.urlresolvers import reverse
@@ -354,10 +353,12 @@ class TrackFile(models.Model):
 
     @property
     def path(self):
-        if settings.USE_SAMPLE_TRACK:
-            return static('music/sample1.ogg')
+        if settings.PROTECT_AUDIO_FILES:
+            return reverse(
+                'api:v1:trackfiles-serve', kwargs={'pk': self.pk})
         return self.audio_file.url
 
+
 class ImportBatch(models.Model):
     creation_date = models.DateTimeField(default=timezone.now)
     submitted_by = models.ForeignKey('users.User', related_name='imports')
diff --git a/api/funkwhale_api/music/tests/factories.py b/api/funkwhale_api/music/tests/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..dfa7a75e2814c82e276f692da07d1411da2b3e95
--- /dev/null
+++ b/api/funkwhale_api/music/tests/factories.py
@@ -0,0 +1,39 @@
+import factory
+
+
+class ArtistFactory(factory.django.DjangoModelFactory):
+    name = factory.Sequence(lambda n: 'artist-{0}'.format(n))
+    mbid = factory.Faker('uuid4')
+
+    class Meta:
+        model = 'music.Artist'
+
+
+class AlbumFactory(factory.django.DjangoModelFactory):
+    title = factory.Sequence(lambda n: 'album-{0}'.format(n))
+    mbid = factory.Faker('uuid4')
+    release_date = factory.Faker('date')
+    cover = factory.django.ImageField()
+    artist = factory.SubFactory(ArtistFactory)
+
+    class Meta:
+        model = 'music.Album'
+
+
+class TrackFactory(factory.django.DjangoModelFactory):
+    title = factory.Sequence(lambda n: 'track-{0}'.format(n))
+    mbid = factory.Faker('uuid4')
+    album = factory.SubFactory(AlbumFactory)
+    artist = factory.SelfAttribute('album.artist')
+    position = 1
+
+    class Meta:
+        model = 'music.Track'
+
+
+class TrackFileFactory(factory.django.DjangoModelFactory):
+    track = factory.SubFactory(TrackFactory)
+    audio_file = factory.django.FileField()
+
+    class Meta:
+        model = 'music.TrackFile'
diff --git a/api/funkwhale_api/music/tests/test_api.py b/api/funkwhale_api/music/tests/test_api.py
index d8f56eeb92604b9b9399c61c0124b9b9d8256fed..21a567084e25a4d3ade7a27fe878d227839f4bbf 100644
--- a/api/funkwhale_api/music/tests/test_api.py
+++ b/api/funkwhale_api/music/tests/test_api.py
@@ -10,6 +10,8 @@ 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):
 
@@ -214,3 +216,26 @@ class TestAPI(TMPDirTestCaseMixin, TestCase):
             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/views.py b/api/funkwhale_api/music/views.py
index 772f4173ea0a5de52ff3e5b75293c8d6affb0ecd..4a4032c57f80695ba88fb92826941be3ede23605 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -1,8 +1,11 @@
 import os
 import json
+import unicodedata
+import urllib
 from django.core.urlresolvers import reverse
 from django.db import models, transaction
 from django.db.models.functions import Length
+from django.conf import settings
 from rest_framework import viewsets, views
 from rest_framework.decorators import detail_route, list_route
 from rest_framework.response import Response
@@ -51,6 +54,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     search_fields = ['name']
     ordering_fields = ('creation_date',)
 
+
 class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     queryset = (
         models.Album.objects.all()
@@ -63,6 +67,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     search_fields = ['title']
     ordering_fields = ('creation_date',)
 
+
 class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = models.ImportBatch.objects.all().order_by('-creation_date')
     serializer_class = serializers.ImportBatchSerializer
@@ -70,6 +75,7 @@ class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
     def get_queryset(self):
         return super().get_queryset().filter(submitted_by=self.request.user)
 
+
 class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
     """
     A simple ViewSet for viewing and editing accounts.
@@ -120,6 +126,29 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
         return Response(serializer.data)
 
 
+class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
+    queryset = (models.TrackFile.objects.all().order_by('-id'))
+    serializer_class = serializers.TrackFileSerializer
+    permission_classes = [ConditionalAuthentication]
+
+    @detail_route(methods=['get'])
+    def serve(self, request, *args, **kwargs):
+        try:
+            f = models.TrackFile.objects.get(pk=kwargs['pk'])
+        except models.TrackFile.DoesNotExist:
+            return Response(status=404)
+
+        response = Response()
+        filename = "filename*=UTF-8''{}{}".format(
+            urllib.parse.quote(f.track.full_name),
+            os.path.splitext(f.audio_file.name)[-1])
+        response["Content-Disposition"] = "attachment; {}".format(filename)
+        response['X-Accel-Redirect'] = "{}{}".format(
+            settings.PROTECT_FILES_PATH,
+            f.audio_file.url)
+        return response
+
+
 class TagViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = Tag.objects.all().order_by('name')
     serializer_class = serializers.TagSerializer
diff --git a/api/funkwhale_api/musicbrainz/client.py b/api/funkwhale_api/musicbrainz/client.py
index e281555b2eb98afb47ff67220ace81746d3c5b85..049ed298c7b22cb6f717ee3b571f1153d47f3c2f 100644
--- a/api/funkwhale_api/musicbrainz/client.py
+++ b/api/funkwhale_api/musicbrainz/client.py
@@ -1,11 +1,17 @@
 import musicbrainzngs
+import memoize.djangocache
 
 from django.conf import settings
 from funkwhale_api import __version__
+
 _api = musicbrainzngs
 _api.set_useragent('funkwhale', str(__version__), 'contact@eliotberriot.com')
 
 
+store = memoize.djangocache.Cache('default')
+memo = memoize.Memoizer(store, namespace='memoize:musicbrainz')
+
+
 def clean_artist_search(query, **kwargs):
     cleaned_kwargs = {}
     if kwargs.get('name'):
@@ -17,30 +23,55 @@ class API(object):
     _api = _api
 
     class artists(object):
-        search = clean_artist_search
-        get = _api.get_artist_by_id
+        search = memo(
+            clean_artist_search, max_age=settings.MUSICBRAINZ_CACHE_DURATION)
+        get = memo(
+            _api.get_artist_by_id,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
 
     class images(object):
-        get_front = _api.get_image_front
+        get_front = memo(
+            _api.get_image_front,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
 
     class recordings(object):
-        search = _api.search_recordings
-        get = _api.get_recording_by_id
+        search = memo(
+            _api.search_recordings,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
+        get = memo(
+            _api.get_recording_by_id,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
 
     class works(object):
-        search = _api.search_works
-        get = _api.get_work_by_id
+        search = memo(
+            _api.search_works,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
+        get = memo(
+            _api.get_work_by_id,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
 
     class releases(object):
-        search = _api.search_releases
-        get = _api.get_release_by_id
-        browse = _api.browse_releases
+        search = memo(
+            _api.search_releases,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
+        get = memo(
+            _api.get_release_by_id,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
+        browse = memo(
+            _api.browse_releases,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
         # get_image_front = _api.get_image_front
 
     class release_groups(object):
-        search = _api.search_release_groups
-        get = _api.get_release_group_by_id
-        browse = _api.browse_release_groups
+        search = memo(
+            _api.search_release_groups,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
+        get = memo(
+            _api.get_release_group_by_id,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
+        browse = memo(
+            _api.browse_release_groups,
+            max_age=settings.MUSICBRAINZ_CACHE_DURATION)
         # get_image_front = _api.get_image_front
 
 api = API()
diff --git a/api/funkwhale_api/musicbrainz/tests/test_cache.py b/api/funkwhale_api/musicbrainz/tests/test_cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..d2d1260ecb1cb36dc95e5268d269b0d8dbcf774c
--- /dev/null
+++ b/api/funkwhale_api/musicbrainz/tests/test_cache.py
@@ -0,0 +1,17 @@
+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/musicbrainz/views.py b/api/funkwhale_api/musicbrainz/views.py
index fac8089b98134d890ed5919e923df1d5c4dd6dcf..5c101b161900d5f2eada7ecf0c0ad7f1dabc2c74 100644
--- a/api/funkwhale_api/musicbrainz/views.py
+++ b/api/funkwhale_api/musicbrainz/views.py
@@ -2,6 +2,7 @@ from rest_framework import viewsets
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework.decorators import list_route
+import musicbrainzngs
 
 from funkwhale_api.common.permissions import ConditionalAuthentication
 
@@ -44,7 +45,7 @@ class ReleaseBrowse(APIView):
     def get(self, request, *args, **kwargs):
         result = api.releases.browse(
             release_group=kwargs['release_group_uuid'],
-            includes=['recordings'])
+            includes=['recordings', 'artist-credits'])
         return Response(result)
 
 
@@ -54,17 +55,18 @@ class SearchViewSet(viewsets.ViewSet):
     @list_route(methods=['get'])
     def recordings(self, request, *args, **kwargs):
         query = request.GET['query']
-        results = api.recordings.search(query, artist=query)
+        results = api.recordings.search(query)
         return Response(results)
 
     @list_route(methods=['get'])
     def releases(self, request, *args, **kwargs):
         query = request.GET['query']
-        results = api.releases.search(query, artist=query)
+        results = api.releases.search(query)
         return Response(results)
 
     @list_route(methods=['get'])
     def artists(self, request, *args, **kwargs):
         query = request.GET['query']
         results = api.artists.search(query)
+        # results = musicbrainzngs.search_artists(query)
         return Response(results)
diff --git a/api/funkwhale_api/providers/youtube/client.py b/api/funkwhale_api/providers/youtube/client.py
index 7c0ea326c49275bc8d8ced6769ade084549fb3b8..792e501d7462456aec9ce2fb330096bf72282e4a 100644
--- a/api/funkwhale_api/providers/youtube/client.py
+++ b/api/funkwhale_api/providers/youtube/client.py
@@ -4,21 +4,20 @@ from apiclient.discovery import build
 from apiclient.errors import HttpError
 from oauth2client.tools import argparser
 
-from django.conf import settings
+from dynamic_preferences.registries import (
+    global_preferences_registry as registry)
 
-# Set DEVELOPER_KEY to the API key value from the APIs & auth > Registered apps
-# tab of
-#   https://cloud.google.com/console
-# Please ensure that you have enabled the YouTube Data API for your project.
-DEVELOPER_KEY = settings.FUNKWHALE_PROVIDERS['youtube']['api_key']
 YOUTUBE_API_SERVICE_NAME = "youtube"
 YOUTUBE_API_VERSION = "v3"
 VIDEO_BASE_URL = 'https://www.youtube.com/watch?v={0}'
 
 
 def _do_search(query):
-    youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION,
-    developerKey=DEVELOPER_KEY)
+    manager = registry.manager()
+    youtube = build(
+        YOUTUBE_API_SERVICE_NAME,
+        YOUTUBE_API_VERSION,
+        developerKey=manager['providers_youtube__api_key'])
 
     return youtube.search().list(
         q=query,
@@ -55,4 +54,33 @@ class Client(object):
 
         return results
 
+    def to_funkwhale(self, result):
+        """
+        We convert youtube results to something more generic.
+
+        {
+            "id": "video id",
+            "type": "youtube#video",
+            "url": "https://www.youtube.com/watch?v=id",
+            "description": "description",
+            "channelId": "Channel id",
+            "title": "Title",
+            "channelTitle": "channel Title",
+            "publishedAt": "2012-08-22T18:41:03.000Z",
+            "cover": "http://coverurl"
+        }
+        """
+        return {
+            'id': result['id']['videoId'],
+            'url': 'https://www.youtube.com/watch?v={}'.format(
+                result['id']['videoId']),
+            'type': result['id']['kind'],
+            'title': result['snippet']['title'],
+            'description': result['snippet']['description'],
+            'channelId': result['snippet']['channelId'],
+            'channelTitle': result['snippet']['channelTitle'],
+            'publishedAt': result['snippet']['publishedAt'],
+            'cover': result['snippet']['thumbnails']['high']['url'],
+        }
+
 client = Client()
diff --git a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc7f7d793e5a535f344d63678f418b0736ae2862
--- /dev/null
+++ b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py
@@ -0,0 +1,13 @@
+from dynamic_preferences.types import StringPreference, Section
+from dynamic_preferences.registries import global_preferences_registry
+
+youtube = Section('providers_youtube')
+
+
+@global_preferences_registry.register
+class APIKey(StringPreference):
+    section = youtube
+    name = 'api_key'
+    default = 'CHANGEME'
+    verbose_name = 'YouTube API key'
+    help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.'
diff --git a/api/funkwhale_api/providers/youtube/tests/test_youtube.py b/api/funkwhale_api/providers/youtube/tests/test_youtube.py
index ca0a956285c845854267f146834825e7097f5dd3..db6bd8413f24bc77035737c936591701f14dd80a 100644
--- a/api/funkwhale_api/providers/youtube/tests/test_youtube.py
+++ b/api/funkwhale_api/providers/youtube/tests/test_youtube.py
@@ -8,7 +8,7 @@ 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'])
@@ -25,11 +25,23 @@ class TestAPI(TestCase):
         return_value=api_data.search['8 bit adventure'])
     def test_can_get_search_results_from_funkwhale(self, *mocks):
         query = '8 bit adventure'
-        expected = json.dumps(client.search(query))
         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.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
+        self.assertEqual(
+            json.loads(response.content.decode('utf-8'))[0], expected)
 
     @unittest.mock.patch(
         'funkwhale_api.providers.youtube.client._do_search',
@@ -66,9 +78,22 @@ class TestAPI(TestCase):
             'q': '8 bit adventure',
         }
 
-        expected = json.dumps(client.search_multiple(queries))
+        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.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
+        self.assertEqual(
+            expected,
+            json.loads(response.content.decode('utf-8'))['1'][0])
diff --git a/api/funkwhale_api/providers/youtube/views.py b/api/funkwhale_api/providers/youtube/views.py
index 7ad2c2c3df38f484793aa9542017e62d2dd9b3e1..989b33090cad266921de3f4161b1422f63a4c876 100644
--- a/api/funkwhale_api/providers/youtube/views.py
+++ b/api/funkwhale_api/providers/youtube/views.py
@@ -10,7 +10,10 @@ class APISearch(APIView):
 
     def get(self, request, *args, **kwargs):
         results = client.search(request.GET['query'])
-        return Response(results)
+        return Response([
+            client.to_funkwhale(result)
+            for result in results
+        ])
 
 
 class APISearchs(APIView):
@@ -18,4 +21,10 @@ class APISearchs(APIView):
 
     def post(self, request, *args, **kwargs):
         results = client.search_multiple(request.data)
-        return Response(results)
+        return Response({
+            key: [
+                client.to_funkwhale(result)
+                for result in group
+            ]
+            for key, group in results.items()
+        })
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 1abbbb51ffdb5ee99d50681a82d145796638adb6..c5ca896ab667e6e69f588dd4446643844003382e 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -19,8 +19,11 @@ class User(AbstractUser):
     relevant_permissions = {
         # internal_codename : {external_codename}
         'music.add_importbatch': {
-            'external_codename': 'import.launch'
-        }
+            'external_codename': 'import.launch',
+        },
+        'dynamic_preferences.change_globalpreferencemodel': {
+            'external_codename': 'settings.change',
+        },
     }
 
     def __str__(self):
diff --git a/api/funkwhale_api/users/tests/test_views.py b/api/funkwhale_api/users/tests/test_views.py
index 6250a7ca7873a51b7508dc548c8fe88373d25c24..52826cfa4b53da83487d86bea8ca0899d5e2e97c 100644
--- a/api/funkwhale_api/users/tests/test_views.py
+++ b/api/funkwhale_api/users/tests/test_views.py
@@ -47,7 +47,13 @@ class UserTestCase(TestCase):
         # login required
         self.assertEqual(response.status_code, 401)
 
-        user = UserFactory(is_staff=True, perms=['music.add_importbatch'])
+        user = UserFactory(
+            is_staff=True,
+            perms=[
+                'music.add_importbatch',
+                'dynamic_preferences.change_globalpreferencemodel',
+            ]
+        )
         self.assertTrue(user.has_perm('music.add_importbatch'))
         self.login(user)
 
@@ -63,3 +69,5 @@ class UserTestCase(TestCase):
         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/requirements/base.txt b/api/requirements/base.txt
index ae851962a47279cf40070fe5e053a77ab8fb5620..bdf17cf9a6aaa2473fe66bf9a4b93b7ea90697cc 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -50,3 +50,9 @@ beautifulsoup4==4.6.0
 Markdown==2.6.8
 ipython==6.1.0
 mutagen==1.38
+
+
+# Until this is merged
+git+https://github.com/EliotBerriot/PyMemoize.git@django
+
+django-dynamic-preferences>=1.2,<1.3
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 1a1b81b39fd3ca99b05413d870d03806120aef39..4ffede783e55cc2b07ec010260b436f278ca1700 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -6,13 +6,15 @@ services:
     restart: unless-stopped
     env_file: .env
     image: postgres:9.4
+    volumes:
+      - ./data/postgres:/var/lib/postgresql/data
 
   redis:
     restart: unless-stopped
     env_file: .env
     image: redis:3
     volumes:
-      - ./data:/data
+      - ./data/redis:/data
 
   celeryworker:
     restart: unless-stopped
@@ -46,6 +48,7 @@ services:
       - ./data/music:/music:ro
       - ./data/media:/app/funkwhale_api/media
       - ./data/static:/app/staticfiles
+      - ./front/dist:/frontend
     ports:
       - "${FUNKWHALE_API_IP:-127.0.0.1}:${FUNKWHALE_API_PORT:-5000}:5000"
     links:
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index 7395e37d90a4fa5b1ce800ce5c4afdfff2dfb5a4..6a0a9f50936f32dfc9c29cbf29ac58ed1658e473 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -47,7 +47,17 @@ server {
     location /media/ {
         alias /srv/funkwhale/data/media/;
     }
+
+    location /_protected/media {
+        # this is an internal location that is used to serve
+        # audio files once correct permission / authentication
+        # has been checked on API side
+        internal;
+        alias   /srv/funkwhale/data/media;
+    }
+
     location /staticfiles/ {
+        # django static files
         alias /srv/funkwhale/data/static/;
     }
 }
diff --git a/dev.yml b/dev.yml
index 21b0912e397d554793b387892fa9a7c816f5f090..f0fc8845a085e6ed27251b3a7cd24e3006b0db91 100644
--- a/dev.yml
+++ b/dev.yml
@@ -27,7 +27,7 @@ services:
     env_file: .env.dev
     build:
       context: ./api
-      dockerfile: docker/Dockerfile.local
+      dockerfile: docker/Dockerfile.test
     links:
      - postgres
      - redis
@@ -53,12 +53,14 @@ services:
       - redis
       - celeryworker
 
-  # nginx:
-  #   env_file: .env.dev
-  #   build: ./api/compose/nginx
-  #   links:
-  #     - api
-  #   volumes:
-  #     - ./api/funkwhale_api/media:/staticfiles/media
-  #   ports:
-  #     - "0.0.0.0:6001:80"
+  nginx:
+    env_file: .env.dev
+    image: nginx
+    links:
+      - api
+      - front
+    volumes:
+      - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
+      - ./api/funkwhale_api/media:/protected/media
+    ports:
+      - "0.0.0.0:6001:80"
diff --git a/api/compose/nginx/nginx.conf b/docker/nginx/conf.dev
similarity index 72%
rename from api/compose/nginx/nginx.conf
rename to docker/nginx/conf.dev
index 331d8d45f3c2311cac0aafd01ded6bb22f0454bd..6ca395fb1aaf47fbfb1a09bfd491daddc5f28875 100644
--- a/api/compose/nginx/nginx.conf
+++ b/docker/nginx/conf.dev
@@ -27,27 +27,21 @@ http {
 
     #gzip  on;
 
-    upstream app {
-        server django:12081;
-    }
-
     server {
         listen 80;
         charset     utf-8;
 
-        root /staticfiles;
-        location / {
-            # checks for static file, if not found proxy to app
-            try_files $uri @proxy_to_app;
+        location /_protected/media {
+            internal;
+            alias   /protected/media;
         }
-
-        location @proxy_to_app {
+        location / {
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
             proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-            proxy_set_header Host $http_host;
+            proxy_set_header X-Forwarded-Proto $scheme;
             proxy_redirect off;
-
-            proxy_pass   http://app;
+            proxy_pass   http://api:12081/;
         }
-
     }
 }
diff --git a/docs/changelog.rst b/docs/changelog.rst
index c4092dc8b549d8157b77500da46731d7b2cbb103..6e609aac973e5aabf6501a879bc7d98502450eb4 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,6 +1,24 @@
 Changelog
 =========
 
+0.2
+-------
+
+2017-07-09
+
+* [feature] can now import artist and releases from youtube and musicbrainz.
+  This requires a YouTube API key for the search
+* [breaking] we now check for user permission before serving audio files, which requires
+a specific configuration block in your reverse proxy configuration:
+
+.. code-block::
+
+    location /_protected/media {
+        internal;
+        alias   /srv/funkwhale/data/media;
+    }
+
+
 
 0.1
 -------
diff --git a/docs/installation/docker.rst b/docs/installation/docker.rst
index 9f7a288f3783a804a69f7ff25cae790b3b6f34a5..76958fb0bfeb04be5469fde0d73130ece09e9cee 100644
--- a/docs/installation/docker.rst
+++ b/docs/installation/docker.rst
@@ -46,7 +46,7 @@ Then launch the whole thing:
 
     docker-compose up -d
 
-Now, you just need to setup the :ref:`frontend files <frontend-setup>`, and configure your :ref:`reverse-proxy <reverse-proxy-setup>`. Don't worry, it's quite easy.
+Now, you just need to configure your :ref:`reverse-proxy <reverse-proxy-setup>`. Don't worry, it's quite easy.
 
 About music acquisition
 -----------------------
diff --git a/docs/installation/index.rst b/docs/installation/index.rst
index 33ac3bb755098da824ea6876431a43f95d7c85e4..1544dfbf0986a017c54e21e1de7031cf5390f6d3 100644
--- a/docs/installation/index.rst
+++ b/docs/installation/index.rst
@@ -26,6 +26,11 @@ Available installation methods
 Frontend setup
 ---------------
 
+.. note::
+
+    You do not need to do this if you are deploying using Docker, as frontend files
+    are already included in the funkwhale docker image.
+
 Files for the web frontend are purely static and can simply be downloaded, unzipped and served from any webserver:
 
 .. code-block:: bash
diff --git a/front/package.json b/front/package.json
index 732fdb4067ef3a515dc24f0d045be2caf4cd5554..7cec50319a142649dedfb00645418b195c9bf339 100644
--- a/front/package.json
+++ b/front/package.json
@@ -19,7 +19,8 @@
     "semantic-ui-css": "^2.2.10",
     "vue": "^2.3.3",
     "vue-resource": "^1.3.4",
-    "vue-router": "^2.3.1"
+    "vue-router": "^2.3.1",
+    "vuedraggable": "^2.14.1"
   },
   "devDependencies": {
     "autoprefixer": "^6.7.2",
diff --git a/front/src/App.vue b/front/src/App.vue
index 2704ad15143795f3a26ca5d7c6c410ab6e7daa49..f81d7d3daae22224f9d32450844a9677302c111e 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -2,6 +2,26 @@
   <div id="app">
     <sidebar></sidebar>
     <router-view></router-view>
+    <div class="ui divider"></div>
+    <div id="footer" class="ui vertical footer segment">
+      <div class="ui container">
+        <div class="ui stackable equal height stackable grid">
+          <div class="three wide column">
+            <h4 class="ui header">Links</h4>
+            <div class="ui link list">
+              <a href="https://funkwhale.audio" class="item" target="_blank">Official website</a>
+              <a href="https://docs.funkwhale.audio" class="item" target="_blank">Documentation</a>
+              <a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">Source code</a>
+              <a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">Issue tracker</a>
+            </div>
+          </div>
+          <div class="ten wide column">
+            <h4 class="ui header">About funkwhale</h4>
+            <p>Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!</p>
+          </div>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
@@ -27,12 +47,16 @@ export default {
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
 }
-.main.pusher {
+.main.pusher, .footer {
   margin-left: 350px !important;
   transform: none !important;
+}
+.main-pusher {
   padding: 1.5rem 0;
 }
-
+#footer {
+  padding: 1.5rem;
+}
 .ui.stripe.segment {
   padding: 4em;
 }
diff --git a/front/src/audio/index.js b/front/src/audio/index.js
index 22cc85ad33c381401c8d78a6f8a0ba72f26152f5..48f61044399795977b90f0f4a8b767f07c235ff6 100644
--- a/front/src/audio/index.js
+++ b/front/src/audio/index.js
@@ -1,12 +1,5 @@
 import logger from '@/logging'
-
-const pad = (val) => {
-  val = Math.floor(val)
-  if (val < 10) {
-    return '0' + val
-  }
-  return val + ''
-}
+import time from '@/utils/time'
 
 const Cov = {
   on (el, type, func) {
@@ -108,7 +101,7 @@ class Audio {
     })
     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 = this.timeParse(this.state.duration)
+    this.state.durationTimerFormat = time.parse(this.state.duration)
   }
 
   updatePlayState (e) {
@@ -116,9 +109,9 @@ class Audio {
     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 = this.timeParse(this.state.duration)
-    this.state.currentTimeFormat = this.timeParse(this.state.currentTime)
-    this.state.lastTimeFormat = this.timeParse(this.state.duration - this.state.currentTime)
+    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)
@@ -181,14 +174,6 @@ class Audio {
     }
     this.$Audio.currentTime = time
   }
-
-  timeParse (sec) {
-    let min = 0
-    min = Math.floor(sec / 60)
-    sec = sec - min * 60
-    return pad(min) + ':' + pad(sec)
-  }
-
 }
 
 export default Audio
diff --git a/front/src/audio/queue.js b/front/src/audio/queue.js
index ba0af486f53b30f95872fb115b303a455c8ef6cf..efa3dcdf7d6ba5ee22694b1e5d0ac160924d8c7c 100644
--- a/front/src/audio/queue.js
+++ b/front/src/audio/queue.js
@@ -5,6 +5,8 @@ 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 = {}) {
@@ -92,6 +94,24 @@ class Queue {
     }
     cache.set('volume', newValue)
   }
+
+  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) {
     this.previousQueue = null
     index = index || this.tracks.length
@@ -163,7 +183,17 @@ class Queue {
     if (!file) {
       return this.next()
     }
-    this.audio = new Audio(backend.absoluteUrl(file.path), {
+    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())
+    }
+
+    this.audio = new Audio(path, {
       preload: true,
       autoplay: true,
       rate: 1,
diff --git a/front/src/auth/index.js b/front/src/auth/index.js
index b5a3fb5adb1f4695e1c420f51880755cc2519297..80236942858440d517d2fe80b7bb43c71b3ea7c0 100644
--- a/front/src/auth/index.js
+++ b/front/src/auth/index.js
@@ -10,14 +10,13 @@ const LOGIN_URL = config.API_URL + 'token/'
 const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
 // const SIGNUP_URL = API_URL + 'users/'
 
-export default {
-
-  // User object will let us check authentication status
-  user: {
-    authenticated: false,
-    username: '',
-    profile: null
-  },
+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) {
@@ -50,7 +49,7 @@ export default {
 
   checkAuth () {
     logger.default.info('Checking authentication...')
-    var jwt = cache.get('token')
+    var jwt = this.getAuthToken()
     var username = cache.get('username')
     if (jwt) {
       this.user.authenticated = true
@@ -63,9 +62,13 @@ export default {
     }
   },
 
+  getAuthToken () {
+    return cache.get('token')
+  },
+
   // The object to be passed as a header for authenticated requests
   getAuthHeader () {
-    return 'JWT ' + cache.get('token')
+    return 'JWT ' + this.getAuthToken()
   },
 
   fetchProfile () {
@@ -83,7 +86,14 @@ export default {
     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/components/Home.vue b/front/src/components/Home.vue
index 891e99ae0ce24cf792aee65107053181bdadeade..dd324943feccf17c8e0a1e9a570c836b984cde7a 100644
--- a/front/src/components/Home.vue
+++ b/front/src/components/Home.vue
@@ -6,7 +6,7 @@
             Welcome on funkwhale
         </h1>
         <p>We think listening music should be simple.</p>
-        <router-link class="ui icon teal button" to="/browse">
+        <router-link class="ui icon teal button" to="/library">
           Get me to the library
           <i class="right arrow icon"></i>
         </router-link>
@@ -60,7 +60,7 @@
         <h2 class="ui header">
           Clean library
         </h2>
-        <p>Funkwhale takes care of fealing your music.</p>
+        <p>Funkwhale takes care of handling your music.</p>
         <div class="ui list">
           <div class="item">
             <i class="download icon"></i>
@@ -90,9 +90,9 @@
         <p>Funkwhale is dead simple to use.</p>
         <div class="ui list">
           <div class="item">
-            <i class="browser icon"></i>
+            <i class="libraryr icon"></i>
             <div class="content">
-              No add-ons, no plugins : you only need a web browser
+              No add-ons, no plugins : you only need a web libraryr
             </div>
           </div>
           <div class="item">
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index c98dc2f0132c8f7d60b1e4d72e97dd190ca338a0..e39dd16b9cc3eee8c0528a313011e317304aa0b0 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -13,7 +13,7 @@
 
   <div class="menu-area">
     <div class="ui compact fluid two item inverted menu">
-      <a class="active item" data-tab="browse">Browse</a>
+      <a class="active item" data-tab="library">Browse</a>
       <a class="item" data-tab="queue">
         Queue &nbsp;
          <template v-if="queue.tracks.length === 0">
@@ -26,12 +26,12 @@
     </div>
   </div>
   <div class="tabs">
-    <div class="ui bottom attached active tab" data-tab="browse">
+    <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-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
-        <router-link class="item" :to="{path: '/browse'}"><i class="sound icon"> </i>Browse library</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>
       </div>
     </div>
@@ -50,27 +50,27 @@
     </div>
     <div class="ui bottom attached tab" data-tab="queue">
       <table class="ui compact inverted very basic fixed single line table">
-        <tbody>
-          <tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :class="[{'active': index === queue.currentIndex}]">
-            <td class="right aligned">{{ index + 1}}</td>
-            <td class="center aligned">
-              <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
-              <img class="ui mini image" v-else src="../assets/audio/default-cover.png">
-            </td>
-            <td colspan="4">
-              <strong>{{ track.title }}</strong><br />
-              {{ track.artist.name }}
-            </td>
-            <td>
-              <template v-if="favoriteTracks.objects[track.id]">
-                <i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i>
-              </template
-            </td>
-            <td>
-              <i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
-            </td>
-          </tr>
-        </tbody>
+        <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}]">
+              <td class="right aligned">{{ index + 1}}</td>
+              <td class="center aligned">
+                  <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
+                  <img class="ui mini image" v-else src="../assets/audio/default-cover.png">
+              </td>
+              <td colspan="4">
+                  <strong>{{ track.title }}</strong><br />
+                  {{ track.artist.name }}
+              </td>
+              <td>
+                <template v-if="favoriteTracks.objects[track.id]">
+                  <i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i>
+                  </template
+              </td>
+              <td>
+                  <i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
+              </td>
+            </tr>
+          </draggable>
       </table>
       <div v-if="radios.running" class="ui black message">
 
@@ -98,6 +98,7 @@ 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'
@@ -107,7 +108,8 @@ export default {
   components: {
     Player,
     SearchBar,
-    Logo
+    Logo,
+    draggable
   },
   data () {
     return {
@@ -120,6 +122,11 @@ export default {
   },
   mounted () {
     $(this.$el).find('.menu .item').tab()
+  },
+  methods: {
+    reorder (e) {
+      this.queue.reorder(e.oldIndex, e.newIndex)
+    }
   }
 }
 </script>
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 2f41bbbf081cb2b8e6abd899bbc0cd5272287db6..466ead0e8369a04716d6d6ba6380be09e899e6fd 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -7,14 +7,14 @@
           <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: 'browse.track', params: {id: queue.currentTrack.id }}">
+          <router-link class="small header discrete link track" :to="{name: 'library.track', params: {id: queue.currentTrack.id }}">
             {{ queue.currentTrack.title }}
           </router-link>
           <div class="meta">
-            <router-link class="artist" :to="{name: 'browse.artist', params: {id: queue.currentTrack.artist.id }}">
+            <router-link class="artist" :to="{name: 'library.artist', params: {id: queue.currentTrack.artist.id }}">
               {{ queue.currentTrack.artist.name }}
             </router-link> /
-            <router-link class="album" :to="{name: 'browse.album', params: {id: queue.currentTrack.album.id }}">
+            <router-link class="album" :to="{name: 'library.album', params: {id: queue.currentTrack.album.id }}">
               {{ queue.currentTrack.album.title }}
             </router-link>
           </div>
diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue
index 64bf3202f21338d9f20363064287a21ef877f162..6f4629296d4eecfc16b1ba802669bafd01135378 100644
--- a/front/src/components/audio/SearchBar.vue
+++ b/front/src/components/audio/SearchBar.vue
@@ -35,7 +35,7 @@ export default {
           let categories = [
             {
               code: 'artists',
-              route: 'browse.artist',
+              route: 'library.artist',
               name: 'Artist',
               getTitle (r) {
                 return r.name
@@ -46,7 +46,7 @@ export default {
             },
             {
               code: 'albums',
-              route: 'browse.album',
+              route: 'library.album',
               name: 'Album',
               getTitle (r) {
                 return r.title
@@ -57,7 +57,7 @@ export default {
             },
             {
               code: 'tracks',
-              route: 'browse.track',
+              route: 'library.track',
               name: 'Track',
               getTitle (r) {
                 return r.title
diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue
index 79261ef7e87a45f2c614ff6661ecc113e0858e51..fcdf1622d039fe2ae0cf0f5420dd70efaf1a638e 100644
--- a/front/src/components/audio/album/Card.vue
+++ b/front/src/components/audio/album/Card.vue
@@ -6,10 +6,10 @@
           <img v-else src="../../../assets/audio/default-cover.png">
         </div>
         <div class="header">
-          <router-link class="discrete link" :to="{name: 'browse.album', params: {id: album.id }}">{{ album.title }}</router-link>
+          <router-link class="discrete link" :to="{name: 'library.album', params: {id: album.id }}">{{ album.title }}</router-link>
         </div>
         <div class="meta">
-          By <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
+          By <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}">
             {{ album.artist.name }}
           </router-link>
         </div>
@@ -21,7 +21,7 @@
                   <play-button class="basic icon" :track="track" :discrete="true"></play-button>
                 </td>
                 <td colspan="6">
-                  <router-link class="track discrete link" :to="{name: 'browse.track', params: {id: track.id }}">
+                  <router-link class="track discrete link" :to="{name: 'library.track', params: {id: track.id }}">
                     {{ track.title }}
                   </router-link>
                 </td>
diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue
index 4cdd2969fd21b120c43c80d03ca8ba26cce18926..a9701c07ebadda7e7003e01c05fd09a9e19c9743 100644
--- a/front/src/components/audio/artist/Card.vue
+++ b/front/src/components/audio/artist/Card.vue
@@ -2,7 +2,7 @@
   <div class="ui card">
     <div class="content">
         <div class="header">
-          <router-link class="discrete link" :to="{name: 'browse.artist', params: {id: artist.id }}">
+          <router-link class="discrete link" :to="{name: 'library.artist', params: {id: artist.id }}">
             {{ artist.name }}
           </router-link>
         </div>
@@ -15,7 +15,7 @@
                   <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
                 </td>
                 <td colspan="4">
-                  <router-link class="discrete link":to="{name: 'browse.album', params: {id: album.id }}">
+                  <router-link class="discrete link":to="{name: 'library.album', params: {id: album.id }}">
                     <strong>{{ album.title }}</strong>
                   </router-link><br />
                   {{ album.tracks.length }} tracks
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index aa3d324ed839370e2ecf65cd6933379a6304113b..6898353d89810ee4952b161c5701c700733df13d 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -20,17 +20,17 @@
           <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
         </td>
         <td colspan="6">
-            <router-link class="track" :to="{name: 'browse.track', params: {id: track.id }}">
+            <router-link class="track" :to="{name: 'library.track', params: {id: track.id }}">
               {{ track.title }}
             </router-link>
         </td>
         <td colspan="6">
-          <router-link class="artist discrete link" :to="{name: 'browse.artist', params: {id: track.artist.id }}">
+          <router-link class="artist discrete link" :to="{name: 'library.artist', params: {id: track.artist.id }}">
             {{ track.artist.name }}
           </router-link>
         </td>
         <td colspan="6">
-          <router-link class="album discrete link" :to="{name: 'browse.album', params: {id: track.album.id }}">
+          <router-link class="album discrete link" :to="{name: 'library.album', params: {id: track.album.id }}">
             {{ track.album.title }}
           </router-link>
         </td>
diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue
index af4936b3dfd847ac56d31a0eb8bc9b17be1a1f47..867738759f3ef20b6f9d210b827aca876cb27172 100644
--- a/front/src/components/auth/Login.vue
+++ b/front/src/components/auth/Login.vue
@@ -69,7 +69,7 @@ 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: '/browse'}, function (response) {
+      auth.login(this, credentials, {path: '/library'}, function (response) {
         // error callback
         if (response.status === 400) {
           self.error = 'invalid_credentials'
diff --git a/front/src/components/browse/Album.vue b/front/src/components/library/Album.vue
similarity index 96%
rename from front/src/components/browse/Album.vue
rename to front/src/components/library/Album.vue
index 35b1f3a3c7a4bacd9b13d7a37b0908eaca193220..5cc4d027159625f7807b01b73840886ee2285b4e 100644
--- a/front/src/components/browse/Album.vue
+++ b/front/src/components/library/Album.vue
@@ -12,7 +12,7 @@
               {{ album.title }}
               <div class="sub header">
                 Album containing {{ album.tracks.length }} tracks,
-                by <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
+                by <router-link :to="{name: 'library.artist', params: {id: album.artist.id }}">
                   {{ album.artist.name }}
                 </router-link>
               </div>
diff --git a/front/src/components/browse/Artist.vue b/front/src/components/library/Artist.vue
similarity index 100%
rename from front/src/components/browse/Artist.vue
rename to front/src/components/library/Artist.vue
diff --git a/front/src/components/browse/Home.vue b/front/src/components/library/Home.vue
similarity index 99%
rename from front/src/components/browse/Home.vue
rename to front/src/components/library/Home.vue
index 3ce8616a3b60a55d24a1451aea85225e85c315e0..651f7cb63bb90a0c66b1b9b97bf983d469c40f58 100644
--- a/front/src/components/browse/Home.vue
+++ b/front/src/components/library/Home.vue
@@ -34,7 +34,7 @@ import RadioCard from '@/components/radios/Card'
 const ARTISTS_URL = config.API_URL + 'artists/'
 
 export default {
-  name: 'browse',
+  name: 'library',
   components: {
     Search,
     ArtistCard,
diff --git a/front/src/components/browse/Browse.vue b/front/src/components/library/Library.vue
similarity index 55%
rename from front/src/components/browse/Browse.vue
rename to front/src/components/library/Library.vue
index d8f542a5e5357abf1f145c1ab00c137a3e1920ff..56b750a4afcfe2870a5d7a7961c259f9da62105a 100644
--- a/front/src/components/browse/Browse.vue
+++ b/front/src/components/library/Library.vue
@@ -1,7 +1,9 @@
 <template>
-  <div class="main browse pusher">
+  <div class="main library pusher">
     <div class="ui secondary pointing menu">
-      <router-link class="ui item" to="/browse">Browse</router-link>
+      <router-link class="ui item" to="/library" exact>Browse</router-link>
+      <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>
     </div>
     <router-view></router-view>
   </div>
@@ -9,18 +11,25 @@
 
 <script>
 
+import auth from '@/auth'
+
 export default {
-  name: 'browse'
+  name: 'library',
+  data: function () {
+    return {
+      auth
+    }
+  }
 }
 </script>
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style lang="scss">
-.browse.pusher > .ui.secondary.menu {
+.library.pusher > .ui.secondary.menu {
   margin: 0 2.5rem;
 }
 
-.browse {
+.library {
   .ui.segment.head {
     background-size: cover;
     background-position: center;
diff --git a/front/src/components/browse/Track.vue b/front/src/components/library/Track.vue
similarity index 90%
rename from front/src/components/browse/Track.vue
rename to front/src/components/library/Track.vue
index 336af285b3aae63d19d1cbaa60e3b50a6e45a2f4..3c627c13c257d6c71d2ac03f80ff9f573c5bff58 100644
--- a/front/src/components/browse/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -12,10 +12,10 @@
               {{ track.title }}
               <div class="sub header">
                 From album
-                <router-link :to="{name: 'browse.album', params: {id: track.album.id }}">
+                <router-link :to="{name: 'library.album', params: {id: track.album.id }}">
                   {{ track.album.title }}
                 </router-link>
-                by <router-link :to="{name: 'browse.artist', params: {id: track.artist.id }}">
+                by <router-link :to="{name: 'library.artist', params: {id: track.artist.id }}">
                   {{ track.artist.name }}
                 </router-link>
               </div>
@@ -61,6 +61,8 @@
 
 <script>
 
+import auth from '@/auth'
+import url from '@/utils/url'
 import logger from '@/logging'
 import backend from '@/audio/backend'
 import PlayButton from '@/components/audio/PlayButton'
@@ -121,7 +123,11 @@ export default {
     },
     downloadUrl () {
       if (this.track.files.length > 0) {
-        return backend.absoluteUrl(this.track.files[0].path)
+        let u = backend.absoluteUrl(this.track.files[0].path)
+        if (auth.user.authenticated) {
+          u = url.updateQueryString(u, 'jwt', auth.getAuthToken())
+        }
+        return u
       }
     },
     lyricsSearchUrl () {
diff --git a/front/src/components/library/import/ArtistImport.vue b/front/src/components/library/import/ArtistImport.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4f049f52ea2f40829c4bcb5ce6d7bc87c53226d9
--- /dev/null
+++ b/front/src/components/library/import/ArtistImport.vue
@@ -0,0 +1,153 @@
+<template>
+  <div>
+    <h3 class="ui dividing block header">
+      <a :href="getMusicbrainzUrl('artist', metadata.id)" target="_blank" title="View on MusicBrainz">{{ metadata.name }}</a>
+    </h3>
+    <form class="ui form" @submit.prevent="">
+      <h6 class="ui header">Filter album types</h6>
+      <div class="inline fields">
+        <div class="field" v-for="t in availableReleaseTypes">
+          <div class="ui checkbox">
+            <input type="checkbox" :value="t" v-model="releaseTypes" />
+            <label>{{ t }}</label>
+          </div>
+        </div>
+      </div>
+    </form>
+    <template
+      v-for="release in releases">
+      <release-import
+        :key="release.id"
+        :metadata="release"
+        :backends="backends"
+        :defaultEnabled="false"
+        :default-backend-id="defaultBackendId"
+        @import-data-changed="recordReleaseData"
+        @enabled="recordReleaseEnabled"
+      ></release-import>
+      <div class="ui divider"></div>
+    </template>
+  </div>
+</template>
+
+<script>
+import Vue from 'vue'
+import logger from '@/logging'
+import config from '@/config'
+
+import ImportMixin from './ImportMixin'
+import ReleaseImport from './ReleaseImport'
+
+export default Vue.extend({
+  mixins: [ImportMixin],
+  components: {
+    ReleaseImport
+  },
+  data () {
+    return {
+      releaseImportData: [],
+      releaseGroupsData: {},
+      releases: [],
+      releaseTypes: ['Album'],
+      availableReleaseTypes: ['Album', 'Live', 'Compilation', 'EP', 'Single', 'Other']
+    }
+  },
+  created () {
+    this.fetchReleaseGroupsData()
+  },
+  methods: {
+    recordReleaseData (release) {
+      let existing = this.releaseImportData.filter(r => {
+        return r.releaseId === release.releaseId
+      })[0]
+      if (existing) {
+        existing.tracks = release.tracks
+      } else {
+        this.releaseImportData.push({
+          releaseId: release.releaseId,
+          enabled: true,
+          tracks: release.tracks
+        })
+      }
+    },
+    recordReleaseEnabled (release, enabled) {
+      let existing = this.releaseImportData.filter(r => {
+        return r.releaseId === release.releaseId
+      })[0]
+      if (existing) {
+        existing.enabled = enabled
+      } else {
+        this.releaseImportData.push({
+          releaseId: release.releaseId,
+          enabled: enabled,
+          tracks: release.tracks
+        })
+      }
+    },
+    fetchReleaseGroupsData () {
+      let self = this
+      this.releaseGroups.forEach(group => {
+        let url = config.API_URL + 'providers/musicbrainz/releases/browse/' + group.id + '/'
+        let resource = Vue.resource(url)
+        resource.get({}).then((response) => {
+          logger.default.info('successfully fetched release group', group.id)
+          let release = response.data['release-list'].filter(r => {
+            return r.status === 'Official'
+          })[0]
+          self.releaseGroupsData[group.id] = release
+          self.releases = self.computeReleaseData()
+        }, (response) => {
+          logger.default.error('error while fetching release group', group.id)
+        })
+      })
+    },
+    computeReleaseData () {
+      let self = this
+      let releases = []
+      this.releaseGroups.forEach(group => {
+        let data = self.releaseGroupsData[group.id]
+        if (data) {
+          releases.push(data)
+        }
+      })
+      return releases
+    }
+  },
+  computed: {
+    type () {
+      return 'artist'
+    },
+    releaseGroups () {
+      let self = this
+      return this.metadata['release-group-list'].filter(r => {
+        return self.releaseTypes.indexOf(r.type) !== -1
+      }).sort(function (a, b) {
+        if (a['first-release-date'] < b['first-release-date']) {
+          return -1
+        }
+        if (a['first-release-date'] > b['first-release-date']) {
+          return 1
+        }
+        return 0
+      })
+    },
+    importData () {
+      let releases = this.releaseImportData.filter(r => {
+        return r.enabled
+      })
+      return {
+        artistId: this.metadata.id,
+        count: releases.reduce(function (a, b) {
+          return a + b.tracks.length
+        }, 0),
+        albums: releases
+      }
+    }
+  },
+  watch: {
+    releaseTypes (newValue) {
+      this.fetchReleaseGroupsData()
+    }
+  }
+})
+</script>
diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..57560fc04e5779e55d20189a5623ab619b6953a8
--- /dev/null
+++ b/front/src/components/library/import/BatchDetail.vue
@@ -0,0 +1,106 @@
+<template>
+  <div>
+    <div v-if="isLoading && !batch" class="ui vertical segment">
+      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+    </div>
+    <div v-if="batch" class="ui vertical stripe segment">
+      <div :class="
+        ['ui',
+        {'active': batch.status === 'pending'},
+        {'warning': batch.status === 'pending'},
+        {'success': batch.status === 'finished'},
+        'progress']">
+        <div class="bar" :style="progressBarStyle">
+          <div class="progress"></div>
+        </div>
+        <div v-if="batch.status === 'pending'" class="label">Importing {{ batch.jobs.length }} tracks...</div>
+        <div v-if="batch.status === 'finished'" class="label">Imported {{ batch.jobs.length }} tracks!</div>
+      </div>
+      <table class="ui table">
+        <thead>
+          <tr>
+            <th>Job ID</th>
+            <th>Recording MusicBrainz ID</th>
+            <th>Source</th>
+            <th>Status</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="job in batch.jobs">
+            <td>{{ job.id }}</th>
+            <td>
+              <a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
+            </td>
+            <td>
+              <a :href="job.source" target="_blank">{{ job.source }}</a>
+            </td>
+            <td>
+              <span
+                :class="['ui', {'yellow': job.status === 'pending'}, {'green': job.status === 'finished'}, 'label']">{{ job.status }}</span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+    </div>
+  </div>
+</template>
+
+<script>
+
+import logger from '@/logging'
+import config from '@/config'
+
+const FETCH_URL = config.API_URL + 'import-batches/'
+
+export default {
+  props: ['id'],
+  data () {
+    return {
+      isLoading: true,
+      batch: null
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      var self = this
+      this.isLoading = true
+      let url = FETCH_URL + this.id + '/'
+      logger.default.debug('Fetching batch "' + this.id + '"')
+      this.$http.get(url).then((response) => {
+        self.batch = response.data
+        self.isLoading = false
+        if (self.batch.status === 'pending') {
+          setTimeout(
+            self.fetchData,
+            5000
+          )
+        }
+      })
+    }
+  },
+  computed: {
+    progress () {
+      return this.batch.jobs.filter(j => {
+        return j.status === 'finished'
+      }).length * 100 / this.batch.jobs.length
+    },
+    progressBarStyle () {
+      return 'width: ' + parseInt(this.progress) + '%'
+    }
+  },
+  watch: {
+    id () {
+      this.fetchData()
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+
+</style>
diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..41b94bd4eca31b81a2bf8b66accfd0def921717f
--- /dev/null
+++ b/front/src/components/library/import/BatchList.vue
@@ -0,0 +1,80 @@
+<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>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+  </div>
+</template>
+
+<script>
+import logger from '@/logging'
+import config from '@/config'
+
+const BATCHES_URL = config.API_URL + 'import-batches/'
+
+export default {
+  components: {},
+  data () {
+    return {
+      results: [],
+      isLoading: false,
+      nextLink: null,
+      previousLink: null
+    }
+  },
+  created () {
+    this.fetchData(BATCHES_URL)
+  },
+  methods: {
+    fetchData (url) {
+      var self = this
+      this.isLoading = true
+      logger.default.time('Loading import batches')
+      this.$http.get(url, {}).then((response) => {
+        self.results = response.data.results
+        self.nextLink = response.data.next
+        self.previousLink = response.data.previous
+        logger.default.timeEnd('Loading import batches')
+        self.isLoading = false
+      })
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f3fc6fca6a163a1f99e0387cbf75300b6bc084a8
--- /dev/null
+++ b/front/src/components/library/import/ImportMixin.vue
@@ -0,0 +1,81 @@
+<template>
+
+</template>
+
+<script>
+import logger from '@/logging'
+import config from '@/config'
+import Vue from 'vue'
+import router from '@/router'
+
+export default {
+  props: {
+    metadata: {type: Object, required: true},
+    defaultEnabled: {type: Boolean, default: true},
+    backends: {type: Array},
+    defaultBackendId: {type: String}
+  },
+  data () {
+    return {
+      currentBackendId: this.defaultBackendId,
+      isImporting: false,
+      enabled: this.defaultEnabled
+    }
+  },
+  methods: {
+    getMusicbrainzUrl (type, id) {
+      return 'https://musicbrainz.org/' + type + '/' + id
+    },
+    launchImport () {
+      let self = this
+      this.isImporting = true
+      let url = config.API_URL + 'submit/' + self.importType + '/'
+      let payload = self.importData
+      let resource = Vue.resource(url)
+      resource.save({}, payload).then((response) => {
+        logger.default.info('launched import for', self.type, self.metadata.id)
+        self.isImporting = false
+        router.push({
+          name: 'library.import.batches.detail',
+          params: {
+            id: response.data.id
+          }
+        })
+      }, (response) => {
+        logger.default.error('error while launching import for', self.type, self.metadata.id)
+        self.isImporting = false
+      })
+    }
+  },
+  computed: {
+    importType () {
+      return this.type
+    },
+    currentBackend () {
+      let self = this
+      return this.backends.filter(b => {
+        return b.id === self.currentBackendId
+      })[0]
+    }
+  },
+  watch: {
+    isImporting (newValue) {
+      this.$emit('import-state-changed', newValue)
+    },
+    importData: {
+      handler (newValue) {
+        this.$emit('import-data-changed', newValue)
+      },
+      deep: true
+    },
+    enabled (newValue) {
+      this.$emit('enabled', this.importData, newValue)
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+
+</style>
diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue
new file mode 100644
index 0000000000000000000000000000000000000000..10f6f352af0717886a0e4dc96b2dabe77d846ab7
--- /dev/null
+++ b/front/src/components/library/import/Main.vue
@@ -0,0 +1,231 @@
+<template>
+  <div>
+    <div class="ui vertical stripe segment">
+      <div class="ui top three attached ordered steps">
+        <a @click="currentStep = 0" :class="['step', {'active': currentStep === 0}, {'completed': currentStep > 0}]">
+          <div class="content">
+            <div class="title">Import source</div>
+            <div class="description">
+              Uploaded files or external source
+            </div>
+          </div>
+        </a>
+        <a @click="currentStep = 1" :class="['step', {'active': currentStep === 1}, {'completed': currentStep > 1}]">
+          <div class="content">
+            <div class="title">Metadata</div>
+            <div class="description">Grab corresponding metadata</div>
+          </div>
+        </a>
+        <a @click="currentStep = 2" :class="['step', {'active': currentStep === 2}, {'completed': currentStep > 2}]">
+          <div class="content">
+            <div class="title">Music</div>
+            <div class="description">Select relevant sources or files for import</div>
+          </div>
+        </a>
+      </div>
+      <div class="ui attached segment">
+        <template v-if="currentStep === 0">
+          <p>First, choose where you want to import the music from :</p>
+          <form class="ui form">
+            <div class="field">
+              <div class="ui radio checkbox">
+                <input type="radio" id="external" value="external" v-model="currentSource">
+                <label for="external">External source. Supported backends:
+                  <div v-for="backend in backends" class="ui basic label">
+                    <i v-if="backend.icon" :class="[backend.icon, 'icon']"></i>
+                    {{ backend.label }}
+                  </div>
+                </label>
+              </div>
+            </div>
+            <div class="field">
+              <div class="ui disabled radio checkbox">
+                <input type="radio" id="upload" value="upload" v-model="currentSource" disabled>
+                <label for="upload">File upload</label>
+              </div>
+            </div>
+          </form>
+        </template>
+        <div v-if="currentStep === 1" class="ui stackable two column grid">
+          <div class="column">
+            <form class="ui form" @submit.prevent="">
+              <div class="field">
+                <label>Search an entity you want to import:</label>
+                <metadata-search
+                  :mb-type="mbType"
+                  :mb-id="mbId"
+                  @id-changed="updateId"
+                  @type-changed="updateType"></metadata-search>
+              </div>
+            </form>
+              <div class="ui horizontal divider">
+               Or
+             </div>
+            <form class="ui form" @submit.prevent="">
+              <div class="field">
+                <label>Input a MusicBrainz ID manually:</label>
+                <input type="text" v-model="currentId" />
+              </div>
+            </form>
+            <div class="ui hidden divider"></div>
+            <template v-if="currentType && currentId">
+              <h4 class="ui header">You will import:</h4>
+              <component
+                :mbId="currentId"
+                :is="metadataComponent"
+                @metadata-changed="this.updateMetadata"
+                ></component>
+            </template>
+            <p>You can also skip this step and enter metadata manually.</p>
+          </div>
+          <div class="column">
+            <h5 class="ui header">What is metadata?</h5>
+            <p>Metadata is the data related to the music you want to import. This includes all the information about the artists, albums and tracks. In order to have a high quality library, it is recommended to grab data from the <a href="http://musicbrainz.org/" target="_blank">MusicBrainz project</a>, which you can think about as the Wikipedia of music.</p>
+          </div>
+        </div>
+        <div v-if="currentStep === 2">
+          <component
+            ref="import"
+            :metadata="metadata"
+            :is="importComponent"
+            :backends="backends"
+            :default-backend-id="backends[0].id"
+            @import-data-changed="updateImportData"
+            @import-state-changed="updateImportState"
+            ></component>
+        </div>
+        <div class="ui hidden divider"></div>
+        <div class="ui buttons">
+          <button @click="currentStep -= 1" :disabled="currentStep === 0" class="ui icon button"><i class="left arrow icon"></i> Previous step</button>
+          <button @click="currentStep += 1" v-if="currentStep < 2" class="ui icon button">Next step <i class="right arrow icon"></i></button>
+          <button
+            @click="$refs.import.launchImport()"
+            v-if="currentStep === 2"
+            :class="['ui', 'positive', 'icon', {'loading': isImporting}, 'button']"
+            :disabled="isImporting || importData.count === 0"
+            >Import {{ importData.count }} tracks <i class="check icon"></i></button>
+        </div>
+      </div>
+    </div>
+    <div class="ui vertical stripe segment">
+
+    </div>
+  </div>
+</template>
+
+<script>
+
+import MetadataSearch from '@/components/metadata/Search'
+import ReleaseCard from '@/components/metadata/ReleaseCard'
+import ArtistCard from '@/components/metadata/ArtistCard'
+import ReleaseImport from './ReleaseImport'
+import ArtistImport from './ArtistImport'
+
+import router from '@/router'
+import $ from 'jquery'
+
+export default {
+  components: {
+    MetadataSearch,
+    ArtistCard,
+    ReleaseCard,
+    ArtistImport,
+    ReleaseImport
+  },
+  props: {
+    mbType: {type: String, required: false},
+    source: {type: String, required: false},
+    mbId: {type: String, required: false}
+  },
+  data: function () {
+    return {
+      currentType: this.mbType || 'artist',
+      currentId: this.mbId,
+      currentStep: 0,
+      currentSource: this.source || 'external',
+      metadata: {},
+      isImporting: false,
+      importData: {
+        tracks: []
+      },
+      backends: [
+        {
+          id: 'youtube',
+          label: 'YouTube',
+          icon: 'youtube'
+        }
+      ]
+    }
+  },
+  created () {
+    if (this.currentSource) {
+      this.currentStep = 1
+    }
+  },
+  mounted: function () {
+    $(this.$el).find('.ui.checkbox').checkbox()
+  },
+  methods: {
+    updateRoute () {
+      router.replace({
+        query: {
+          source: this.currentSource,
+          type: this.currentType,
+          id: this.currentId
+        }
+      })
+    },
+    updateImportData (newValue) {
+      this.importData = newValue
+    },
+    updateImportState (newValue) {
+      this.isImporting = newValue
+    },
+    updateMetadata (newValue) {
+      this.metadata = newValue
+    },
+    updateType (newValue) {
+      this.currentType = newValue
+    },
+    updateId (newValue) {
+      this.currentId = newValue
+    }
+  },
+  computed: {
+    metadataComponent () {
+      if (this.currentType === 'artist') {
+        return 'ArtistCard'
+      }
+      if (this.currentType === 'release') {
+        return 'ReleaseCard'
+      }
+      if (this.currentType === 'recording') {
+        return 'RecordingCard'
+      }
+    },
+    importComponent () {
+      if (this.currentType === 'artist') {
+        return 'ArtistImport'
+      }
+      if (this.currentType === 'release') {
+        return 'ReleaseImport'
+      }
+      if (this.currentType === 'recording') {
+        return 'RecordingImport'
+      }
+    }
+  },
+  watch: {
+    currentType (newValue) {
+      this.currentId = ''
+      this.updateRoute()
+    },
+    currentId (newValue) {
+      this.updateRoute()
+    }
+  }
+}
+</script>
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/library/import/ReleaseImport.vue b/front/src/components/library/import/ReleaseImport.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9f8c1d347a326b96470423679bdedfa50fe08b0c
--- /dev/null
+++ b/front/src/components/library/import/ReleaseImport.vue
@@ -0,0 +1,113 @@
+<template>
+  <div>
+    <h3 class="ui dividing block header">
+      Album <a :href="getMusicbrainzUrl('release', metadata.id)" target="_blank" title="View on MusicBrainz">{{ metadata.title }}</a> ({{ tracks.length}} tracks) by
+      <a :href="getMusicbrainzUrl('artist', metadata['artist-credit'][0]['artist']['id'])" target="_blank" title="View on MusicBrainz">{{ metadata['artist-credit-phrase'] }}</a>
+      <div class="ui divider"></div>
+      <div class="sub header">
+        <div class="ui toggle checkbox">
+          <input type="checkbox" v-model="enabled" />
+          <label>Import this release</label>
+        </div>
+      </div>
+    </h3>
+    <template
+      v-if="enabled"
+      v-for="track in tracks">
+      <track-import
+        :key="track.recording.id"
+        :metadata="track"
+        :release-metadata="metadata"
+        :backends="backends"
+        :default-backend-id="defaultBackendId"
+        @import-data-changed="recordTrackData"
+        @enabled="recordTrackEnabled"
+      ></track-import>
+      <div class="ui divider"></div>
+    </template>
+  </div>
+</template>
+
+<script>
+import Vue from 'vue'
+import ImportMixin from './ImportMixin'
+import TrackImport from './TrackImport'
+
+export default Vue.extend({
+  mixins: [ImportMixin],
+  components: {
+    TrackImport
+  },
+  data () {
+    return {
+      trackImportData: []
+    }
+  },
+  methods: {
+    recordTrackData (track) {
+      let existing = this.trackImportData.filter(t => {
+        return t.mbid === track.mbid
+      })[0]
+      if (existing) {
+        existing.source = track.source
+      } else {
+        this.trackImportData.push({
+          mbid: track.mbid,
+          enabled: true,
+          source: track.source
+        })
+      }
+    },
+    recordTrackEnabled (track, enabled) {
+      let existing = this.trackImportData.filter(t => {
+        return t.mbid === track.mbid
+      })[0]
+      if (existing) {
+        existing.enabled = enabled
+      } else {
+        this.trackImportData.push({
+          mbid: track.mbid,
+          enabled: enabled,
+          source: null
+        })
+      }
+    }
+  },
+  computed: {
+    type () {
+      return 'release'
+    },
+    importType () {
+      return 'album'
+    },
+    tracks () {
+      return this.metadata['medium-list'][0]['track-list']
+    },
+    importData () {
+      let tracks = this.trackImportData.filter(t => {
+        return t.enabled
+      })
+      return {
+        releaseId: this.metadata.id,
+        count: tracks.length,
+        tracks: tracks
+      }
+    }
+  },
+  watch: {
+    importData: {
+      handler (newValue) {
+        this.$emit('import-data-changed', newValue)
+      },
+      deep: true
+    }
+  }
+})
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+.ui.card {
+    width: 100% !important;
+}
+</style>
diff --git a/front/src/components/library/import/TrackImport.vue b/front/src/components/library/import/TrackImport.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3081091c59d6f7da5d88b06cf0fb933650f70e7c
--- /dev/null
+++ b/front/src/components/library/import/TrackImport.vue
@@ -0,0 +1,188 @@
+<template>
+  <div class="ui stackable grid">
+    <div class="three wide column">
+      <h5 class="ui header">
+        {{ metadata.position }}. {{ metadata.recording.title }}
+        <div class="sub header">
+          {{ time.parse(parseInt(metadata.length) / 1000) }}
+        </div>
+      </h5>
+      <div class="ui toggle checkbox">
+        <input type="checkbox" v-model="enabled" />
+        <label>Import this track</label>
+      </div>
+    </div>
+    <div class="three wide column" v-if="enabled">
+      <form class="ui mini form" @submit.prevent="">
+        <div class="field">
+          <label>Source</label>
+          <select v-model="currentBackendId">
+            <option v-for="backend in backends" :value="backend.id">
+              {{ backend.label }}
+            </option>
+          </select>
+        </div>
+      </form>
+      <div class="ui hidden divider"></div>
+      <template v-if="currentResult">
+        <button @click="currentResultIndex -= 1" class="ui basic tiny icon button" :disabled="currentResultIndex === 0">
+          <i class="left arrow icon"></i>
+        </button>
+        Result {{ currentResultIndex + 1 }}/{{ results.length }}
+        <button @click="currentResultIndex += 1" class="ui basic tiny icon button" :disabled="currentResultIndex + 1 === results.length">
+          <i class="right arrow icon"></i>
+        </button>
+      </template>
+    </div>
+    <div class="four wide column" v-if="enabled">
+      <form class="ui mini form" @submit.prevent="">
+        <div class="field">
+          <label>Search query</label>
+          <input type="text" v-model="query" />
+          <label>Imported URL</label>
+          <input type="text" v-model="importedUrl" />
+        </div>
+      </form>
+    </div>
+    <div class="six wide column" v-if="enabled">
+      <div v-if="isLoading" class="ui vertical segment">
+        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+      </div>
+      <div v-if="!isLoading && currentResult" class="ui items">
+        <div class="item">
+          <div class="ui small image">
+            <img :src="currentResult.cover" />
+          </div>
+          <div class="content">
+            <a
+              :href="currentResult.url"
+              target="_blank"
+              class="description"
+              v-html="$options.filters.highlight(currentResult.title, warnings)"></a>
+            <div v-if="currentResult.channelTitle" class="meta">
+              {{ currentResult.channelTitle}}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import Vue from 'vue'
+import time from '@/utils/time'
+import config from '@/config'
+import logger from '@/logging'
+import ImportMixin from './ImportMixin'
+
+import $ from 'jquery'
+
+Vue.filter('highlight', function (words, query) {
+  query.forEach(w => {
+    let re = new RegExp('(' + w + ')', 'gi')
+    words = words.replace(re, '<span class=\'highlight\'>$1</span>')
+  })
+  return words
+})
+
+export default Vue.extend({
+  mixins: [ImportMixin],
+  props: {
+    releaseMetadata: {type: Object, required: true}
+  },
+  data () {
+    let queryParts = [
+      this.releaseMetadata['artist-credit'][0]['artist']['name'],
+      this.releaseMetadata['title'],
+      this.metadata['recording']['title']
+    ]
+    return {
+      query: queryParts.join(' '),
+      isLoading: false,
+      results: [],
+      currentResultIndex: 0,
+      importedUrl: '',
+      warnings: [
+        'live',
+        'full',
+        'cover'
+      ],
+      time
+    }
+  },
+  created () {
+    if (this.enabled) {
+      this.search()
+    }
+  },
+  mounted () {
+    $('.ui.checkbox').checkbox()
+  },
+  methods: {
+    search () {
+      let self = this
+      this.isLoading = true
+      let url = config.API_URL + 'providers/' + this.currentBackendId + '/search/'
+      let resource = Vue.resource(url)
+
+      resource.get({query: this.query}).then((response) => {
+        logger.default.debug('searching', self.query, 'on', self.currentBackendId)
+        self.results = response.data
+        self.isLoading = false
+      }, (response) => {
+        logger.default.error('error while searching', self.query, 'on', self.currentBackendId)
+        self.isLoading = false
+      })
+    }
+  },
+  computed: {
+    type () {
+      return 'track'
+    },
+    currentResult () {
+      if (this.results) {
+        return this.results[this.currentResultIndex]
+      }
+    },
+    importData () {
+      return {
+        count: 1,
+        mbid: this.metadata.recording.id,
+        source: this.importedUrl
+      }
+    }
+  },
+  watch: {
+    query () {
+      this.search()
+    },
+    currentResult (newValue) {
+      if (newValue) {
+        this.importedUrl = newValue.url
+      }
+    },
+    importedUrl (newValue) {
+      this.$emit('url-changed', this.importData, this.importedUrl)
+    },
+    enabled (newValue) {
+      if (newValue && this.results.length === 0) {
+        this.search()
+      }
+    }
+  }
+})
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+.ui.card {
+    width: 100% !important;
+}
+</style>
+<style lang="scss">
+.highlight {
+  font-weight: bold !important;
+  background-color: yellow !important;
+}
+</style>
diff --git a/front/src/components/metadata/ArtistCard.vue b/front/src/components/metadata/ArtistCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3a50a315522aed317437e203080f4a7061e4fc3c
--- /dev/null
+++ b/front/src/components/metadata/ArtistCard.vue
@@ -0,0 +1,64 @@
+<template>
+  <div class="ui card">
+    <div class="content">
+      <div v-if="isLoading" class="ui vertical segment">
+        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+      </div>
+      <template v-if="data.id">
+        <div class="header">
+          <a :href="getMusicbrainzUrl('artist', data.id)" target="_blank" title="View on MusicBrainz">{{ data.name }}</a>
+        </div>
+        <div class="description">
+          <table class="ui very basic fixed single line compact table">
+            <tbody>
+              <tr v-for="group in releasesGroups">
+                <td>
+                  {{ group['first-release-date'] }}
+                </td>
+                <td colspan="3">
+                  <a :href="getMusicbrainzUrl('release-group', group.id)" class="discrete link" target="_blank" title="View on MusicBrainz">
+                    {{ group.title }}
+                  </a>
+                </td>
+                  <td>
+                  </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script>
+import Vue from 'vue'
+import CardMixin from './CardMixin'
+import time from '@/utils/time'
+
+export default Vue.extend({
+  mixins: [CardMixin],
+  data () {
+    return {
+      time
+    }
+  },
+  computed: {
+    type () {
+      return 'artist'
+    },
+    releasesGroups () {
+      return this.data['release-group-list'].filter(r => {
+        return r.type === 'Album'
+      })
+    }
+  }
+})
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+.ui.card {
+    width: 100% !important;
+}
+</style>
diff --git a/front/src/components/metadata/CardMixin.vue b/front/src/components/metadata/CardMixin.vue
new file mode 100644
index 0000000000000000000000000000000000000000..78aae5e7e06da465de2037f1e427e646eee88317
--- /dev/null
+++ b/front/src/components/metadata/CardMixin.vue
@@ -0,0 +1,50 @@
+<template>
+
+</template>
+
+<script>
+import logger from '@/logging'
+
+import config from '@/config'
+import Vue from 'vue'
+
+export default {
+  props: {
+    mbId: {type: String, required: true}
+  },
+  created: function () {
+    this.fetchData()
+  },
+  data: function () {
+    return {
+      isLoading: false,
+      data: {}
+    }
+  },
+  methods: {
+    fetchData () {
+      let self = this
+      this.isLoading = true
+      let url = config.API_URL + 'providers/musicbrainz/' + this.type + 's/' + this.mbId + '/'
+      let resource = Vue.resource(url)
+      resource.get({}).then((response) => {
+        logger.default.info('successfully fetched', self.type, self.mbId)
+        self.data = response.data[self.type]
+        this.$emit('metadata-changed', self.data)
+        self.isLoading = false
+      }, (response) => {
+        logger.default.error('error while fetching', self.type, self.mbId)
+        self.isLoading = false
+      })
+    },
+    getMusicbrainzUrl (type, id) {
+      return 'https://musicbrainz.org/' + type + '/' + id
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+
+</style>
diff --git a/front/src/components/metadata/ReleaseCard.vue b/front/src/components/metadata/ReleaseCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..201c3ab0c527440acb56f4320ff2e505a6f538c3
--- /dev/null
+++ b/front/src/components/metadata/ReleaseCard.vue
@@ -0,0 +1,66 @@
+<template>
+  <div class="ui card">
+    <div class="content">
+      <div v-if="isLoading" class="ui vertical segment">
+        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+      </div>
+      <template v-if="data.id">
+        <div class="header">
+          <a :href="getMusicbrainzUrl('release', data.id)" target="_blank" title="View on MusicBrainz">{{ data.title }}</a>
+        </div>
+        <div class="meta">
+          <a :href="getMusicbrainzUrl('artist', data['artist-credit'][0]['artist']['id'])" target="_blank" title="View on MusicBrainz">{{ data['artist-credit-phrase'] }}</a>
+        </div>
+        <div class="description">
+          <table class="ui very basic fixed single line compact table">
+            <tbody>
+              <tr v-for="track in tracks">
+                <td>
+                  {{ track.position }}
+                </td>
+                <td colspan="3">
+                  <a :href="getMusicbrainzUrl('recording', track.id)" class="discrete link" target="_blank" title="View on MusicBrainz">
+                    {{ track.recording.title }}
+                  </a>
+                </td>
+                  <td>
+                    {{ time.parse(parseInt(track.length) / 1000) }}
+                  </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script>
+import Vue from 'vue'
+import CardMixin from './CardMixin'
+import time from '@/utils/time'
+
+export default Vue.extend({
+  mixins: [CardMixin],
+  data () {
+    return {
+      time
+    }
+  },
+  computed: {
+    type () {
+      return 'release'
+    },
+    tracks () {
+      return this.data['medium-list'][0]['track-list']
+    }
+  }
+})
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+.ui.card {
+    width: 100% !important;
+}
+</style>
diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8a400cf7b0e5b488e794afb62a0ddab8d38ce59a
--- /dev/null
+++ b/front/src/components/metadata/Search.vue
@@ -0,0 +1,153 @@
+<template>
+  <div>
+    <div class="ui form">
+      <div class="inline fields">
+        <div v-for="type in types"  class="field">
+          <div class="ui radio checkbox">
+            <input type="radio" :value="type.value" v-model="currentType">
+            <label >{{ type.label }}</label>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="ui fluid search">
+      <div class="ui icon input">
+        <input class="prompt" placeholder="Enter your search query..." type="text">
+        <i class="search icon"></i>
+      </div>
+      <div class="results"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+import jQuery from 'jquery'
+import config from '@/config'
+import auth from '@/auth'
+
+export default {
+  props: {
+    mbType: {type: String, required: false},
+    mbId: {type: String, required: false}
+  },
+  data: function () {
+    return {
+      currentType: this.mbType || 'artist',
+      currentId: this.mbId || '',
+      types: [
+        {
+          value: 'artist',
+          label: 'Artist'
+        },
+        {
+          value: 'release',
+          label: 'Album'
+        },
+        {
+          value: 'recording',
+          label: 'Track'
+        }
+      ]
+    }
+  },
+
+  mounted: function () {
+    jQuery(this.$el).find('.ui.checkbox').checkbox()
+    this.setUpSearch()
+  },
+  methods: {
+
+    setUpSearch () {
+      var self = this
+      jQuery(this.$el).search({
+        minCharacters: 3,
+        onSelect (result, response) {
+          self.currentId = result.id
+        },
+        apiSettings: {
+          beforeXHR: function (xhrObject, s) {
+            xhrObject.setRequestHeader('Authorization', auth.getAuthHeader())
+            return xhrObject
+          },
+          onResponse: function (initialResponse) {
+            let category = self.currentTypeObject.value
+            let results = initialResponse[category + '-list'].map(r => {
+              let description = []
+              if (category === 'artist') {
+                if (r.type) {
+                  description.push(r.type)
+                }
+                if (r.area) {
+                  description.push(r.area.name)
+                } else if (r['begin-area']) {
+                  description.push(r['begin-area'].name)
+                }
+                return {
+                  title: r.name,
+                  id: r.id,
+                  description: description.join(' - ')
+                }
+              }
+              if (category === 'release') {
+                if (r['medium-track-count']) {
+                  description.push(
+                    r['medium-track-count'] + ' tracks'
+                  )
+                }
+                if (r['artist-credit-phrase']) {
+                  description.push(r['artist-credit-phrase'])
+                }
+                if (r['date']) {
+                  description.push(r['date'])
+                }
+                return {
+                  title: r.title,
+                  id: r.id,
+                  description: description.join(' - ')
+                }
+              }
+              if (category === 'recording') {
+                if (r['artist-credit-phrase']) {
+                  description.push(r['artist-credit-phrase'])
+                }
+                return {
+                  title: r.title,
+                  id: r.id,
+                  description: description.join(' - ')
+                }
+              }
+            })
+            return {results: results}
+          },
+          url: this.searchUrl
+        }
+      })
+    }
+  },
+  computed: {
+    currentTypeObject: function () {
+      let self = this
+      return this.types.filter(t => {
+        return t.value === self.currentType
+      })[0]
+    },
+    searchUrl: function () {
+      return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}'
+    }
+  },
+  watch: {
+    currentType (newValue) {
+      this.setUpSearch()
+      this.$emit('type-changed', newValue)
+    },
+    currentId (newValue) {
+      this.$emit('id-changed', newValue)
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+
+</style>
diff --git a/front/src/router/index.js b/front/src/router/index.js
index bb92b5ae151701e4c5cca54130afc35f1ad807ae..b3d90731f41cf11f614612b1a3cc954625fd975a 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -4,11 +4,15 @@ import Home from '@/components/Home'
 import Login from '@/components/auth/Login'
 import Profile from '@/components/auth/Profile'
 import Logout from '@/components/auth/Logout'
-import Browse from '@/components/browse/Browse'
-import BrowseHome from '@/components/browse/Home'
-import BrowseArtist from '@/components/browse/Artist'
-import BrowseAlbum from '@/components/browse/Album'
-import BrowseTrack from '@/components/browse/Track'
+import Library from '@/components/library/Library'
+import LibraryHome from '@/components/library/Home'
+import LibraryArtist from '@/components/library/Artist'
+import LibraryAlbum from '@/components/library/Album'
+import LibraryTrack from '@/components/library/Track'
+import LibraryImport from '@/components/library/import/Main'
+import BatchList from '@/components/library/import/BatchList'
+import BatchDetail from '@/components/library/import/BatchDetail'
+
 import Favorites from '@/components/favorites/List'
 
 Vue.use(Router)
@@ -43,13 +47,27 @@ export default new Router({
       component: Favorites
     },
     {
-      path: '/browse',
-      component: Browse,
+      path: '/library',
+      component: Library,
       children: [
-        { path: '', component: BrowseHome },
-        { path: 'artist/:id', name: 'browse.artist', component: BrowseArtist, props: true },
-        { path: 'album/:id', name: 'browse.album', component: BrowseAlbum, props: true },
-        { path: 'track/:id', name: 'browse.track', component: BrowseTrack, props: true }
+        { path: '', component: LibraryHome },
+        { path: 'artist/:id', name: 'library.artist', component: LibraryArtist, props: true },
+        { path: 'album/:id', name: 'library.album', component: LibraryAlbum, props: true },
+        { path: 'track/:id', name: 'library.track', component: LibraryTrack, props: true },
+        {
+          path: 'import/launch',
+          name: 'library.import.launch',
+          component: LibraryImport,
+          props: (route) => ({ mbType: route.query.type, mbId: route.query.id })
+        },
+        {
+          path: 'import/batches',
+          name: 'library.import.batches',
+          component: BatchList,
+          children: [
+          ]
+        },
+        { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
       ]
     }
 
diff --git a/front/src/utils/time.js b/front/src/utils/time.js
new file mode 100644
index 0000000000000000000000000000000000000000..022a365bf2a6421de20ad8e212112bfdae6484d2
--- /dev/null
+++ b/front/src/utils/time.js
@@ -0,0 +1,16 @@
+function pad (val) {
+  val = Math.floor(val)
+  if (val < 10) {
+    return '0' + val
+  }
+  return val + ''
+}
+
+export default {
+  parse: function (sec) {
+    let min = 0
+    min = Math.floor(sec / 60)
+    sec = sec - min * 60
+    return pad(min) + ':' + pad(sec)
+  }
+}
diff --git a/front/src/utils/url.js b/front/src/utils/url.js
new file mode 100644
index 0000000000000000000000000000000000000000..61a430988a4093852812d5aada507cdd14acf38b
--- /dev/null
+++ b/front/src/utils/url.js
@@ -0,0 +1,11 @@
+export default {
+  updateQueryString (uri, key, value) {
+    var re = new RegExp('([?&])' + key + '=.*?(&|$)', 'i')
+    var separator = uri.indexOf('?') !== -1 ? '&' : '?'
+    if (uri.match(re)) {
+      return uri.replace(re, '$1' + key + '=' + value + '$2')
+    } else {
+      return uri + separator + key + '=' + value
+    }
+  }
+}