diff --git a/.gitignore b/.gitignore
index c1b8300f2989fd0c22b266df093efcd1354367df..66ec5a41dbee681dbbbbe0e61802282c0e629e8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,7 +72,7 @@ api/music
 api/media
 api/staticfiles
 api/static
-
+api/.pytest_cache
 
 # Front
 front/node_modules/
diff --git a/CHANGELOG b/CHANGELOG
index 2d005e1a36b0cbca335ee63d9d4ccb70d2381d6e..5572a45ead0c91c605f2a7214a00a0a2f158a50e 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,10 +1,44 @@
 Changelog
 =========
 
+0.6 (Unreleased)
+----------------
+
 
-0.5 (Unreleased)
+0.5 (2018-02-24)
 ----------------
 
+- Front: Now reset player colors when track has no cover (#46)
+- Front: play button now disabled for unplayable tracks
+- API: You can now enable or disable registration on the fly, via a preference (#58)
+- Front: can now signup via the web interface (#35)
+- Front: Fixed broken redirection on login
+- Front: Fixed broken error handling on settings and login form
+
+About page:
+
+There is a brand new about page on instances (/about), and instance
+owner can now provide a name, a short and a long description for their instance via the admin (/api/admin/dynamic_preferences/globalpreferencemodel/).
+
+Transcoding:
+
+Basic transcoding is now available to/from the following formats : ogg and mp3.
+
+*This is still an alpha feature at the moment, please report any bug.*
+
+This relies internally on FFMPEG and can put some load on your server.
+It's definitely recommended you setup some caching for the transcoded files
+at your webserver level. Check the the exemple nginx file at deploy/nginx.conf
+for an implementation.
+
+On the frontend, usage of transcoding should be transparent in the player.
+
+Music Requests:
+
+This release includes a new feature, music requests, which allows users
+to request music they'd like to see imported.
+Admins can browse those requests and mark them as completed when
+an import is made.
 
 0.4 (2018-02-18)
 ----------------
diff --git a/api/Dockerfile b/api/Dockerfile
index 3281e6f562a64dd7df21d8f2a7186814e3ba985c..5d4e858574a0063b756a168f48b529e904bc427b 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -3,7 +3,7 @@ FROM python:3.5
 ENV PYTHONUNBUFFERED 1
 
 # Requirements have to be pulled and installed here, otherwise caching won't work
-
+RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
 COPY ./requirements.apt /requirements.apt
 RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
 RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index c7ebc4ed3668196ccd11d6844ba14d704e3ba43e..ff6db0d069395c316d207a640e0187ccf92b12df 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -52,6 +52,10 @@ v1_patterns += [
         include(
             ('funkwhale_api.users.api_urls', 'users'),
             namespace='users')),
+    url(r'^requests/',
+        include(
+            ('funkwhale_api.requests.api_urls', 'requests'),
+            namespace='requests')),
     url(r'^token/$', jwt_views.obtain_jwt_token, name='token'),
     url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
 ]
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 6d02cbbc1038999cc7a3de46448369aa00e030bf..491babdd15f8d4c017230f097af4b274c880a000 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -80,10 +80,12 @@ if RAVEN_ENABLED:
 
 # Apps specific for this project go here.
 LOCAL_APPS = (
+    'funkwhale_api.common',
     'funkwhale_api.users',  # custom users app
     # Your stuff: custom apps go here
     'funkwhale_api.instance',
     'funkwhale_api.music',
+    'funkwhale_api.requests',
     'funkwhale_api.favorites',
     'funkwhale_api.radios',
     'funkwhale_api.history',
@@ -262,7 +264,7 @@ AUTHENTICATION_BACKENDS = (
 )
 
 # Some really nice defaults
-ACCOUNT_AUTHENTICATION_METHOD = 'username'
+ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
 ACCOUNT_EMAIL_REQUIRED = True
 ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
 
@@ -315,7 +317,6 @@ CORS_ORIGIN_ALLOW_ALL = True
 # )
 CORS_ALLOW_CREDENTIALS = True
 API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
-REGISTRATION_MODE = env('REGISTRATION_MODE', default='disabled')
 REST_FRAMEWORK = {
     'DEFAULT_PERMISSION_CLASSES': (
         'rest_framework.permissions.IsAuthenticated',
diff --git a/api/config/urls.py b/api/config/urls.py
index de67ebb571de4b5f4e15cedd969d1adadeb42aee..8f7e37bc26ae56ba9967682f4ec3f19f04cc71f4 100644
--- a/api/config/urls.py
+++ b/api/config/urls.py
@@ -13,8 +13,8 @@ urlpatterns = [
     url(settings.ADMIN_URL, admin.site.urls),
 
     url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
-    url(r'^api/auth/', include('rest_auth.urls')),
-    url(r'^api/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
+    url(r'^api/v1/auth/', include('rest_auth.urls')),
+    url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
     url(r'^accounts/', include('allauth.urls')),
 
     # Your stuff: custom urls includes go here
diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test
index 08b437cf25d136579ab04bf50a484cd8bccb8883..069b89c2f83de1628d485374545f908fe52ec93c 100644
--- a/api/docker/Dockerfile.test
+++ b/api/docker/Dockerfile.test
@@ -1,9 +1,10 @@
 FROM python:3.5
 
 ENV PYTHONUNBUFFERED 1
-ENV PYTHONDONTWRITEBYTECODE  1
+ENV PYTHONDONTWRITEBYTECODE 1
 
 # Requirements have to be pulled and installed here, otherwise caching won't work
+RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
 COPY ./requirements.apt /requirements.apt
 COPY ./install_os_dependencies.sh /install_os_dependencies.sh
 RUN bash install_os_dependencies.sh install
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index d1c7fcdf447cc845b96ee86c5de04dbb97805eb6..7675f9251bc44f178c27e0c96b7846a0a5410931 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.4'
+__version__ = '0.5'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index 32bad6060273e53abad7de942c285b48b16c252e..59dcbd26b8449abef324273219a961d44f39f489 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -17,9 +17,6 @@ class ListeningViewSet(mixins.CreateModelMixin,
     queryset = models.Listening.objects.all()
     permission_classes = [ConditionalAuthentication]
 
-    def create(self, request, *args, **kwargs):
-        return super().create(request, *args, **kwargs)
-
     def get_queryset(self):
         queryset = super().get_queryset()
         if self.request.user.is_authenticated:
diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py
index 1d93c383eb80372c507862a3b4f2e3450d792fca..1d11a2988ca1d76da7b61d8ef8657ac6cdee74df 100644
--- a/api/funkwhale_api/instance/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py
@@ -1,8 +1,41 @@
+from django.forms import widgets
+
 from dynamic_preferences import types
 from dynamic_preferences.registries import global_preferences_registry
 
 raven = types.Section('raven')
+instance = types.Section('instance')
+
+
+@global_preferences_registry.register
+class InstanceName(types.StringPreference):
+    show_in_api = True
+    section = instance
+    name = 'name'
+    default = ''
+    help_text = 'Instance public name'
+    verbose_name = 'The public name of your instance'
+
 
+@global_preferences_registry.register
+class InstanceShortDescription(types.StringPreference):
+    show_in_api = True
+    section = instance
+    name = 'short_description'
+    default = ''
+    verbose_name = 'Instance succinct description'
+
+
+@global_preferences_registry.register
+class InstanceLongDescription(types.StringPreference):
+    show_in_api = True
+    section = instance
+    name = 'long_description'
+    default = ''
+    help_text = 'Instance long description (markdown allowed)'
+    field_kwargs = {
+        'widget': widgets.Textarea
+    }
 
 @global_preferences_registry.register
 class RavenDSN(types.StringPreference):
diff --git a/api/funkwhale_api/music/forms.py b/api/funkwhale_api/music/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..04e4bfe057c0723b161265c63000a2494c687a9d
--- /dev/null
+++ b/api/funkwhale_api/music/forms.py
@@ -0,0 +1,23 @@
+from django import forms
+
+from . import models
+
+
+class TranscodeForm(forms.Form):
+    FORMAT_CHOICES = [
+        ('ogg', 'ogg'),
+        ('mp3', 'mp3'),
+    ]
+
+    to = forms.ChoiceField(choices=FORMAT_CHOICES)
+    BITRATE_CHOICES = [
+        (64, '64'),
+        (128, '128'),
+        (256, '256'),
+    ]
+    bitrate = forms.ChoiceField(
+        choices=BITRATE_CHOICES, required=False)
+
+    track_file = forms.ModelChoiceField(
+        queryset=models.TrackFile.objects.all()
+    )
diff --git a/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py
new file mode 100644
index 0000000000000000000000000000000000000000..c45298798b87f52dfe101b1d5f4a10a94407f10b
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.0.2 on 2018-02-18 15:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0017_auto_20171227_1728'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='trackfile',
+            name='mimetype',
+            field=models.CharField(blank=True, max_length=200, null=True),
+        ),
+        migrations.AlterField(
+            model_name='importjob',
+            name='source',
+            field=models.CharField(max_length=500),
+        ),
+        migrations.AlterField(
+            model_name='importjob',
+            name='status',
+            field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py
new file mode 100644
index 0000000000000000000000000000000000000000..127aa5e69a245215e2b0f16e0b5e3081efb7db49
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+import os
+
+from django.db import migrations, models
+from funkwhale_api.music.utils import guess_mimetype
+
+
+def populate_mimetype(apps, schema_editor):
+    TrackFile = apps.get_model("music", "TrackFile")
+
+    for tf in TrackFile.objects.filter(audio_file__isnull=False, mimetype__isnull=True).only('audio_file'):
+        try:
+            tf.mimetype = guess_mimetype(tf.audio_file)
+        except Exception as e:
+            print('Error on track file {}: {}'.format(tf.pk, e))
+            continue
+        print('Track file {}: {}'.format(tf.pk, tf.mimetype))
+        tf.save(update_fields=['mimetype'])
+
+
+def rewind(apps, schema_editor):
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0018_auto_20180218_1554'),
+    ]
+
+    operations = [
+        migrations.RunPython(populate_mimetype, rewind),
+    ]
diff --git a/api/funkwhale_api/music/migrations/0020_importbatch_status.py b/api/funkwhale_api/music/migrations/0020_importbatch_status.py
new file mode 100644
index 0000000000000000000000000000000000000000..265d1ba5d5312d086f25d0861039b3a94a5df4e5
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0020_importbatch_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.2 on 2018-02-20 19:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0019_populate_mimetypes'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='importbatch',
+            name='status',
+            field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/migrations/0021_populate_batch_status.py b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py
new file mode 100644
index 0000000000000000000000000000000000000000..061d649b06115d4871b6c6eb7ae57b25c27661e8
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+import os
+
+from django.db import migrations, models
+
+
+def populate_status(apps, schema_editor):
+    from funkwhale_api.music.utils import compute_status
+    ImportBatch = apps.get_model("music", "ImportBatch")
+
+    for ib in ImportBatch.objects.prefetch_related('jobs'):
+        ib.status = compute_status(ib.jobs.all())
+        ib.save(update_fields=['status'])
+
+
+def rewind(apps, schema_editor):
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0020_importbatch_status'),
+    ]
+
+    operations = [
+        migrations.RunPython(populate_status, rewind),
+    ]
diff --git a/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py
new file mode 100644
index 0000000000000000000000000000000000000000..d9f6f01d9121f1148e1f3d5a729b1fc476a89f42
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.0.2 on 2018-02-20 22:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('requests', '__first__'),
+        ('music', '0021_populate_batch_status'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='importbatch',
+            name='import_request',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='import_batches', to='requests.ImportRequest'),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index f8373ab4d0f286dae34da01dfbffe8967ed9f9a0..97992fc8f12cafa7af7cb99c7cbfb1cee2b42192 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -10,14 +10,18 @@ from django.conf import settings
 from django.db import models
 from django.core.files.base import ContentFile
 from django.core.files import File
+from django.db.models.signals import post_save
+from django.dispatch import receiver
 from django.urls import reverse
 from django.utils import timezone
+
 from taggit.managers import TaggableManager
 from versatileimagefield.fields import VersatileImageField
 
 from funkwhale_api import downloader
 from funkwhale_api import musicbrainz
 from . import importers
+from . import utils
 
 
 class APIModelMixin(models.Model):
@@ -364,6 +368,7 @@ class TrackFile(models.Model):
     source = models.URLField(null=True, blank=True)
     duration = models.IntegerField(null=True, blank=True)
     acoustid_track_id = models.UUIDField(null=True, blank=True)
+    mimetype = models.CharField(null=True, blank=True, max_length=200)
 
     def download_file(self):
         # import the track file, since there is not any
@@ -393,6 +398,18 @@ class TrackFile(models.Model):
             self.track.full_name,
             os.path.splitext(self.audio_file.name)[-1])
 
+    def save(self, **kwargs):
+        if not self.mimetype and self.audio_file:
+            self.mimetype = utils.guess_mimetype(self.audio_file)
+        return super().save(**kwargs)
+
+
+IMPORT_STATUS_CHOICES = (
+    ('pending', 'Pending'),
+    ('finished', 'Finished'),
+    ('errored', 'Errored'),
+    ('skipped', 'Skipped'),
+)
 
 class ImportBatch(models.Model):
     IMPORT_BATCH_SOURCES = [
@@ -406,22 +423,24 @@ class ImportBatch(models.Model):
         'users.User',
         related_name='imports',
         on_delete=models.CASCADE)
-
+    status = models.CharField(
+        choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
+    import_request = models.ForeignKey(
+        'requests.ImportRequest',
+        related_name='import_batches',
+        null=True,
+        blank=True,
+        on_delete=models.CASCADE)
     class Meta:
         ordering = ['-creation_date']
 
     def __str__(self):
         return str(self.pk)
 
-    @property
-    def status(self):
-        pending = any([job.status == 'pending' for job in self.jobs.all()])
-        errored = any([job.status == 'errored' for job in self.jobs.all()])
-        if pending:
-            return 'pending'
-        if errored:
-            return 'errored'
-        return 'finished'
+    def update_status(self):
+        self.status = utils.compute_status(self.jobs.all())
+        self.save(update_fields=['status'])
+
 
 class ImportJob(models.Model):
     batch = models.ForeignKey(
@@ -434,15 +453,39 @@ class ImportJob(models.Model):
         on_delete=models.CASCADE)
     source = models.CharField(max_length=500)
     mbid = models.UUIDField(editable=False, null=True, blank=True)
-    STATUS_CHOICES = (
-        ('pending', 'Pending'),
-        ('finished', 'Finished'),
-        ('errored', 'Errored'),
-        ('skipped', 'Skipped'),
-    )
-    status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30)
+
+    status = models.CharField(
+        choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
     audio_file = models.FileField(
         upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
 
     class Meta:
         ordering = ('id', )
+
+
+@receiver(post_save, sender=ImportJob)
+def update_batch_status(sender, instance, **kwargs):
+    instance.batch.update_status()
+
+
+@receiver(post_save, sender=ImportBatch)
+def update_request_status(sender, instance, created, **kwargs):
+    update_fields = kwargs.get('update_fields', []) or []
+    if not instance.import_request:
+        return
+
+    if not created and not 'status' in update_fields:
+        return
+
+    r_status = instance.import_request.status
+    status = instance.status
+
+    if status == 'pending' and r_status == 'pending':
+        # let's mark the request as accepted since we started an import
+        instance.import_request.status = 'accepted'
+        return instance.import_request.save(update_fields=['status'])
+
+    if status == 'finished' and r_status == 'accepted':
+        # let's mark the request as imported since the import is over
+        instance.import_request.status = 'imported'
+        return instance.import_request.save(update_fields=['status'])
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 506893a4d23ae1026f5f26a159eeb841443dde96..db6298a9e446eddc64bc17f95e3a6b75bf126573 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -28,7 +28,14 @@ class TrackFileSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = models.TrackFile
-        fields = ('id', 'path', 'duration', 'source', 'filename', 'track')
+        fields = (
+            'id',
+            'path',
+            'duration',
+            'source',
+            'filename',
+            'mimetype',
+            'track')
 
     def get_path(self, o):
         url = o.path
@@ -118,5 +125,5 @@ class ImportBatchSerializer(serializers.ModelSerializer):
     jobs = ImportJobSerializer(many=True, read_only=True)
     class Meta:
         model = models.ImportBatch
-        fields = ('id', 'jobs', 'status', 'creation_date')
+        fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
         read_only_fields = ('creation_date',)
diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py
index 32b1aeb470af219124e8a0c533f13b6e991981c5..a75cf5de8508efaa5df587eb17b01a2e87f1214f 100644
--- a/api/funkwhale_api/music/utils.py
+++ b/api/funkwhale_api/music/utils.py
@@ -1,7 +1,9 @@
+import magic
 import re
 
 from django.db.models import Q
 
+
 def normalize_query(query_string,
                     findterms=re.compile(r'"([^"]+)"|(\S+)').findall,
                     normspace=re.compile(r'\s{2,}').sub):
@@ -15,6 +17,7 @@ def normalize_query(query_string,
     '''
     return [normspace(' ', (t[0] or t[1]).strip()) for t in findterms(query_string)]
 
+
 def get_query(query_string, search_fields):
     ''' Returns a query, that is a combination of Q objects. That combination
         aims to search keywords within a model by testing the given search fields.
@@ -35,3 +38,18 @@ def get_query(query_string, search_fields):
         else:
             query = query & or_query
     return query
+
+
+def guess_mimetype(f):
+    b = min(100000, f.size)
+    return magic.from_buffer(f.read(b), mime=True)
+
+
+def compute_status(jobs):
+    errored = any([job.status == 'errored' for job in jobs])
+    if errored:
+        return 'errored'
+    pending = any([job.status == 'pending' for job in jobs])
+    if pending:
+        return 'pending'
+    return 'finished'
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 2395454c46429a5b48ccee7fe22e25bf39808318..bf9d39b1d507c7122cb5e6d2eeb70b45e8946fee 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -1,11 +1,16 @@
+import ffmpeg
 import os
 import json
+import subprocess
 import unicodedata
 import urllib
+
 from django.urls import reverse
 from django.db import models, transaction
 from django.db.models.functions import Length
 from django.conf import settings
+from django.http import StreamingHttpResponse
+
 from rest_framework import viewsets, views, mixins
 from rest_framework.decorators import detail_route, list_route
 from rest_framework.response import Response
@@ -14,11 +19,13 @@ from musicbrainzngs import ResponseError
 from django.contrib.auth.decorators import login_required
 from django.utils.decorators import method_decorator
 
+from funkwhale_api.requests.models import ImportRequest
 from funkwhale_api.musicbrainz import api
 from funkwhale_api.common.permissions import (
     ConditionalAuthentication, HasModelPermission)
 from taggit.models import Tag
 
+from . import forms
 from . import models
 from . import serializers
 from . import importers
@@ -183,6 +190,40 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
             f.audio_file.url)
         return response
 
+    @list_route(methods=['get'])
+    def viewable(self, request, *args, **kwargs):
+        return Response({}, status=200)
+
+    @list_route(methods=['get'])
+    def transcode(self, request, *args, **kwargs):
+        form = forms.TranscodeForm(request.GET)
+        if not form.is_valid():
+            return Response(form.errors, status=400)
+
+        f = form.cleaned_data['track_file']
+        output_kwargs = {
+            'format': form.cleaned_data['to']
+        }
+        args = (ffmpeg
+            .input(f.audio_file.path)
+            .output('pipe:', **output_kwargs)
+            .get_args()
+        )
+        # we use a generator here so the view return immediatly and send
+        # file chunk to the browser, instead of blocking a few seconds
+        def _transcode():
+            p = subprocess.Popen(
+                ['ffmpeg'] + args,
+                stdout=subprocess.PIPE)
+            for line in p.stdout:
+                yield line
+
+        response = StreamingHttpResponse(
+            _transcode(), status=200,
+            content_type=form.cleaned_data['to'])
+
+        return response
+
 
 class TagViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = Tag.objects.all().order_by('name')
@@ -274,14 +315,28 @@ class SubmitViewSet(viewsets.ViewSet):
         serializer = serializers.ImportBatchSerializer(batch)
         return Response(serializer.data)
 
+    def get_import_request(self, data):
+        try:
+            raw = data['importRequest']
+        except KeyError:
+            return
+
+        pk = int(raw)
+        try:
+            return ImportRequest.objects.get(pk=pk)
+        except ImportRequest.DoesNotExist:
+            pass
+
     @list_route(methods=['post'])
     @transaction.non_atomic_requests
     def album(self, request, *args, **kwargs):
         data = json.loads(request.body.decode('utf-8'))
-        import_data, batch = self._import_album(data, request, batch=None)
+        import_request = self.get_import_request(data)
+        import_data, batch = self._import_album(
+            data, request, batch=None, import_request=import_request)
         return Response(import_data)
 
-    def _import_album(self, data, request, batch=None):
+    def _import_album(self, data, request, batch=None, import_request=None):
         # we import the whole album here to prevent race conditions that occurs
         # when using get_or_create_from_api in tasks
         album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
@@ -292,7 +347,9 @@ class SubmitViewSet(viewsets.ViewSet):
         except ResponseError:
             pass
         if not batch:
-            batch = models.ImportBatch.objects.create(submitted_by=request.user)
+            batch = models.ImportBatch.objects.create(
+                submitted_by=request.user,
+                import_request=import_request)
         for row in data['tracks']:
             try:
                 models.TrackFile.objects.get(track__mbid=row['mbid'])
@@ -306,6 +363,7 @@ class SubmitViewSet(viewsets.ViewSet):
     @transaction.non_atomic_requests
     def artist(self, request, *args, **kwargs):
         data = json.loads(request.body.decode('utf-8'))
+        import_request = self.get_import_request(data)
         artist_data = api.artists.get(id=data['artistId'])['artist']
         cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
         artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
@@ -313,7 +371,8 @@ class SubmitViewSet(viewsets.ViewSet):
         import_data = []
         batch = None
         for row in data['albums']:
-            row_data, batch = self._import_album(row, request, batch=batch)
+            row_data, batch = self._import_album(
+                row, request, batch=batch, import_request=import_request)
             import_data.append(row_data)
 
         return Response(import_data[0])
diff --git a/api/funkwhale_api/requests/__init__.py b/api/funkwhale_api/requests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/requests/api_urls.py b/api/funkwhale_api/requests/api_urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..37459a664a4d7e1fd9bc229fbb678daa02adb012
--- /dev/null
+++ b/api/funkwhale_api/requests/api_urls.py
@@ -0,0 +1,11 @@
+from django.conf.urls import include, url
+from . import views
+
+from rest_framework import routers
+router = routers.SimpleRouter()
+router.register(
+    r'import-requests',
+    views.ImportRequestViewSet,
+    'import-requests')
+
+urlpatterns = router.urls
diff --git a/api/funkwhale_api/requests/factories.py b/api/funkwhale_api/requests/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..2bcdeb6a9699ee47a16cc4fb2e05ebe9b19286e0
--- /dev/null
+++ b/api/funkwhale_api/requests/factories.py
@@ -0,0 +1,15 @@
+import factory
+
+from funkwhale_api.factories import registry
+from funkwhale_api.users.factories import UserFactory
+
+
+@registry.register
+class ImportRequestFactory(factory.django.DjangoModelFactory):
+    artist_name = factory.Faker('name')
+    albums = factory.Faker('sentence')
+    user = factory.SubFactory(UserFactory)
+    comment = factory.Faker('paragraph')
+
+    class Meta:
+        model = 'requests.ImportRequest'
diff --git a/api/funkwhale_api/requests/filters.py b/api/funkwhale_api/requests/filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..bf353e8ad075abb76f3a4fdc846d9d25b0a105f4
--- /dev/null
+++ b/api/funkwhale_api/requests/filters.py
@@ -0,0 +1,14 @@
+import django_filters
+
+from . import models
+
+
+class ImportRequestFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = models.ImportRequest
+        fields = {
+            'artist_name': ['exact', 'iexact', 'startswith', 'icontains'],
+            'status': ['exact'],
+            'user__username': ['exact'],
+        }
diff --git a/api/funkwhale_api/requests/migrations/0001_initial.py b/api/funkwhale_api/requests/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c239b3c079234ec130be85c300e3a9956a7ad41
--- /dev/null
+++ b/api/funkwhale_api/requests/migrations/0001_initial.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.0.2 on 2018-02-20 22:49
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ImportRequest',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('imported_date', models.DateTimeField(blank=True, null=True)),
+                ('artist_name', models.CharField(max_length=250)),
+                ('albums', models.CharField(blank=True, max_length=3000, null=True)),
+                ('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('imported', 'imported'), ('closed', 'closed')], default='pending', max_length=50)),
+                ('comment', models.TextField(blank=True, max_length=3000, null=True)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_requests', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
diff --git a/api/funkwhale_api/requests/migrations/__init__.py b/api/funkwhale_api/requests/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..c298524306caa9bc7f4cd6e9eb1f4abbb2d4bde0
--- /dev/null
+++ b/api/funkwhale_api/requests/models.py
@@ -0,0 +1,29 @@
+from django.db import models
+
+from django.utils import timezone
+
+NATURE_CHOICES = [
+    ('artist', 'artist'),
+    ('album', 'album'),
+    ('track', 'track'),
+]
+
+STATUS_CHOICES = [
+    ('pending', 'pending'),
+    ('accepted', 'accepted'),
+    ('imported', 'imported'),
+    ('closed', 'closed'),
+]
+
+class ImportRequest(models.Model):
+    creation_date = models.DateTimeField(default=timezone.now)
+    imported_date = models.DateTimeField(null=True, blank=True)
+    user = models.ForeignKey(
+        'users.User',
+        related_name='import_requests',
+        on_delete=models.CASCADE)
+    artist_name = models.CharField(max_length=250)
+    albums = models.CharField(max_length=3000, null=True, blank=True)
+    status = models.CharField(
+        choices=STATUS_CHOICES, max_length=50, default='pending')
+    comment = models.TextField(null=True, blank=True, max_length=3000)
diff --git a/api/funkwhale_api/requests/serializers.py b/api/funkwhale_api/requests/serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..51a709514e0cb95a2f03289dd981df918734c795
--- /dev/null
+++ b/api/funkwhale_api/requests/serializers.py
@@ -0,0 +1,30 @@
+from rest_framework import serializers
+
+from funkwhale_api.users.serializers import UserBasicSerializer
+
+from . import models
+
+
+class ImportRequestSerializer(serializers.ModelSerializer):
+    user = UserBasicSerializer(read_only=True)
+
+    class Meta:
+        model = models.ImportRequest
+        fields = (
+            'id',
+            'status',
+            'albums',
+            'artist_name',
+            'user',
+            'creation_date',
+            'imported_date',
+            'comment')
+        read_only_fields = (
+            'creation_date',
+            'imported_date',
+            'user',
+            'status')
+
+    def create(self, validated_data):
+        validated_data['user'] = self.context['user']
+        return super().create(validated_data)
diff --git a/api/funkwhale_api/requests/views.py b/api/funkwhale_api/requests/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..395fac66cff33e3a01fdcc33a56adfcea0aa0c7e
--- /dev/null
+++ b/api/funkwhale_api/requests/views.py
@@ -0,0 +1,36 @@
+from rest_framework import generics, mixins, viewsets
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.decorators import detail_route
+
+from funkwhale_api.music.views import SearchMixin
+
+from . import filters
+from . import models
+from . import serializers
+
+
+class ImportRequestViewSet(
+        SearchMixin,
+        mixins.CreateModelMixin,
+        mixins.RetrieveModelMixin,
+        mixins.ListModelMixin,
+        viewsets.GenericViewSet):
+
+    serializer_class = serializers.ImportRequestSerializer
+    queryset = (
+        models.ImportRequest.objects.all()
+              .select_related()
+              .order_by('-creation_date'))
+    search_fields = ['artist_name', 'album_name', 'comment']
+    filter_class = filters.ImportRequestFilter
+    ordering_fields = ('id', 'artist_name', 'creation_date', 'status')
+
+    def perform_create(self, serializer):
+        return serializer.save(user=self.request.user)
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        if self.request.user.is_authenticated:
+            context['user'] = self.request.user
+        return context
diff --git a/api/funkwhale_api/users/adapters.py b/api/funkwhale_api/users/adapters.py
index 792b4860fc240439f72c8cd7dc2fe319ac633fa1..96d1b8b1d6b4aaa708bc3c09490beae84c10fa1a 100644
--- a/api/funkwhale_api/users/adapters.py
+++ b/api/funkwhale_api/users/adapters.py
@@ -1,15 +1,10 @@
 from allauth.account.adapter import DefaultAccountAdapter
 
-from django.conf import settings
+from dynamic_preferences.registries import global_preferences_registry
 
 
 class FunkwhaleAccountAdapter(DefaultAccountAdapter):
 
     def is_open_for_signup(self, request):
-
-        if settings.REGISTRATION_MODE == "disabled":
-            return False
-        if settings.REGISTRATION_MODE == "public":
-            return True
-
-        return False
+        manager = global_preferences_registry.manager()
+        return manager['users__registration_enabled']
diff --git a/api/funkwhale_api/users/dynamic_preferences_registry.py b/api/funkwhale_api/users/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..16d79da143cb3139f9a92137044b6092288e1b40
--- /dev/null
+++ b/api/funkwhale_api/users/dynamic_preferences_registry.py
@@ -0,0 +1,15 @@
+from dynamic_preferences import types
+from dynamic_preferences.registries import global_preferences_registry
+
+users = types.Section('users')
+
+
+@global_preferences_registry.register
+class RegistrationEnabled(types.BooleanPreference):
+    show_in_api = True
+    section = users
+    name = 'registration_enabled'
+    default = False
+    verbose_name = (
+        'Can visitors open a new account on this instance?'
+    )
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 261873bdbec1a34ee0c69e30a002161e69e423f7..8c218b1c28acd2811b208e0a3f4b4f0d788c1aea 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -3,6 +3,12 @@ from rest_framework import serializers
 from . import models
 
 
+class UserBasicSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.User
+        fields = ['id', 'username', 'name', 'date_joined']
+
+
 class UserSerializer(serializers.ModelSerializer):
 
     permissions = serializers.SerializerMethodField()
diff --git a/api/requirements.apt b/api/requirements.apt
index e28360b5658fe17d9f2a5afa4d6e28feedf87101..462a5a705c75b319636449490c6e3c29b089a7f1 100644
--- a/api/requirements.apt
+++ b/api/requirements.apt
@@ -5,6 +5,7 @@ libjpeg-dev
 zlib1g-dev
 libpq-dev
 postgresql-client
-libav-tools
+libmagic-dev
+ffmpeg
 python3-dev
 curl
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index f38da9629041fcd8f9f1a0620d00ec17c0823f32..133fcc0cb65f0deb430d73ff5259d05efc215cc2 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -57,3 +57,5 @@ git+https://github.com/EliotBerriot/django-cachalot.git@django-2
 django-dynamic-preferences>=1.5,<1.6
 pyacoustid>=1.1.5,<1.2
 raven>=6.5,<7
+python-magic==0.4.15
+ffmpeg-python==0.1.10
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 4d7a6fa981d1431cd01a97d4578d2a992b4cb749..10d7c323512c684ff495bda2a2a7a7ae581213f8 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -56,6 +56,24 @@ def api_client(client):
     return APIClient()
 
 
+@pytest.fixture
+def logged_in_api_client(db, factories, api_client):
+    user = factories['users.User']()
+    assert api_client.login(username=user.username, password='test')
+    setattr(api_client, 'user', user)
+    yield api_client
+    delattr(api_client, 'user')
+
+
+@pytest.fixture
+def superuser_api_client(db, factories, api_client):
+    user = factories['users.SuperUser']()
+    assert api_client.login(username=user.username, password='test')
+    setattr(api_client, 'user', user)
+    yield api_client
+    delattr(api_client, 'user')
+
+
 @pytest.fixture
 def superuser_client(db, factories, client):
     user = factories['users.SuperUser']()
diff --git a/api/tests/instance/test_preferences.py b/api/tests/instance/test_preferences.py
index c89bfa3492091a93d0000f661bd83f2fc8ebedc0..beb8e6d33f810110c35ed14b164a76ed4c8bb019 100644
--- a/api/tests/instance/test_preferences.py
+++ b/api/tests/instance/test_preferences.py
@@ -1,3 +1,5 @@
+import pytest
+
 from django.urls import reverse
 
 from dynamic_preferences.api import serializers
@@ -20,3 +22,14 @@ def test_can_list_settings_via_api(preferences, api_client):
     for p in response.data:
         i = '__'.join([p['section'], p['name']])
         assert i in expected_preferences
+
+
+@pytest.mark.parametrize('pref,value', [
+    ('instance__name', 'My instance'),
+    ('instance__short_description', 'For music lovers'),
+    ('instance__long_description', 'For real music lovers'),
+])
+def test_instance_settings(pref, value, preferences):
+    preferences[pref] = value
+
+    assert preferences[pref] == value
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2ca1abbd04a562764194f09653e17c4724f3cc4
--- /dev/null
+++ b/api/tests/music/test_import.py
@@ -0,0 +1,37 @@
+import json
+
+from django.urls import reverse
+
+from . import data as api_data
+
+
+def test_create_import_can_bind_to_request(
+        mocker, factories, superuser_api_client):
+    request = factories['requests.ImportRequest']()
+
+    mocker.patch('funkwhale_api.music.tasks.import_job_run')
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.artists.get',
+        return_value=api_data.artists['get']['soad'])
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.images.get_front',
+        return_value=b'')
+    mocker.patch(
+        'funkwhale_api.musicbrainz.api.releases.get',
+        return_value=api_data.albums['get_with_includes']['hypnotize'])
+    payload = {
+        'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
+        'importRequest': request.pk,
+        'tracks': [
+            {
+                'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
+                'source': 'https://www.youtube.com/watch?v=1111111111',
+            }
+        ]
+    }
+    url = reverse('api:v1:submit-album')
+    response = superuser_api_client.post(
+        url, json.dumps(payload), content_type='application/json')
+    batch = request.import_batches.latest('id')
+
+    assert batch.import_request == request
diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py
index 165415465d1431d7294f065efccdb390b87b6b96..9f52ba8874e50c10ea0216cacc8363ab767d6396 100644
--- a/api/tests/music/test_models.py
+++ b/api/tests/music/test_models.py
@@ -1,9 +1,12 @@
+import os
 import pytest
 
 from funkwhale_api.music import models
 from funkwhale_api.music import importers
 from funkwhale_api.music import tasks
 
+DATA_DIR = os.path.dirname(os.path.abspath(__file__))
+
 
 def test_can_store_release_group_id_on_album(factories):
     album = factories['music.Album']()
@@ -48,3 +51,29 @@ def test_import_job_is_bound_to_track_file(factories, mocker):
     tasks.import_job_run(import_job_id=job.pk)
     job.refresh_from_db()
     assert job.track_file.track == track
+
+
+@pytest.mark.parametrize('status', ['pending', 'errored', 'finished'])
+def test_saving_job_updates_batch_status(status,factories, mocker):
+    batch = factories['music.ImportBatch']()
+
+    assert batch.status == 'pending'
+
+    job = factories['music.ImportJob'](batch=batch, status=status)
+
+    batch.refresh_from_db()
+
+    assert batch.status == status
+
+
+@pytest.mark.parametrize('extention,mimetype', [
+    ('ogg', 'audio/ogg'),
+    ('mp3', 'audio/mpeg'),
+])
+def test_audio_track_mime_type(extention, mimetype, factories):
+
+    name = '.'.join(['test', extention])
+    path = os.path.join(DATA_DIR, name)
+    tf = factories['music.TrackFile'](audio_file__from_path=path)
+
+    assert tf.mimetype == mimetype
diff --git a/api/tests/requests/test_models.py b/api/tests/requests/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..797656bd70c5ab916c650c589bd70ea5fc41be87
--- /dev/null
+++ b/api/tests/requests/test_models.py
@@ -0,0 +1,23 @@
+import pytest
+
+from django.forms import ValidationError
+
+
+def test_can_bind_import_batch_to_request(factories):
+    request = factories['requests.ImportRequest']()
+
+    assert request.status == 'pending'
+
+    # when we create the import, we consider the request as accepted
+    batch = factories['music.ImportBatch'](import_request=request)
+    request.refresh_from_db()
+
+    assert request.status == 'accepted'
+
+    # now, the batch is finished, therefore the request status should be
+    # imported
+    batch.status = 'finished'
+    batch.save(update_fields=['status'])
+    request.refresh_from_db()
+
+    assert request.status == 'imported'
diff --git a/api/tests/requests/test_views.py b/api/tests/requests/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..6c34f9ad19fcc8ae7ec10ba6563cb44d45cb3461
--- /dev/null
+++ b/api/tests/requests/test_views.py
@@ -0,0 +1,26 @@
+from django.urls import reverse
+
+
+def test_request_viewset_requires_auth(db, api_client):
+    url = reverse('api:v1:requests:import-requests-list')
+    response = api_client.get(url)
+    assert response.status_code == 401
+
+
+def test_user_can_create_request(logged_in_api_client):
+    url = reverse('api:v1:requests:import-requests-list')
+    user = logged_in_api_client.user
+    data = {
+        'artist_name': 'System of a Down',
+        'albums': 'All please!',
+        'comment': 'Please, they rock!',
+    }
+    response = logged_in_api_client.post(url, data)
+
+    assert response.status_code == 201
+
+    ir = user.import_requests.latest('id')
+    assert ir.status == 'pending'
+    assert ir.creation_date is not None
+    for field, value in data.items():
+        assert getattr(ir, field) == value
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 1eb8ef222a79d68f41d40c8555c0c7cb9d931680..569acbd15ee5138150dd7de4112cd7ebd2d5523a 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -6,7 +6,7 @@ from django.urls import reverse
 from funkwhale_api.users.models import User
 
 
-def test_can_create_user_via_api(settings, client, db):
+def test_can_create_user_via_api(preferences, client, db):
     url = reverse('rest_register')
     data = {
         'username': 'test1',
@@ -14,7 +14,7 @@ def test_can_create_user_via_api(settings, client, db):
         'password1': 'testtest',
         'password2': 'testtest',
     }
-    settings.REGISTRATION_MODE = "public"
+    preferences['users__registration_enabled'] = True
     response = client.post(url, data)
     assert response.status_code == 201
 
@@ -22,7 +22,7 @@ def test_can_create_user_via_api(settings, client, db):
     assert u.username == 'test1'
 
 
-def test_can_disable_registration_view(settings, client, db):
+def test_can_disable_registration_view(preferences, client, db):
     url = reverse('rest_register')
     data = {
         'username': 'test1',
@@ -30,7 +30,7 @@ def test_can_disable_registration_view(settings, client, db):
         'password1': 'testtest',
         'password2': 'testtest',
     }
-    settings.REGISTRATION_MODE = "disabled"
+    preferences['users__registration_enabled'] = False
     response = client.post(url, data)
     assert response.status_code == 403
 
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index 5bdfeb9c626fa074eaedb5c7f499871946d3dea9..6a4b15b67cf04857b87296c30a23060e650f2d7c 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -74,11 +74,6 @@ DJANGO_SECRET_KEY=
 # If True, unauthenticated users won't be able to query the API
 API_AUTHENTICATION_REQUIRED=True
 
-# What is the workflow for registration on funkwhale ? Possible values:
-# public: anybody can register an account
-# disabled: nobody can register an account
-REGISTRATION_MODE=disabled
-
 # Sentry/Raven error reporting (server side)
 # Enable Raven if you want to help improve funkwhale by
 # automatically sending error reports our Sentry instance.
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index cf865a9ea7b3ef84eeb6eda8d6d65f509790eaf5..dfdbac2ae909bf9d5efafb1698f4fd8256e17eba 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -39,6 +39,15 @@ server {
 
     root /srv/funkwhale/front/dist;
 
+    # global proxy conf
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_set_header X-Forwarded-Host   $host:$server_port;
+    proxy_set_header X-Forwarded-Port   $server_port;
+    proxy_redirect off;
+
     location / {
         try_files $uri $uri/ @rewrites;
     }
@@ -49,15 +58,9 @@ server {
     location /api/ {
         # this is needed if you have file import via upload enabled
         client_max_body_size 30M;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-        proxy_set_header X-Forwarded-Host   $host:$server_port;
-        proxy_set_header X-Forwarded-Port   $server_port;
-        proxy_redirect off;
         proxy_pass   http://funkwhale-api/api/;
     }
+
     location /media/ {
         alias /srv/funkwhale/data/media/;
     }
@@ -70,6 +73,41 @@ server {
         alias   /srv/funkwhale/data/media;
     }
 
+    # Transcoding logic and caching
+    location = /transcode-auth {
+        # needed so we can authenticate transcode requests, but still
+        # cache the result
+        internal;
+        set $query '';
+        # ensure we actually pass the jwt to the underlytin auth url
+        if ($request_uri ~* "[^\?]+\?(.*)$") {
+            set $query $1;
+        }
+        proxy_set_header X-Forwarded-Host   $host:$server_port;
+        proxy_set_header X-Forwarded-Port   $server_port;
+        proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
+        proxy_pass_request_body off;
+        proxy_set_header        Content-Length "";
+    }
+
+    location /api/v1/trackfiles/transcode/ {
+        # this block deals with authenticating and caching transcoding
+        # requests. Caching is heavily recommended as transcoding
+        # is a CPU intensive process.
+        auth_request /transcode-auth;
+        if ($args ~ (.*)jwt=[^&]*(.*)) {
+            set $cleaned_args $1$2;
+        }
+        proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args";
+        proxy_cache transcode;
+        proxy_cache_valid 200 7d;
+        proxy_ignore_headers "Set-Cookie";
+        proxy_hide_header "Set-Cookie";
+        add_header X-Cache-Status $upstream_cache_status;
+        proxy_pass   http://funkwhale-api;
+    }
+    # end of transcoding logic
+
     location /staticfiles/ {
         # django static files
         alias /srv/funkwhale/data/static/;
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 1b749c30a24006e2e954044bc50ec03a94d1adb6..29c04fc6643c17a8918c9ad4546993d49587336e 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -26,23 +26,59 @@ http {
     keepalive_timeout  65;
 
     #gzip  on;
+    proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
 
     server {
         listen 6001;
         charset     utf-8;
         client_max_body_size 20M;
+
+        # global proxy pass config
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-Host   localhost:8080;
+        proxy_set_header X-Forwarded-Port   8080;
+        proxy_redirect off;
+
         location /_protected/media {
             internal;
             alias   /protected/media;
         }
-        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 X-Forwarded-Proto $scheme;
+        location = /transcode-auth {
+            # needed so we can authenticate transcode requests, but still
+            # cache the result
+            internal;
+            set $query '';
+            # ensure we actually pass the jwt to the underlytin auth url
+            if ($request_uri ~* "[^\?]+\?(.*)$") {
+                set $query $1;
+            }
             proxy_set_header X-Forwarded-Host   localhost:8080;
             proxy_set_header X-Forwarded-Port   8080;
-            proxy_redirect off;
+            proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
+            proxy_pass_request_body off;
+            proxy_set_header        Content-Length "";
+        }
+
+        location /api/v1/trackfiles/transcode/ {
+            # this block deals with authenticating and caching transcoding
+            # requests. Caching is heavily recommended as transcoding
+            # is a CPU intensive process.
+            auth_request /transcode-auth;
+            if ($args ~ (.*)jwt=[^&]*(.*)) {
+                set $cleaned_args $1$2;
+            }
+            proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args";
+            proxy_cache transcode;
+            proxy_cache_valid 200 7d;
+            proxy_ignore_headers "Set-Cookie";
+            proxy_hide_header "Set-Cookie";
+            add_header X-Cache-Status $upstream_cache_status;
+            proxy_pass http://api:12081;
+        }
+        location / {
             proxy_pass   http://api:12081/;
         }
     }
diff --git a/front/package.json b/front/package.json
index ac3895f6d65655fd198699c92cd55300df301b1e..042e332d0eb91d81df9c27fe13b82b0e16013d3a 100644
--- a/front/package.json
+++ b/front/package.json
@@ -20,9 +20,11 @@
     "js-logger": "^1.3.0",
     "jwt-decode": "^2.2.0",
     "lodash": "^4.17.4",
+    "moment": "^2.20.1",
     "moxios": "^0.4.0",
     "raven-js": "^3.22.3",
     "semantic-ui-css": "^2.2.10",
+    "showdown": "^1.8.6",
     "vue": "^2.3.3",
     "vue-lazyload": "^1.1.4",
     "vue-router": "^2.3.1",
diff --git a/front/src/App.vue b/front/src/App.vue
index 98ad48d3ff41cd37cfa4df496c51c24f8dbd0544..8453aa33941813d6c451fa2184d05e7f9c669486 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -9,6 +9,9 @@
           <div class="three wide column">
             <h4 class="ui header">Links</h4>
             <div class="ui link list">
+              <router-link class="item" to="/about">
+                About this instance
+              </router-link>
               <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>
diff --git a/front/src/audio/formats.js b/front/src/audio/formats.js
new file mode 100644
index 0000000000000000000000000000000000000000..f6e2157a15d6c64afff10addd30ceba724dcdc65
--- /dev/null
+++ b/front/src/audio/formats.js
@@ -0,0 +1,10 @@
+export default {
+  formats: [
+    // 'audio/ogg',
+    'audio/mpeg'
+  ],
+  formatsMap: {
+    'audio/ogg': 'ogg',
+    'audio/mpeg': 'mp3'
+  }
+}
diff --git a/front/src/components/About.vue b/front/src/components/About.vue
new file mode 100644
index 0000000000000000000000000000000000000000..01ce6a294fdeb632d03e8eb23a4a4bbf83766223
--- /dev/null
+++ b/front/src/components/About.vue
@@ -0,0 +1,45 @@
+<template>
+  <div class="main pusher">
+    <div class="ui vertical center aligned stripe segment">
+      <div class="ui text container">
+        <h1 class="ui huge header">
+            <template v-if="instance.name.value">About {{ instance.name.value }}</template>
+            <template v-else="instance.name.value">About this instance</template>
+        </h1>
+      </div>
+    </div>
+    <div class="ui vertical stripe segment">
+      <p v-if="!instance.short_description.value && !instance.long_description.value">
+        Unfortunately, owners of this instance did not yet take the time to complete this page.</p>
+      <div
+        v-if="instance.short_description.value"
+        class="ui middle aligned stackable text container">
+        <p>{{ instance.short_description.value }}</p>
+      </div>
+      <div
+        v-if="instance.long_description.value"
+        class="ui middle aligned stackable text container"
+        v-html="$options.filters.markdown(instance.long_description.value)">
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import {mapState} from 'vuex'
+
+export default {
+  created () {
+    this.$store.dispatch('instance/fetchSettings')
+  },
+  computed: {
+    ...mapState({
+      instance: state => state.instance.settings.instance
+    })
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue
index dd324943feccf17c8e0a1e9a570c836b984cde7a..0cea1c25ab4361d019d6df6be7c4cdad4b1c43a5 100644
--- a/front/src/components/Home.vue
+++ b/front/src/components/Home.vue
@@ -6,6 +6,10 @@
             Welcome on funkwhale
         </h1>
         <p>We think listening music should be simple.</p>
+        <router-link class="ui icon button" to="/about">
+          <i class="info icon"></i>
+          Learn more about this instance
+        </router-link>
         <router-link class="ui icon teal button" to="/library">
           Get me to the library
           <i class="right arrow icon"></i>
diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue
index 3ac7c59af5817413c13a97e569065ca48cb95b91..83b386fdeb57f72700b97abacf37c4512bcff5e0 100644
--- a/front/src/components/Pagination.vue
+++ b/front/src/components/Pagination.vue
@@ -49,4 +49,8 @@ export default {
 </script>
 
 <style scoped>
+.ui.menu {
+  border: none;
+  box-shadow: none;
+}
 </style>
diff --git a/front/src/components/Raven.vue b/front/src/components/Raven.vue
index e5e125b810ec0cb53e4080d64f3b0c9133305307..f45d0ed22bee60d746599c1c340695cf00b346a6 100644
--- a/front/src/components/Raven.vue
+++ b/front/src/components/Raven.vue
@@ -22,7 +22,6 @@ export default {
       Raven.uninstall()
       logger.default.info('Installing raven...')
       Raven.config(this.dsn).addPlugin(RavenVue, Vue).install()
-      console.log({}.test.test)
     }
   },
   watch: {
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index 4767255ecae8b6bb27a872614f9a4e029146ded9..451cdcf0188a9e213e4d7cb785e9dd926ff23147 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -1,6 +1,6 @@
 <template>
   <div :class="['ui', {'tiny': discrete}, 'buttons']">
-    <button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, 'button']">
+    <button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, {disabled: playableTracks.length === 0}, 'button']">
       <i class="ui play icon"></i>
       <template v-if="!discrete"><slot>Play</slot></template>
     </button>
@@ -36,20 +36,25 @@ export default {
       jQuery(this.$el).find('.ui.dropdown').dropdown()
     }
   },
-  methods: {
-    add () {
+  computed: {
+    playableTracks () {
+      let tracks
       if (this.track) {
-        this.$store.dispatch('queue/append', {track: this.track})
+        tracks = [this.track]
       } else {
-        this.$store.dispatch('queue/appendMany', {tracks: this.tracks})
+        tracks = this.tracks
       }
+      return tracks.filter(e => {
+        return e.files.length > 0
+      })
+    }
+  },
+  methods: {
+    add () {
+      this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks})
     },
     addNext (next) {
-      if (this.track) {
-        this.$store.dispatch('queue/append', {track: this.track, index: this.$store.state.queue.currentIndex + 1})
-      } else {
-        this.$store.dispatch('queue/appendMany', {tracks: this.tracks, index: this.$store.state.queue.currentIndex + 1})
-      }
+      this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1})
       if (next) {
         this.$store.dispatch('queue/next')
       }
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index e44a92d4fe09c054ae2ffae43c780371769f270c..5e9965158c7a50ada134f51774f046344810ddd2 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -232,7 +232,7 @@ export default {
   },
   watch: {
     currentTrack (newValue) {
-      if (!newValue) {
+      if (!newValue || !newValue.album.cover) {
         this.ambiantColors = this.defaultAmbiantColors
       }
     },
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
index a513c468f6792d2013bb25b6b4946a863be667a7..d8dcaff9b393013be60b2cb7e0b82ac0b6329e31 100644
--- a/front/src/components/audio/Track.vue
+++ b/front/src/components/audio/Track.vue
@@ -1,21 +1,20 @@
 <template>
   <audio
     ref="audio"
-    :src="url"
     @error="errored"
-    @progress="updateLoad"
     @loadeddata="loaded"
+    @durationchange="updateDuration"
     @timeupdate="updateProgress"
     @ended="ended"
     preload>
-
+    <source v-for="src in srcs" :src="src.url" :type="src.type">
   </audio>
 </template>
 
 <script>
 import {mapState} from 'vuex'
-import backend from '@/audio/backend'
 import url from '@/utils/url'
+import formats from '@/audio/formats'
 
 // import logger from '@/logging'
 
@@ -34,31 +33,43 @@ export default {
       volume: state => state.player.volume,
       looping: state => state.player.looping
     }),
-    url: function () {
+    srcs: function () {
       let file = this.track.files[0]
       if (!file) {
         this.$store.dispatch('player/trackErrored')
-        return null
+        return []
       }
-      let path = backend.absoluteUrl(file.path)
+      let sources = [
+        {type: file.mimetype, url: file.path}
+      ]
+      formats.formats.forEach(f => {
+        if (f !== file.mimetype) {
+          let format = formats.formatsMap[f]
+          let url = `/api/v1/trackfiles/transcode/?track_file=${file.id}&to=${format}`
+          sources.push({type: f, url: url})
+        }
+      })
       if (this.$store.state.auth.authenticated) {
         // we need to send the token directly in url
         // so authentication can be checked by the backend
         // because for audio files we cannot use the regular Authentication
         // header
-        path = url.updateQueryString(path, 'jwt', this.$store.state.auth.token)
+        sources.forEach(e => {
+          e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token)
+        })
       }
-      return path
+      return sources
     }
   },
   methods: {
     errored: function () {
       this.$store.dispatch('player/trackErrored')
     },
-    updateLoad: function () {
-
+    updateDuration: function (e) {
+      this.$store.commit('player/duration', this.$refs.audio.duration)
     },
     loaded: function () {
+      this.$refs.audio.volume = this.volume
       if (this.isCurrent) {
         this.$store.commit('player/duration', this.$refs.audio.duration)
         if (this.startTime) {
diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue
index 99b439af8b3e25c821ddb1dbc0c86c6419961d2c..2cf6d5f6db1cd8bc4e4d72d96be5c70d559e9452 100644
--- a/front/src/components/auth/Login.vue
+++ b/front/src/components/auth/Login.vue
@@ -12,13 +12,13 @@
             </ul>
           </div>
           <div class="field">
-            <label>Username</label>
+            <label>Username or email</label>
             <input
             ref="username"
             required
             type="text"
             autofocus
-            placeholder="Enter your username"
+            placeholder="Enter your username or email"
             v-model="credentials.username"
             >
           </div>
@@ -32,6 +32,9 @@
             >
           </div>
           <button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Login</button>
+          <router-link class="ui right floated basic button" :to="{path: '/signup'}">
+            Create an account
+          </router-link>
         </form>
       </div>
     </div>
@@ -73,9 +76,9 @@ export default {
       // to properly make use of http in the auth service
       this.$store.dispatch('auth/login', {
         credentials,
-        next: this.next,
-        onError: response => {
-          if (response.status === 400) {
+        next: '/library',
+        onError: error => {
+          if (error.response.status === 400) {
             self.error = 'invalid_credentials'
           } else {
             self.error = 'unknown_error'
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index f090581ef7305927e293e3f6ffce3e6033131004..4e8f33289b470526539e71ff40f6de32a2857dcb 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -37,7 +37,6 @@
 
 <script>
 import axios from 'axios'
-import config from '@/config'
 import logger from '@/logging'
 
 export default {
@@ -61,12 +60,16 @@ export default {
         new_password1: this.new_password,
         new_password2: this.new_password
       }
-      let url = config.BACKEND_URL + 'api/auth/registration/change-password/'
+      let url = 'auth/registration/change-password/'
       return axios.post(url, credentials).then(response => {
         logger.default.info('Password successfully changed')
-        self.$router.push('/profile/me')
-      }, response => {
-        if (response.status === 400) {
+        self.$router.push({
+          name: 'profile',
+          params: {
+            username: self.$store.state.auth.username
+          }})
+      }, error => {
+        if (error.response.status === 400) {
           self.error = 'invalid_credentials'
         } else {
           self.error = 'unknown_error'
diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue
new file mode 100644
index 0000000000000000000000000000000000000000..13b723d201437933d9f6fd46bcaccfb2d316f5ed
--- /dev/null
+++ b/front/src/components/auth/Signup.vue
@@ -0,0 +1,137 @@
+<template>
+  <div class="main pusher">
+    <div class="ui vertical stripe segment">
+      <div class="ui small text container">
+        <h2>Create a funkwhale account</h2>
+        <form
+          v-if="$store.state.instance.settings.users.registration_enabled.value"
+          :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
+          @submit.prevent="submit()">
+          <div v-if="errors.length > 0" class="ui negative message">
+            <div class="header">We cannot create your account</div>
+            <ul class="list">
+              <li v-for="error in errors">{{ error }}</li>
+            </ul>
+          </div>
+          <div class="field">
+            <label>Username</label>
+            <input
+            ref="username"
+            required
+            type="text"
+            autofocus
+            placeholder="Enter your username"
+            v-model="username">
+          </div>
+          <div class="field">
+            <label>Email</label>
+            <input
+            ref="email"
+            required
+            type="email"
+            placeholder="Enter your email"
+            v-model="email">
+          </div>
+          <div class="field">
+            <label>Password</label>
+            <div class="ui action input">
+              <input
+              required
+              :type="passwordInputType"
+              placeholder="Enter your password"
+              v-model="password">
+              <span @click="showPassword = !showPassword" title="Show/hide password" class="ui icon button">
+                <i class="eye icon"></i>
+              </span>
+            </div>
+          </div>
+          <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit">Create my account</button>
+        </form>
+        <p v-else>Registration is currently disabled on this instance, please try again later.</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import logger from '@/logging'
+
+export default {
+  name: 'login',
+  props: {
+    next: {type: String, default: '/'}
+  },
+  data () {
+    return {
+      username: '',
+      email: '',
+      password: '',
+      isLoadingInstanceSetting: true,
+      errors: [],
+      isLoading: false,
+      showPassword: false
+    }
+  },
+  created () {
+    let self = this
+    this.$store.dispatch('instance/fetchSettings', {
+      callback: function () {
+        self.isLoadingInstanceSetting = false
+      }
+    })
+  },
+  methods: {
+    submit () {
+      var self = this
+      self.isLoading = true
+      this.errors = []
+      var payload = {
+        username: this.username,
+        password1: this.password,
+        password2: this.password,
+        email: this.email
+      }
+      return axios.post('auth/registration/', payload).then(response => {
+        logger.default.info('Successfully created account')
+        self.$router.push({
+          name: 'profile',
+          params: {
+            username: this.username
+          }})
+      }, error => {
+        self.errors = this.getErrors(error.response)
+        self.isLoading = false
+      })
+    },
+    getErrors (response) {
+      let errors = []
+      if (response.status !== 400) {
+        errors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
+        return errors
+      }
+      for (var field in response.data) {
+        if (response.data.hasOwnProperty(field)) {
+          response.data[field].forEach(e => {
+            errors.push(e)
+          })
+        }
+      }
+      return errors
+    }
+  },
+  computed: {
+    passwordInputType () {
+      if (this.showPassword) {
+        return 'text'
+      }
+      return 'password'
+    }
+  }
+
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ff6ff5c71efa2df05e1467751235ddb15dd67222
--- /dev/null
+++ b/front/src/components/common/HumanDate.vue
@@ -0,0 +1,8 @@
+<template>
+  <time :datetime="date" :title="date | moment">{{ date | ago }}</time>
+</template>
+<script>
+export default {
+  props: ['date']
+}
+</script>
diff --git a/front/src/components/discussion/Comment.vue b/front/src/components/discussion/Comment.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a3c5176ecb9b908817b72f62f884c6f87d2b9660
--- /dev/null
+++ b/front/src/components/discussion/Comment.vue
@@ -0,0 +1,49 @@
+<template>
+  <div class="comment">
+    <div class="content">
+      <a class="author">{{ user.username }}</a>
+      <div class="metadata">
+        <div class="date"><human-date :date="date"></human-date></div>
+      </div>
+      <div class="text" v-html="comment"></div>
+      </div>
+      <div class="actions">
+        <span
+          @click="collapsed = false"
+          v-if="truncated && collapsed"
+          class="expand">Expand</span>
+        <span
+          @click="collapsed = true"
+          v-if="truncated && !collapsed"
+          class="collapse">Collapse</span>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+  export default {
+    props: {
+      user: {type: Object, required: true},
+      date: {required: true},
+      content: {type: String, required: true}
+    },
+    data () {
+      return {
+        collapsed: true,
+        length: 50
+      }
+    },
+    computed: {
+      comment () {
+        let text = this.content
+        if (this.collapsed) {
+          text = this.$options.filters.truncate(text, this.length)
+        }
+        return this.$options.filters.markdown(text)
+      },
+      truncated () {
+        return this.content.length > this.length
+      }
+    }
+  }
+</script>
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
new file mode 100644
index 0000000000000000000000000000000000000000..40315bc47d8efa9731c7133354181e09bbd6cb59
--- /dev/null
+++ b/front/src/components/globals.js
@@ -0,0 +1,7 @@
+import Vue from 'vue'
+
+import HumanDate from '@/components/common/HumanDate'
+
+Vue.component('human-date', HumanDate)
+
+export default {}
diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue
index cdcbe4b72ee7153d757da33eddb393d1d67a568e..e4e22fc09919f815038795762f408ac0a760aa58 100644
--- a/front/src/components/library/Home.vue
+++ b/front/src/components/library/Home.vue
@@ -4,7 +4,7 @@
       <search :autofocus="true"></search>
     </div>
     <div class="ui vertical stripe segment">
-      <div class="ui stackable two column grid">
+      <div class="ui stackable three column grid">
         <div class="column">
           <h2 class="ui header">Latest artists</h2>
           <div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
@@ -18,6 +18,10 @@
           <radio-card :type="'random'"></radio-card>
           <radio-card :type="'less-listened'"></radio-card>
         </div>
+        <div class="column">
+          <h2 class="ui header">Music requests</h2>
+          <request-form></request-form>
+        </div>
       </div>
     </div>
   </div>
@@ -30,6 +34,7 @@ import backend from '@/audio/backend'
 import logger from '@/logging'
 import ArtistCard from '@/components/audio/artist/Card'
 import RadioCard from '@/components/radios/Card'
+import RequestForm from '@/components/requests/Form'
 
 const ARTISTS_URL = 'artists/'
 
@@ -38,7 +43,8 @@ export default {
   components: {
     Search,
     ArtistCard,
-    RadioCard
+    RadioCard,
+    RequestForm
   },
   data () {
     return {
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index 5fe192022c8e34416259118f262fb87076e98458..6cd156493f4487683df91fd9314f31fb8807de1d 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -5,8 +5,13 @@
       <router-link class="ui item" to="/library/artists" exact>Artists</router-link>
       <router-link class="ui item" to="/library/radios" exact>Radios</router-link>
       <div class="ui secondary right menu">
+        <router-link class="ui item" to="/library/requests/" exact>
+          Requests
+          <div class="ui teal label">{{ requestsCount }}</div>
+        </router-link>
         <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link>
-        <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link>
+        <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches
+        </router-link>
       </div>
     </div>
     <router-view :key="$route.fullPath"></router-view>
@@ -14,9 +19,25 @@
 </template>
 
 <script>
-
+import axios from 'axios'
 export default {
-  name: 'library'
+  name: 'library',
+  data () {
+    return {
+      requestsCount: 0
+    }
+  },
+  created () {
+    this.fetchRequestsCount()
+  },
+  methods: {
+    fetchRequestsCount () {
+      let self = this
+      axios.get('requests/import-requests', {params: {status: 'pending'}}).then(response => {
+        self.requestsCount = response.data.count
+      })
+    }
+  }
 }
 </script>
 
diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue
index 33c6193bd7c0a95afa6e59847dc6056a0ca38145..8b0757dcc9256afa7d09fc4e61eb091668ed7582 100644
--- a/front/src/components/library/import/ImportMixin.vue
+++ b/front/src/components/library/import/ImportMixin.vue
@@ -13,7 +13,8 @@ export default {
     defaultEnabled: {type: Boolean, default: true},
     backends: {type: Array},
     defaultBackendId: {type: String},
-    queryTemplate: {type: String, default: '$artist $title'}
+    queryTemplate: {type: String, default: '$artist $title'},
+    request: {type: Object, required: false}
   },
   data () {
     return {
@@ -32,6 +33,9 @@ export default {
       this.isImporting = true
       let url = 'submit/' + self.importType + '/'
       let payload = self.importData
+      if (this.request) {
+        payload.importRequest = this.request.id
+      }
       axios.post(url, payload).then((response) => {
         logger.default.info('launched import for', self.type, self.metadata.id)
         self.isImporting = false
diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue
index 66ba8c66144db35f4d4c17dfcada5e4c89fcc2e4..0a1cc6df9480366a7105166045e39ffbf91249de 100644
--- a/front/src/components/library/import/Main.vue
+++ b/front/src/components/library/import/Main.vue
@@ -92,6 +92,7 @@
           <component
             ref="import"
             v-if="currentSource == 'external'"
+            :request="currentRequest"
             :metadata="metadata"
             :is="importComponent"
             :backends="backends"
@@ -113,7 +114,10 @@
         </div>
       </div>
     </div>
-    <div class="ui vertical stripe segment">
+    <div class="ui vertical stripe segment" v-if="currentRequest">
+      <h3 class="ui header">Music request</h3>
+      <p>This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.</p>
+      <request-card :request="currentRequest" :import-action="false"></request-card>
 
     </div>
   </div>
@@ -121,6 +125,7 @@
 
 <script>
 
+import RequestCard from '@/components/requests/Card'
 import MetadataSearch from '@/components/metadata/Search'
 import ReleaseCard from '@/components/metadata/ReleaseCard'
 import ArtistCard from '@/components/metadata/ArtistCard'
@@ -128,6 +133,7 @@ import ReleaseImport from './ReleaseImport'
 import FileUpload from './FileUpload'
 import ArtistImport from './ArtistImport'
 
+import axios from 'axios'
 import router from '@/router'
 import $ from 'jquery'
 
@@ -138,19 +144,22 @@ export default {
     ReleaseCard,
     ArtistImport,
     ReleaseImport,
-    FileUpload
+    FileUpload,
+    RequestCard
   },
   props: {
     mbType: {type: String, required: false},
+    request: {type: String, required: false},
     source: {type: String, required: false},
     mbId: {type: String, required: false}
   },
   data: function () {
     return {
+      currentRequest: null,
       currentType: this.mbType || 'artist',
       currentId: this.mbId,
       currentStep: 0,
-      currentSource: '',
+      currentSource: this.source,
       metadata: {},
       isImporting: false,
       importData: {
@@ -166,6 +175,9 @@ export default {
     }
   },
   created () {
+    if (this.request) {
+      this.fetchRequest(this.request)
+    }
     if (this.currentSource) {
       this.currentStep = 1
     }
@@ -179,7 +191,8 @@ export default {
         query: {
           source: this.currentSource,
           type: this.currentType,
-          id: this.currentId
+          id: this.currentId,
+          request: this.request
         }
       })
     },
@@ -197,6 +210,12 @@ export default {
     },
     updateId (newValue) {
       this.currentId = newValue
+    },
+    fetchRequest (id) {
+      let self = this
+      axios.get(`requests/import-requests/${id}`).then((response) => {
+        self.currentRequest = response.data
+      })
     }
   },
   computed: {
diff --git a/front/src/components/requests/Card.vue b/front/src/components/requests/Card.vue
new file mode 100644
index 0000000000000000000000000000000000000000..deb9c3fe093a58cc6913805ad4fcbe9bed4da58d
--- /dev/null
+++ b/front/src/components/requests/Card.vue
@@ -0,0 +1,61 @@
+<template>
+  <div :class="['ui', {collapsed: collapsed}, 'card']">
+    <div class="content">
+      <div class="header">{{ request.artist_name }}</div>
+      <div class="description">
+        <div
+          v-if="request.albums" v-html="$options.filters.markdown(request.albums)"></div>
+        <div v-if="request.comment" class="ui comments">
+          <comment
+            :user="request.user"
+            :content="request.comment"
+            :date="request.creation_date"></comment>
+        </div>
+      </div>
+    </div>
+    <div class="extra content">
+      <span >
+        <i v-if="request.status === 'pending'" class="hourglass start icon"></i>
+        <i v-if="request.status === 'accepted'" class="hourglass half icon"></i>
+        <i v-if="request.status === 'imported'" class="check icon"></i>
+        {{ request.status | capitalize }}
+      </span>
+      <button
+        @click="createImport"
+        v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']"
+        class="ui mini basic green right floated button">Create import</button>
+      
+    </div>
+  </div>
+</template>
+
+<script>
+import Comment from '@/components/discussion/Comment'
+
+export default {
+  props: {
+    request: {type: Object, required: true},
+    importAction: {type: Boolean, default: true}
+  },
+  components: {
+    Comment
+  },
+  data () {
+    return {
+      collapsed: true
+    }
+  },
+  methods: {
+    createImport () {
+      this.$router.push({
+        name: 'library.import.launch',
+        query: {request: this.request.id}})
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+
+</style>
diff --git a/front/src/components/requests/Form.vue b/front/src/components/requests/Form.vue
new file mode 100644
index 0000000000000000000000000000000000000000..68c725ba7adfe903c3ecdf971067b1abb2d5baef
--- /dev/null
+++ b/front/src/components/requests/Form.vue
@@ -0,0 +1,115 @@
+<template>
+  <div>
+    <form v-if="!over" class="ui form" @submit.prevent="submit">
+      <p>Something's missing in the library? Let us know what you would like to listen!</p>
+      <div class="required field">
+        <label>Artist name</label>
+        <input v-model="currentArtistName" placeholder="The Beatles, Mickael Jackson…" required maxlength="200">
+      </div>
+      <div class="field">
+        <label>Albums</label>
+        <p>Leave this field empty if you're requesting the whole discography.</p>
+        <input v-model="currentAlbums" placeholder="The White Album, Thriller…" maxlength="2000">
+      </div>
+      <div class="field">
+        <label>Comment</label>
+        <textarea v-model="currentComment" rows="3" placeholder="Use this comment box to add details to your request if needed" maxlength="2000"></textarea>
+      </div>
+      <button class="ui submit button" type="submit">Submit</button>
+    </form>
+    <div v-else class="ui success message">
+      <div class="header">Request submitted!</div>
+      <p>We've received your request, you'll get some groove soon ;)</p>
+      <button @click="reset" class="ui button">Submit another request</button>
+    </div>
+    <div v-if="requests.length > 0">
+      <div class="ui divider"></div>
+      <h3 class="ui header">Pending requests</h3>
+      <div class="ui list">
+        <div v-for="request in requests" class="item">
+          <div class="content">
+            <div class="header">{{ request.artist_name }}</div>
+            <div v-if="request.albums" class="description">
+              {{ request.albums|truncate }}</div>
+            <div v-if="request.comment" class="description">
+              {{ request.comment|truncate }}</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import $ from 'jquery'
+import axios from 'axios'
+
+import logger from '@/logging'
+
+export default {
+  props: {
+    defaultArtistName: {type: String, default: ''},
+    defaultAlbums: {type: String, default: ''},
+    defaultComment: {type: String, default: ''}
+  },
+  created () {
+    this.fetchRequests()
+  },
+  mounted () {
+    $('.ui.radio.checkbox').checkbox()
+  },
+  data () {
+    return {
+      currentArtistName: this.defaultArtistName,
+      currentAlbums: this.defaultAlbums,
+      currentComment: this.defaultComment,
+      isLoading: false,
+      over: false,
+      requests: []
+    }
+  },
+  methods: {
+    fetchRequests () {
+      let self = this
+      let url = 'requests/import-requests/'
+      axios.get(url, {}).then((response) => {
+        self.requests = response.data.results
+      })
+    },
+    submit () {
+      let self = this
+      this.isLoading = true
+      let url = 'requests/import-requests/'
+      let payload = {
+        artist_name: this.currentArtistName,
+        albums: this.currentAlbums,
+        comment: this.currentComment
+      }
+      axios.post(url, payload).then((response) => {
+        logger.default.info('Submitted request!')
+        self.isLoading = false
+        self.over = true
+        self.requests.unshift(response.data)
+      }, (response) => {
+        logger.default.error('error while submitting request')
+        self.isLoading = false
+      })
+    },
+    reset () {
+      this.over = false
+      this.currentArtistName = ''
+      this.currentAlbums = ''
+      this.currentComment = ''
+    },
+    truncate (string, length) {
+      if (string.length > length) {
+        return string.substring(0, length) + '…'
+      }
+      return string
+    }
+  }
+}
+</script>
+
+<style scoped>
+</style>
diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..cb3e9af00bc40d0301b23dcb446576a567e7f153
--- /dev/null
+++ b/front/src/components/requests/RequestsList.vue
@@ -0,0 +1,163 @@
+<template>
+  <div>
+    <div class="ui vertical stripe segment">
+      <h2 class="ui header">Music requests</h2>
+      <div :class="['ui', {'loading': isLoading}, 'form']">
+        <div class="fields">
+          <div class="field">
+            <label>Search</label>
+            <input type="text" v-model="query" placeholder="Enter an artist name, a username..."/>
+          </div>
+          <div class="field">
+            <label>Ordering</label>
+            <select class="ui dropdown" v-model="ordering">
+              <option v-for="option in orderingOptions" :value="option[0]">
+                {{ option[1] }}
+              </option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Ordering direction</label>
+            <select class="ui dropdown" v-model="orderingDirection">
+              <option value="">Ascending</option>
+              <option value="-">Descending</option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Results per page</label>
+            <select class="ui dropdown" v-model="paginateBy">
+              <option :value="parseInt(12)">12</option>
+              <option :value="parseInt(25)">25</option>
+              <option :value="parseInt(50)">50</option>
+            </select>
+          </div>
+        </div>
+      </div>
+      <div class="ui hidden divider"></div>
+      <div v-if="result" class="ui stackable three column grid">
+        <div
+          v-if="result.results.length > 0"
+          v-for="request in result.results"
+          :key="request.id"
+          class="column">
+          <request-card class="fluid" :request="request"></request-card>
+        </div>
+      </div>
+      <div class="ui center aligned basic segment">
+        <pagination
+          v-if="result && result.results.length > 0"
+          @page-changed="selectPage"
+          :current="page"
+          :paginate-by="paginateBy"
+          :total="result.count"
+          ></pagination>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import _ from 'lodash'
+import $ from 'jquery'
+
+import logger from '@/logging'
+
+import OrderingMixin from '@/components/mixins/Ordering'
+import PaginationMixin from '@/components/mixins/Pagination'
+import RequestCard from '@/components/requests/Card'
+import Pagination from '@/components/Pagination'
+
+const FETCH_URL = 'requests/import-requests/'
+
+export default {
+  mixins: [OrderingMixin, PaginationMixin],
+  props: {
+    defaultQuery: {type: String, required: false, default: ''}
+  },
+  components: {
+    RequestCard,
+    Pagination
+  },
+  data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
+    return {
+      isLoading: true,
+      result: null,
+      page: parseInt(this.defaultPage),
+      query: this.defaultQuery,
+      paginateBy: parseInt(this.defaultPaginateBy || 12),
+      orderingDirection: defaultOrdering.direction,
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['creation_date', 'Creation date'],
+        ['artist_name', 'Artist name'],
+        ['user__username', 'User']
+      ]
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  mounted () {
+    $('.ui.dropdown').dropdown()
+  },
+  methods: {
+    updateQueryString: _.debounce(function () {
+      this.$router.replace({
+        query: {
+          query: this.query,
+          page: this.page,
+          paginateBy: this.paginateBy,
+          ordering: this.getOrderingAsString()
+        }
+      })
+    }, 500),
+    fetchData: _.debounce(function () {
+      var self = this
+      this.isLoading = true
+      let url = FETCH_URL
+      let params = {
+        page: this.page,
+        page_size: this.paginateBy,
+        search: this.query,
+        ordering: this.getOrderingAsString()
+      }
+      logger.default.debug('Fetching request...')
+      axios.get(url, {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+      })
+    }, 500),
+    selectPage: function (page) {
+      this.page = page
+    }
+  },
+  watch: {
+    page () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    paginateBy () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    ordering () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.updateQueryString()
+      this.fetchData()
+    },
+    query () {
+      this.updateQueryString()
+      this.fetchData()
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/filters.js b/front/src/filters.js
new file mode 100644
index 0000000000000000000000000000000000000000..22d93149bb402657e56d37d9ef68cf2959d9c767
--- /dev/null
+++ b/front/src/filters.js
@@ -0,0 +1,44 @@
+import Vue from 'vue'
+
+import moment from 'moment'
+import showdown from 'showdown'
+
+export function truncate (str, max, ellipsis) {
+  max = max || 100
+  ellipsis = ellipsis || '…'
+  if (str.length <= max) {
+    return str
+  }
+  return str.slice(0, max) + ellipsis
+}
+
+Vue.filter('truncate', truncate)
+
+export function markdown (str) {
+  const converter = new showdown.Converter()
+  return converter.makeHtml(str)
+}
+
+Vue.filter('markdown', markdown)
+
+export function ago (date) {
+  const m = moment(date)
+  return m.fromNow()
+}
+
+Vue.filter('ago', ago)
+
+export function momentFormat (date, format) {
+  format = format || 'lll'
+  return moment(date).format(format)
+}
+
+Vue.filter('moment', momentFormat)
+
+export function capitalize (str) {
+  return str.charAt(0).toUpperCase() + str.slice(1)
+}
+
+Vue.filter('capitalize', capitalize)
+
+export default {}
diff --git a/front/src/main.js b/front/src/main.js
index d1ff90c3256846b65848021e1abf0f0b3fb19bc7..2e351310a15e218d1e9438c12d7c8e8ba1b37426 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -13,6 +13,8 @@ import VueLazyload from 'vue-lazyload'
 import store from './store'
 import config from './config'
 import { sync } from 'vuex-router-sync'
+import filters from '@/filters' // eslint-disable-line
+import globals from '@/components/globals' // eslint-disable-line
 
 sync(store, router)
 
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 971ef05cd82f034b9513716a42f35b02a5291054..827afc21823687dc266a69c601435015757a9045 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -1,8 +1,10 @@
 import Vue from 'vue'
 import Router from 'vue-router'
 import PageNotFound from '@/components/PageNotFound'
+import About from '@/components/About'
 import Home from '@/components/Home'
 import Login from '@/components/auth/Login'
+import Signup from '@/components/auth/Signup'
 import Profile from '@/components/auth/Profile'
 import Settings from '@/components/auth/Settings'
 import Logout from '@/components/auth/Logout'
@@ -17,6 +19,7 @@ import LibraryRadios from '@/components/library/Radios'
 import RadioBuilder from '@/components/library/radios/Builder'
 import BatchList from '@/components/library/import/BatchList'
 import BatchDetail from '@/components/library/import/BatchDetail'
+import RequestsList from '@/components/requests/RequestsList'
 
 import Favorites from '@/components/favorites/List'
 
@@ -31,12 +34,22 @@ export default new Router({
       name: 'index',
       component: Home
     },
+    {
+      path: '/about',
+      name: 'about',
+      component: About
+    },
     {
       path: '/login',
       name: 'login',
       component: Login,
       props: (route) => ({ next: route.query.next || '/library' })
     },
+    {
+      path: '/signup',
+      name: 'signup',
+      component: Signup
+    },
     {
       path: '/logout',
       name: 'logout',
@@ -98,7 +111,11 @@ export default new Router({
           path: 'import/launch',
           name: 'library.import.launch',
           component: LibraryImport,
-          props: (route) => ({ mbType: route.query.type, mbId: route.query.id })
+          props: (route) => ({
+            source: route.query.source,
+            request: route.query.request,
+            mbType: route.query.type,
+            mbId: route.query.id })
         },
         {
           path: 'import/batches',
@@ -107,7 +124,21 @@ export default new Router({
           children: [
           ]
         },
-        { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
+        { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true },
+        {
+          path: 'requests/',
+          name: 'library.requests',
+          component: RequestsList,
+          props: (route) => ({
+            defaultOrdering: route.query.ordering,
+            defaultQuery: route.query.query,
+            defaultPaginateBy: route.query.paginateBy,
+            defaultPage: route.query.page,
+            defaultStatus: route.query.status || 'pending'
+          }),
+          children: [
+          ]
+        }
       ]
     },
     { path: '*', component: PageNotFound }
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index a0071f0961d6536f2133331787d90245a6dc1df4..a4dfcada65028f27a7fd4c61741b1f14d43d6dcd 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -6,6 +6,22 @@ export default {
   namespaced: true,
   state: {
     settings: {
+      instance: {
+        name: {
+          value: ''
+        },
+        short_description: {
+          value: ''
+        },
+        long_description: {
+          value: ''
+        }
+      },
+      users: {
+        registration_enabled: {
+          value: true
+        }
+      },
       raven: {
         front_enabled: {
           value: false
@@ -23,7 +39,7 @@ export default {
   },
   actions: {
     // Send a request to the login URL and save the returned JWT
-    fetchSettings ({commit}) {
+    fetchSettings ({commit}, payload) {
       return axios.get('instance/settings/').then(response => {
         logger.default.info('Successfully fetched instance settings')
         let sections = {}
@@ -34,6 +50,9 @@ export default {
           sections[e.section][e.name] = e
         })
         commit('settings', sections)
+        if (payload && payload.callback) {
+          payload.callback()
+        }
       }, response => {
         logger.default.error('Error while fetching settings', response.data)
       })
diff --git a/front/src/store/player.js b/front/src/store/player.js
index fb348042fa107bdef406406bf67aac596da3d4b9..df8d159f40b4fe3287f9f6252df0ecfdb9023830 100644
--- a/front/src/store/player.js
+++ b/front/src/store/player.js
@@ -50,7 +50,12 @@ export default {
   },
   getters: {
     durationFormatted: state => {
-      return time.parse(Math.round(state.duration))
+      let duration = parseInt(state.duration)
+      if (duration % 1 !== 0) {
+        return time.parse(0)
+      }
+      duration = Math.round(state.duration)
+      return time.parse(duration)
     },
     currentTimeFormatted: state => {
       return time.parse(Math.round(state.currentTime))
diff --git a/front/test/unit/specs/filters/filters.spec.js b/front/test/unit/specs/filters/filters.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c2b43da44a83eb0a983d981c7891853a634fb31a
--- /dev/null
+++ b/front/test/unit/specs/filters/filters.spec.js
@@ -0,0 +1,42 @@
+import {truncate, markdown, ago, capitalize} from '@/filters'
+
+describe('filters', () => {
+  describe('truncate', () => {
+    it('leave strings as it if correct size', () => {
+      const input = 'Hello world'
+      let output = truncate(input, 100)
+      expect(output).to.equal(input)
+    })
+    it('returns shorter string with character', () => {
+      const input = 'Hello world'
+      let output = truncate(input, 5)
+      expect(output).to.equal('Hello…')
+    })
+    it('custom ellipsis', () => {
+      const input = 'Hello world'
+      let output = truncate(input, 5, ' pouet')
+      expect(output).to.equal('Hello pouet')
+    })
+  })
+  describe('markdown', () => {
+    it('renders markdown', () => {
+      const input = 'Hello world'
+      let output = markdown(input)
+      expect(output).to.equal('<p>Hello world</p>')
+    })
+  })
+  describe('ago', () => {
+    it('works', () => {
+      const input = new Date()
+      let output = ago(input)
+      expect(output).to.equal('a few seconds ago')
+    })
+  })
+  describe('capitalize', () => {
+    it('works', () => {
+      const input = 'hello world'
+      let output = capitalize(input)
+      expect(output).to.equal('Hello world')
+    })
+  })
+})