diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e4accd722d66823a067fe8f2c1212746bc18b076..38492f61454b3e4503bbe98b7b284c0182d0a1d0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -16,13 +16,15 @@ test_api:
   stage: test
   image: funkwhale/funkwhale:latest
   cache:
-    key: "$CI_PROJECT_ID/pip_cache"
+    key: "$CI_PROJECT_ID__pip_cache"
     paths:
       - "$PIP_CACHE_DIR"
   variables:
     DJANGO_ALLOWED_HOSTS: "localhost"
     DATABASE_URL: "postgresql://postgres@postgres/postgres"
     FUNKWHALE_URL: "https://funkwhale.ci"
+    CACHEOPS_ENABLED: "false"
+
   before_script:
     - cd api
     - pip install -r requirements/base.txt
@@ -44,7 +46,7 @@ test_front:
     - yarn install
     - yarn run unit
   cache:
-    key: "$CI_PROJECT_ID/front_dependencies"
+    key: "$CI_PROJECT_ID__front_dependencies"
     paths:
       - front/node_modules
       - front/yarn.lock
@@ -66,7 +68,7 @@ build_front:
     - yarn install
     - yarn run build
   cache:
-    key: "$CI_PROJECT_ID/front_dependencies"
+    key: "$CI_PROJECT_ID__front_dependencies"
     paths:
       - front/node_modules
       - front/yarn.lock
@@ -84,15 +86,12 @@ build_front:
 
 pages:
   stage: test
-  image: alpine
+  image: python:3.6-alpine
   before_script:
     - cd docs
   script:
-    - apk --no-cache add py2-pip python-dev
     - pip install sphinx
-    - apk --no-cache add make
-    - make html
-    - mv _build/html/ ../public
+    - python -m sphinx . ../public
   artifacts:
     paths:
       - public
diff --git a/CHANGELOG b/CHANGELOG
index 6881b7bf0fbd38b8fc2073bd298cfa7e4d0ec97c..0b91987235f620475b00f26af87787c43670913b 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,7 +3,37 @@ Changelog
 
 .. towncrier
 
-0.6.1 (unreleased)
+0.7 (2018-03-21)
+----------------
+
+Features:
+
+- Can now filter artists and albums with no listenable tracks (#114)
+- Improve the style of the sidebar to make it easier to understand which tab is
+  selected (#118)
+- On artist page, albums are not sorted by release date, if any (#116)
+- Playlists are here \o/ :tada: (#3, #93, #94)
+- Use django-cacheops to cache common ORM requests (#117)
+
+
+Bugfixes:
+
+- Fixed broken import request admin (#115)
+- Fixed forced redirection to login event with
+  API_AUTHENTICATION_REQUIRED=False (#119)
+- Fixed position not being reseted properly when playing the same track
+  multiple times in a row
+- Fixed synchronized start/stop radio buttons for all custom radios (#103)
+- Fixed typo and missing icon on homepage (#96)
+
+
+Documentation:
+
+- Up-to-date and complete development and contribution instructions in
+  README.rst (#123)
+
+
+0.6.1 (2018-03-06)
 ------------------
 
 Features:
diff --git a/README.rst b/README.rst
index 1cec0a6b70b2962c75e19e492d3300f0db7ea498..93281d26fb6abd65d0590183a416236a585b68e9 100644
--- a/README.rst
+++ b/README.rst
@@ -5,27 +5,107 @@ A self-hosted tribute to Grooveshark.com.
 
 LICENSE: BSD
 
-Setting up a development environment (docker)
-----------------------------------------------
+Getting help
+------------
 
-First of all, pull the repository.
+We offer various Matrix.org rooms to discuss about funkwhale:
 
-Then, pull and build all the containers::
+- `#funkwhale:matrix.org <https://riot.im/app/#/room/#funkwhale:matrix.org>`_ for general questions about funkwhale
+- `#funkwhale-dev:matrix.org <https://riot.im/app/#/room/#funkwhale-dev:matrix.org>`_ for development-focused discussion
+
+Please join those rooms if you have any questions!
+
+Running the development version
+-------------------------------
+
+If you want to fix a bug or implement a feature, you'll need
+to run a local, development copy of funkwhale.
+
+We provide a docker based development environment, which should
+be both easy to setup and work similarly regardless of your
+development machine setup.
+
+Instructions for bare-metal setup will come in the future (Merge requests
+are welcome).
+
+Installing docker and docker-compose
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This is already cover in the relevant documentations:
+
+- https://docs.docker.com/install/
+- https://docs.docker.com/compose/install/
+
+Cloning the project
+^^^^^^^^^^^^^^^^^^^
+
+Visit https://code.eliotberriot.com/funkwhale/funkwhale and clone the repository using SSH or HTTPS. Exemple using SSH::
+
+    git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
+    cd funkwhale
+
+
+A note about branches
+^^^^^^^^^^^^^^^^^^^^^
+
+Next release development occurs on the "develop" branch, and releases are made on the "master" branch. Therefor, when submitting Merge Requests, ensure you are merging on the develop branch.
+
+
+Working with docker
+^^^^^^^^^^^^^^^^^^^
+
+In developpement, we use the docker-compose file named ``dev.yml``, and this is why all our docker-compose commands will look like this::
+
+    docker-compose -f dev.yml logs
+
+If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this command before starting your work::
+
+    export COMPOSE_FILE=dev.yml
+
+
+Building the containers
+^^^^^^^^^^^^^^^^^^^^^^^
+
+On your initial clone, or if there have been some changes in the
+app dependencies, you will have to rebuild your containers. This is done
+via the following command::
 
     docker-compose -f dev.yml build
-    docker-compose -f dev.yml pull
 
 
-API setup
-^^^^^^^^^^
+Database management
+^^^^^^^^^^^^^^^^^^^
+
+To setup funkwhale's database schema, run this::
+
+    docker-compose -f dev.yml run --rm api python manage.py migrate
+
+This will create all the tables needed for the API to run proprely.
+You will also need to run this whenever changes are made on the database
+schema.
+
+It is safe to run this command multiple times, so you can run it whenever
+you fetch develop.
+
 
-You'll have apply database migrations::
+Development data
+^^^^^^^^^^^^^^^^
 
-    docker-compose -f dev.yml run celeryworker python manage.py migrate
+You'll need at least an admin user and some artists/tracks/albums to work
+locally.
 
-And to create an admin user::
+Create an admin user with the following command::
 
-    docker-compose -f dev.yml run celeryworker python manage.py createsuperuser
+    docker-compose -f dev.yml run --rm api python manage.py createsuperuser
+
+Injecting fake data is done by running the fllowing script::
+
+    artists=25
+    command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)"
+    echo $command | docker-compose -f dev.yml run --rm api python manage.py shell -i python
+
+The previous command will create 25 artists with random albums, tracks
+and metadata.
 
 
 Launch all services
@@ -33,18 +113,83 @@ Launch all services
 
 Then you can run everything with::
 
-    docker-compose up
+    docker-compose -f dev.yml up
+
+This will launch all services, and output the logs in your current terminal window.
+If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``.
+
+Once everything is up, you can access the various funkwhale's components:
+
+- The Vue webapp, on http://localhost:8080
+- The API, on http://localhost:8080/api/v1/
+- The django admin, on http://localhost:8080/api/admin/
 
-The API server will be accessible at http://localhost:6001, and the front-end at http://localhost:8080.
 
 Running API tests
-------------------
+^^^^^^^^^^^^^^^^^
+
+To run the pytest test suite, use the following command::
+
+    docker-compose -f dev.yml run --rm api pytest
+
+This is regular pytest, so you can use any arguments/options that pytest usually accept::
+
+    # get some help
+    docker-compose -f dev.yml run --rm api pytest -h
+    # Stop on first failure
+    docker-compose -f dev.yml run --rm api pytest -x
+    # Run a specific test file
+    docker-compose -f dev.yml run --rm api pytest tests/test_acoustid.py
+
+
+Running front-end tests
+^^^^^^^^^^^^^^^^^^^^^^^
+
+To run the front-end test suite, use the following command::
+
+    docker-compose -f dev.yml run --rm front yarn run unit
+
+We also support a "watch and test" mode were we continually relaunch
+tests when changes are recorded on the file system::
+
+    docker-compose -f dev.yml run --rm front yarn run unit-watch
+
+The latter is especially useful when you are debugging failing tests.
+
+.. note::
+
+    The front-end test suite coverage is still pretty low
+
+
+Stopping everything
+^^^^^^^^^^^^^^^^^^^
+
+Once you're down with your work, you can stop running containers, if any, with::
+
+    docker-compose -f dev.yml stop
+
+
+Removing everything
+^^^^^^^^^^^^^^^^^^^
+
+If you want to wipe your development environment completely (e.g. if you want to start over from scratch), just run::
+
+    docker-compose -f dev.yml down -v
+
+This will wipe your containers and data, so please be careful before running it.
 
-Everything is managed using docker and docker-compose, just run::
+You can keep your data by removing the ``-v`` flag.
 
-    ./api/runtests
 
-This bash script invoke `python manage.py test` in a docker container under the hood, so you can use
-traditional django test arguments and options, such as::
+Typical workflow for a merge request
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-    ./api/runtests funkwhale_api.music   # run a specific app test
+0. Fork the project if you did not already or if you do not have access to the main repository
+1. Checkout the development branch and pull most recent changes: ``git checkout develop && git pull``
+2. Create a dedicated branch for your work ``42-awesome-fix``. It is good practice to prefix your branch name with the ID of the issue you are solving.
+3. Work on your stuff
+4. Commit small, atomic changes to make it easier to review your contribution
+5. Add a changelog fragment to summarize your changes: ``echo "Implemented awesome stuff (#42)" > changes/changelog.d/42.feature"``
+6. Push your branch
+7. Create your merge request
+8. Take a step back and enjoy, we're really grateful you did all of this and took the time to contribute!
diff --git a/api/compose/django/dev-entrypoint.sh b/api/compose/django/dev-entrypoint.sh
new file mode 100755
index 0000000000000000000000000000000000000000..416207b43d66d6ec7d05b12dd508c33577b8afda
--- /dev/null
+++ b/api/compose/django/dev-entrypoint.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+set -e
+if [ $1 = "pytest" ]; then
+    # let pytest.ini handle it
+    unset DJANGO_SETTINGS_MODULE
+fi
+exec "$@"
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 1def688241366373a3d495c21358b4bd0a76e7bf..077566d1c6a82e329f334a7fe94764cafbd92a70 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -57,9 +57,9 @@ THIRD_PARTY_APPS = (
     'taggit',
     'rest_auth',
     'rest_auth.registration',
-    'mptt',
     'dynamic_preferences',
     'django_filters',
+    'cacheops',
 )
 
 
@@ -369,7 +369,19 @@ MUSICBRAINZ_CACHE_DURATION = env.int(
     'MUSICBRAINZ_CACHE_DURATION',
     default=300
 )
+CACHEOPS_REDIS = env('CACHE_URL', default=CACHE_DEFAULT)
+CACHEOPS_ENABLED = env.bool('CACHEOPS_ENABLED', default=True)
+CACHEOPS = {
+    'music.artist': {'ops': 'all', 'timeout': 60 * 60},
+    'music.album': {'ops': 'all', 'timeout': 60 * 60},
+    'music.track': {'ops': 'all', 'timeout': 60 * 60},
+    'music.trackfile': {'ops': 'all', 'timeout': 60 * 60},
+    'taggit.tag': {'ops': 'all', 'timeout': 60 * 60},
+}
 
 # Custom Admin URL, use {% url 'admin:index' %}
 ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
 CSRF_USE_SESSIONS = True
+
+# Playlist settings
+PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
diff --git a/api/config/settings/test.py b/api/config/settings/test.py
index a0b6b2503a1762295c960cae50c06c65c50a4d76..aff29c6571252d1f0d401c550cedf09998a5c512 100644
--- a/api/config/settings/test.py
+++ b/api/config/settings/test.py
@@ -19,10 +19,6 @@ CACHES = {
 
 CELERY_BROKER_URL = 'memory://'
 
-# TESTING
-# ------------------------------------------------------------------------------
-TEST_RUNNER = 'django.test.runner.DiscoverRunner'
-
 ########## CELERY
 # In development, all tasks will be executed locally by blocking until the task returns
 CELERY_TASK_ALWAYS_EAGER = True
@@ -30,3 +26,4 @@ CELERY_TASK_ALWAYS_EAGER = True
 
 # Your local stuff: Below this line define 3rd party library settings
 API_AUTHENTICATION_REQUIRED = False
+CACHEOPS_ENABLED = False
diff --git a/api/docker-compose.yml b/api/docker-compose.yml
deleted file mode 100644
index 4f79eb301b1241c91573c7e4d34d70686f82e7c2..0000000000000000000000000000000000000000
--- a/api/docker-compose.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-version: '2'
-services:
-    postgres:
-      image: postgres:9.5
-
-    api:
-      build: .
-      links:
-        - postgres
-        - redis
-      command: ./compose/django/gunicorn.sh
-      env_file: .env
-      volumes:
-        - ./media:/app/funkwhale_api/media
-        - ./staticfiles:/app/staticfiles
-        - ./music:/music
-      ports:
-        - "127.0.0.1:6001:5000"
-
-    redis:
-      image: redis:3.0
-
-    celeryworker:
-      build: .
-      env_file: .env
-      links:
-       - postgres
-       - redis
-      command: celery -A funkwhale_api.taskapp worker -l INFO
-      volumes:
-        - ./media:/app/funkwhale_api/media
-        - ./music:/music
-      environment:
-        - C_FORCE_ROOT=True
-
-    celerybeat:
-      build: .
-      env_file: .env
-      links:
-        - postgres
-        - redis
-      command: celery -A funkwhale_api.taskapp beat -l INFO
diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test
index 00638e9dd3cb5d290d336698a842f826d73583e9..0990efa512e6a1084249e331e4146f8a223332f5 100644
--- a/api/docker/Dockerfile.test
+++ b/api/docker/Dockerfile.test
@@ -23,3 +23,4 @@ RUN pip install -r /requirements/test.txt
 
 COPY . /app
 WORKDIR /app
+ENTRYPOINT ["compose/django/dev-entrypoint.sh"]
diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py
index 841f2a299128d2b5f5c6ac52dc87a67fefd0954a..0a12b794efb05892fa6fca0bff82b192e936f108 100644
--- a/api/funkwhale_api/__init__.py
+++ b/api/funkwhale_api/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
-__version__ = '0.6.1'
+__version__ = '0.7'
 __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef9f840dc763409c8a1555d693d1939030877fd5
--- /dev/null
+++ b/api/funkwhale_api/common/fields.py
@@ -0,0 +1,27 @@
+from django.db import models
+
+
+PRIVACY_LEVEL_CHOICES = [
+    ('me', 'Only me'),
+    ('followers', 'Me and my followers'),
+    ('instance', 'Everyone on my instance, and my followers'),
+    ('everyone', 'Everyone, including people on other instances'),
+]
+
+
+def get_privacy_field():
+    return models.CharField(
+        max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
+
+
+def privacy_level_query(user, lookup_field='privacy_level'):
+    if user.is_anonymous:
+        return models.Q(**{
+            lookup_field: 'everyone',
+        })
+
+    return models.Q(**{
+        '{}__in'.format(lookup_field): [
+            'me', 'followers', 'instance', 'everyone'
+        ]
+    })
diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py
index ecfea4c9a51582031a69c8592d4c2592cb2988e6..c99c275c1f636b292c71ab1abb427daa658566fb 100644
--- a/api/funkwhale_api/common/permissions.py
+++ b/api/funkwhale_api/common/permissions.py
@@ -1,4 +1,7 @@
+import operator
+
 from django.conf import settings
+from django.http import Http404
 
 from rest_framework.permissions import BasePermission, DjangoModelPermissions
 
@@ -20,3 +23,39 @@ class HasModelPermission(DjangoModelPermissions):
     """
     def get_required_permissions(self, method, model_cls):
         return super().get_required_permissions(method, self.model)
+
+
+class OwnerPermission(BasePermission):
+    """
+    Ensure the request user is the owner of the object.
+
+    Usage:
+
+    class MyView(APIView):
+        model = MyModel
+        permission_classes = [OwnerPermission]
+        owner_field = 'owner'
+        owner_checks = ['read', 'write']
+    """
+    perms_map = {
+        'GET': 'read',
+        'OPTIONS': 'read',
+        'HEAD': 'read',
+        'POST': 'write',
+        'PUT': 'write',
+        'PATCH': 'write',
+        'DELETE': 'write',
+    }
+
+    def has_object_permission(self, request, view, obj):
+        method_check = self.perms_map[request.method]
+        owner_checks = getattr(view, 'owner_checks', ['read', 'write'])
+        if method_check not in owner_checks:
+            # check not enabled
+            return True
+
+        owner_field = getattr(view, 'owner_field', 'user')
+        owner = operator.attrgetter(owner_field)(obj)
+        if owner != request.user:
+            raise Http404
+        return True
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index ff937a0f5c3aac8d5609b188d18b796e3b809e3a..fbea3735a267188485bfe236ecbd39ae56ae2e39 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -1,12 +1,36 @@
-import django_filters
+from django.db.models import Count
+
+from django_filters import rest_framework as filters
 
 from . import models
 
 
-class ArtistFilter(django_filters.FilterSet):
+class ListenableMixin(filters.FilterSet):
+    listenable = filters.BooleanFilter(name='_', method='filter_listenable')
+
+    def filter_listenable(self, queryset, name, value):
+        queryset = queryset.annotate(
+            files_count=Count('tracks__files')
+        )
+        if value:
+            return queryset.filter(files_count__gt=0)
+        else:
+            return queryset.filter(files_count=0)
+
+
+class ArtistFilter(ListenableMixin):
 
     class Meta:
         model = models.Artist
         fields = {
-            'name': ['exact', 'iexact', 'startswith', 'icontains']
+            'name': ['exact', 'iexact', 'startswith', 'icontains'],
+            'listenable': 'exact',
         }
+
+
+class AlbumFilter(ListenableMixin):
+    listenable = filters.BooleanFilter(name='_', method='filter_listenable')
+
+    class Meta:
+        model = models.Album
+        fields = ['listenable']
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index d026c9847ca3db7f9920469a225399b0d6c0d3dc..0d33855a6b09060eb5094f8b56aaac9aac641a89 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -54,6 +54,7 @@ class TagViewSetMixin(object):
             queryset = queryset.filter(tags__pk=tag)
         return queryset
 
+
 class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     queryset = (
         models.Artist.objects.all()
@@ -67,6 +68,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     filter_class = filters.ArtistFilter
     ordering_fields = ('id', 'name', 'creation_date')
 
+
 class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     queryset = (
         models.Album.objects.all()
@@ -78,6 +80,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
     permission_classes = [ConditionalAuthentication]
     search_fields = ['title__unaccent']
     ordering_fields = ('creation_date',)
+    filter_class = filters.AlbumFilter
 
 
 class ImportBatchViewSet(
@@ -237,6 +240,7 @@ class TagViewSet(viewsets.ReadOnlyModelViewSet):
 
 class Search(views.APIView):
     max_results = 3
+    permission_classes = [ConditionalAuthentication]
 
     def get(self, request, *args, **kwargs):
         query = request.GET['query']
diff --git a/api/funkwhale_api/playlists/admin.py b/api/funkwhale_api/playlists/admin.py
index b337154c9c6ae3e9d86a22a250ca39c8b97533bd..68e447f3842d61ea392b262db83a70e87d09cd89 100644
--- a/api/funkwhale_api/playlists/admin.py
+++ b/api/funkwhale_api/playlists/admin.py
@@ -5,13 +5,13 @@ from . import models
 
 @admin.register(models.Playlist)
 class PlaylistAdmin(admin.ModelAdmin):
-    list_display = ['name', 'user', 'is_public', 'creation_date']
+    list_display = ['name', 'user', 'privacy_level', 'creation_date']
     search_fields = ['name', ]
     list_select_related = True
 
 
 @admin.register(models.PlaylistTrack)
 class PlaylistTrackAdmin(admin.ModelAdmin):
-    list_display = ['playlist', 'track', 'position', ]
+    list_display = ['playlist', 'track', 'index']
     search_fields = ['track__name', 'playlist__name']
     list_select_related = True
diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py
index 19e4770cfae15fd6d790063026dbdb04770b2e1b..cddea60024bec3f6b02941b0d0ae5335f0322b8f 100644
--- a/api/funkwhale_api/playlists/factories.py
+++ b/api/funkwhale_api/playlists/factories.py
@@ -1,6 +1,7 @@
 import factory
 
 from funkwhale_api.factories import registry
+from funkwhale_api.music.factories import TrackFactory
 from funkwhale_api.users.factories import UserFactory
 
 
@@ -11,3 +12,12 @@ class PlaylistFactory(factory.django.DjangoModelFactory):
 
     class Meta:
         model = 'playlists.Playlist'
+
+
+@registry.register
+class PlaylistTrackFactory(factory.django.DjangoModelFactory):
+    playlist = factory.SubFactory(PlaylistFactory)
+    track = factory.SubFactory(TrackFactory)
+
+    class Meta:
+        model = 'playlists.PlaylistTrack'
diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc49415100a3c5d3d53fb5fcb680195934d538c3
--- /dev/null
+++ b/api/funkwhale_api/playlists/filters.py
@@ -0,0 +1,22 @@
+from django_filters import rest_framework as filters
+
+from funkwhale_api.music import utils
+
+from . import models
+
+
+
+class PlaylistFilter(filters.FilterSet):
+    q = filters.CharFilter(name='_', method='filter_q')
+
+    class Meta:
+        model = models.Playlist
+        fields = {
+            'user': ['exact'],
+            'name': ['exact', 'icontains'],
+            'q': 'exact',
+        }
+
+    def filter_q(self, queryset, name, value):
+        query = utils.get_query(value, ['name', 'user__username'])
+        return queryset.filter(query)
diff --git a/api/funkwhale_api/playlists/migrations/0001_initial.py b/api/funkwhale_api/playlists/migrations/0001_initial.py
index bc97d81227ea49f8b46c0e0344fca6978a57b959..987b2f9cfec9be140658b6f5428d70273333c4b8 100644
--- a/api/funkwhale_api/playlists/migrations/0001_initial.py
+++ b/api/funkwhale_api/playlists/migrations/0001_initial.py
@@ -4,7 +4,6 @@ from __future__ import unicode_literals
 from django.db import migrations, models
 from django.conf import settings
 import django.utils.timezone
-import mptt.fields
 
 
 class Migration(migrations.Migration):
@@ -34,7 +33,7 @@ class Migration(migrations.Migration):
                 ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
                 ('position', models.PositiveIntegerField(db_index=True, editable=False)),
                 ('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)),
-                ('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
+                ('previous', models.OneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
                 ('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
             ],
             options={
diff --git a/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py b/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py
new file mode 100644
index 0000000000000000000000000000000000000000..23d0a8eab6a0d174397ff7c6ea62d2fafa9654a7
--- /dev/null
+++ b/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py
@@ -0,0 +1,22 @@
+# Generated by Django 2.0.3 on 2018-03-16 22:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('playlists', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='playlist',
+            name='is_public',
+        ),
+        migrations.AddField(
+            model_name='playlist',
+            name='privacy_level',
+            field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
+        ),
+    ]
diff --git a/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py b/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py
new file mode 100644
index 0000000000000000000000000000000000000000..0284f8f2cf88f1b04e5a05334b73789fb9233e8c
--- /dev/null
+++ b/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py
@@ -0,0 +1,52 @@
+# Generated by Django 2.0.3 on 2018-03-19 12:14
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('playlists', '0002_auto_20180316_2217'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='playlisttrack',
+            options={'ordering': ('-playlist', 'index')},
+        ),
+        migrations.AddField(
+            model_name='playlisttrack',
+            name='creation_date',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+        ),
+        migrations.AddField(
+            model_name='playlisttrack',
+            name='index',
+            field=models.PositiveIntegerField(null=True),
+        ),
+        migrations.RemoveField(
+            model_name='playlisttrack',
+            name='lft',
+        ),
+        migrations.RemoveField(
+            model_name='playlisttrack',
+            name='position',
+        ),
+        migrations.RemoveField(
+            model_name='playlisttrack',
+            name='previous',
+        ),
+        migrations.RemoveField(
+            model_name='playlisttrack',
+            name='rght',
+        ),
+        migrations.RemoveField(
+            model_name='playlisttrack',
+            name='tree_id',
+        ),
+        migrations.AlterUniqueTogether(
+            name='playlisttrack',
+            unique_together={('playlist', 'index')},
+        ),
+    ]
diff --git a/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py b/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py
new file mode 100644
index 0000000000000000000000000000000000000000..415b53612a43178051a4103a0d415ff2f7583d02
--- /dev/null
+++ b/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py
@@ -0,0 +1,27 @@
+# Generated by Django 2.0.3 on 2018-03-20 17:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('playlists', '0003_auto_20180319_1214'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='playlist',
+            name='modification_date',
+            field=models.DateTimeField(auto_now=True),
+        ),
+        migrations.AlterField(
+            model_name='playlisttrack',
+            name='index',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.AlterUniqueTogether(
+            name='playlisttrack',
+            unique_together=set(),
+        ),
+    ]
diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py
index e89dce81c93d23a4827e2b812e5894abcc948485..6bb8fe17820a20b82b2a4468c2c0421350cfa53d 100644
--- a/api/funkwhale_api/playlists/models.py
+++ b/api/funkwhale_api/playlists/models.py
@@ -1,43 +1,130 @@
+from django.conf import settings
 from django.db import models
+from django.db import transaction
 from django.utils import timezone
 
-from mptt.models import MPTTModel, TreeOneToOneField
+from rest_framework import exceptions
+
+from funkwhale_api.common import fields
 
 
 class Playlist(models.Model):
     name = models.CharField(max_length=50)
-    is_public = models.BooleanField(default=False)
     user = models.ForeignKey(
         'users.User', related_name="playlists", on_delete=models.CASCADE)
     creation_date = models.DateTimeField(default=timezone.now)
+    modification_date = models.DateTimeField(
+        auto_now=True)
+    privacy_level = fields.get_privacy_field()
 
     def __str__(self):
         return self.name
 
-    def add_track(self, track, previous=None):
-        plt = PlaylistTrack(previous=previous, track=track, playlist=self)
-        plt.save()
+    @transaction.atomic
+    def insert(self, plt, index=None):
+        """
+        Given a PlaylistTrack, insert it at the correct index in the playlist,
+        and update other tracks index if necessary.
+        """
+        old_index = plt.index
+        move = old_index is not None
+        if index is not None and index == old_index:
+            # moving at same position, just skip
+            return index
+
+        existing = self.playlist_tracks.select_for_update()
+        if move:
+            existing = existing.exclude(pk=plt.pk)
+        total = existing.filter(index__isnull=False).count()
+
+        if index is None:
+            # we simply increment the last track index by 1
+            index = total
+
+        if index > total:
+            raise exceptions.ValidationError('Index is not continuous')
+
+        if index < 0:
+            raise exceptions.ValidationError('Index must be zero or positive')
+
+        if move:
+            # we remove the index temporarily, to avoid integrity errors
+            plt.index = None
+            plt.save(update_fields=['index'])
+            if index > old_index:
+                # new index is higher than current, we decrement previous tracks
+                to_update = existing.filter(
+                    index__gt=old_index, index__lte=index)
+                to_update.update(index=models.F('index') - 1)
+            if index < old_index:
+                # new index is lower than current, we increment next tracks
+                to_update = existing.filter(index__lt=old_index, index__gte=index)
+                to_update.update(index=models.F('index') + 1)
+        else:
+            to_update = existing.filter(index__gte=index)
+            to_update.update(index=models.F('index') + 1)
 
-        return plt
+        plt.index = index
+        plt.save(update_fields=['index'])
+        self.save(update_fields=['modification_date'])
+        return index
 
+    @transaction.atomic
+    def remove(self, index):
+        existing = self.playlist_tracks.select_for_update()
+        self.save(update_fields=['modification_date'])
+        to_update = existing.filter(index__gt=index)
+        return to_update.update(index=models.F('index') - 1)
 
-class PlaylistTrack(MPTTModel):
+    @transaction.atomic
+    def insert_many(self, tracks):
+        existing = self.playlist_tracks.select_for_update()
+        now = timezone.now()
+        total = existing.filter(index__isnull=False).count()
+        if existing.count() + len(tracks) > settings.PLAYLISTS_MAX_TRACKS:
+            raise exceptions.ValidationError(
+                'Playlist would reach the maximum of {} tracks'.format(
+                    settings.PLAYLISTS_MAX_TRACKS))
+        self.save(update_fields=['modification_date'])
+        start = total
+        plts = [
+            PlaylistTrack(
+                creation_date=now, playlist=self, track=track, index=start+i)
+            for i, track in enumerate(tracks)
+        ]
+        return PlaylistTrack.objects.bulk_create(plts)
+
+class PlaylistTrackQuerySet(models.QuerySet):
+    def for_nested_serialization(self):
+        return (self.select_related()
+                    .select_related('track__album__artist')
+                    .prefetch_related(
+                        'track__tags',
+                        'track__files',
+                        'track__artist__albums__tracks__tags'))
+
+
+class PlaylistTrack(models.Model):
     track = models.ForeignKey(
         'music.Track',
         related_name='playlist_tracks',
         on_delete=models.CASCADE)
-    previous = TreeOneToOneField(
-        'self',
-        blank=True,
-        null=True,
-        related_name='next',
-        on_delete=models.CASCADE)
+    index = models.PositiveIntegerField(null=True, blank=True)
     playlist = models.ForeignKey(
         Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
+    creation_date = models.DateTimeField(default=timezone.now)
 
-    class MPTTMeta:
-        level_attr = 'position'
-        parent_attr = 'previous'
+    objects = PlaylistTrackQuerySet.as_manager()
 
     class Meta:
-        ordering = ('-playlist', 'position')
+        ordering = ('-playlist', 'index')
+        unique_together = ('playlist', 'index')
+
+    def delete(self, *args, **kwargs):
+        playlist = self.playlist
+        index = self.index
+        update_indexes = kwargs.pop('update_indexes', False)
+        r = super().delete(*args, **kwargs)
+        if index is not None and update_indexes:
+            playlist.remove(index)
+        return r
diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py
index 7f889d53e8b7c3163dfb1c017af2df14665d3df7..6caf9aa4aa13de423c0fd322afc0af6c02f2afd3 100644
--- a/api/funkwhale_api/playlists/serializers.py
+++ b/api/funkwhale_api/playlists/serializers.py
@@ -1,8 +1,11 @@
+from django.conf import settings
+from django.db import transaction
 from rest_framework import serializers
 from taggit.models import Tag
 
+from funkwhale_api.music.models import Track
 from funkwhale_api.music.serializers import TrackSerializerNested
-
+from funkwhale_api.users.serializers import UserBasicSerializer
 from . import models
 
 
@@ -11,20 +14,81 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = models.PlaylistTrack
-        fields = ('id', 'track', 'playlist', 'position')
+        fields = ('id', 'track', 'playlist', 'index', 'creation_date')
 
 
-class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
+class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
+    index = serializers.IntegerField(
+        required=False, min_value=0, allow_null=True)
 
     class Meta:
         model = models.PlaylistTrack
-        fields = ('id', 'track', 'playlist', 'position')
+        fields = ('id', 'track', 'playlist', 'index')
+
+    def validate_playlist(self, value):
+        if self.context.get('request'):
+            # validate proper ownership on the playlist
+            if self.context['request'].user != value.user:
+                raise serializers.ValidationError(
+                    'You do not have the permission to edit this playlist')
+        existing = value.playlist_tracks.count()
+        if existing >= settings.PLAYLISTS_MAX_TRACKS:
+            raise serializers.ValidationError(
+                'Playlist has reached the maximum of {} tracks'.format(
+                    settings.PLAYLISTS_MAX_TRACKS))
+        return value
+
+    @transaction.atomic
+    def create(self, validated_data):
+        index = validated_data.pop('index', None)
+        instance = super().create(validated_data)
+        instance.playlist.insert(instance, index)
+        return instance
+
+    @transaction.atomic
+    def update(self, instance, validated_data):
+        update_index = 'index' in validated_data
+        index = validated_data.pop('index', None)
+        super().update(instance, validated_data)
+        if update_index:
+            instance.playlist.insert(instance, index)
+        return instance
+
+    def get_unique_together_validators(self):
+        """
+        We explicitely disable unique together validation here
+        because it collides with our internal logic
+        """
+        return []
 
 
 class PlaylistSerializer(serializers.ModelSerializer):
-    playlist_tracks = PlaylistTrackSerializer(many=True, read_only=True)
+    tracks_count = serializers.SerializerMethodField(read_only=True)
+    user = UserBasicSerializer(read_only=True)
 
     class Meta:
         model = models.Playlist
-        fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks')
-        read_only_fields = ['id', 'playlist_tracks', 'creation_date']
+        fields = (
+            'id',
+            'name',
+            'tracks_count',
+            'user',
+            'modification_date',
+            'creation_date',
+            'privacy_level',)
+        read_only_fields = [
+            'id',
+            'modification_date',
+            'creation_date',]
+
+    def get_tracks_count(self, obj):
+        try:
+            return obj.tracks_count
+        except AttributeError:
+            # no annotation?
+            return obj.playlist_tracks.count()
+
+
+class PlaylistAddManySerializer(serializers.Serializer):
+    tracks = serializers.PrimaryKeyRelatedField(
+        many=True, queryset=Track.objects.for_nested_serialization())
diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py
index 1a88d231e46c63d865554f56b40128a24b9edac4..683f90388885ecb5337bed10b16ef3c7d9b866a4 100644
--- a/api/funkwhale_api/playlists/views.py
+++ b/api/funkwhale_api/playlists/views.py
@@ -1,58 +1,123 @@
+from django.db.models import Count
+from django.db import transaction
+
+from rest_framework import exceptions
 from rest_framework import generics, mixins, viewsets
 from rest_framework import status
+from rest_framework.decorators import detail_route
 from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticatedOrReadOnly
 
+from funkwhale_api.common import permissions
+from funkwhale_api.common import fields
 from funkwhale_api.music.models import Track
-from funkwhale_api.common.permissions import ConditionalAuthentication
 
+from . import filters
 from . import models
 from . import serializers
 
-
 class PlaylistViewSet(
         mixins.RetrieveModelMixin,
         mixins.CreateModelMixin,
+        mixins.UpdateModelMixin,
         mixins.DestroyModelMixin,
         mixins.ListModelMixin,
         viewsets.GenericViewSet):
 
     serializer_class = serializers.PlaylistSerializer
-    queryset = (models.Playlist.objects.all())
-    permission_classes = [ConditionalAuthentication]
+    queryset = (
+        models.Playlist.objects.all().select_related('user')
+              .annotate(tracks_count=Count('playlist_tracks'))
+    )
+    permission_classes = [
+        permissions.ConditionalAuthentication,
+        permissions.OwnerPermission,
+        IsAuthenticatedOrReadOnly,
+    ]
+    owner_checks = ['write']
+    filter_class = filters.PlaylistFilter
+    ordering_fields = ('id', 'name', 'creation_date', 'modification_date')
 
-    def create(self, request, *args, **kwargs):
-        serializer = self.get_serializer(data=request.data)
+    @detail_route(methods=['get'])
+    def tracks(self, request, *args, **kwargs):
+        playlist = self.get_object()
+        plts = playlist.playlist_tracks.all().for_nested_serialization()
+        serializer = serializers.PlaylistTrackSerializer(plts, many=True)
+        data = {
+            'count': len(plts),
+            'results': serializer.data
+        }
+        return Response(data, status=200)
+
+    @detail_route(methods=['post'])
+    @transaction.atomic
+    def add(self, request, *args, **kwargs):
+        playlist = self.get_object()
+        serializer = serializers.PlaylistAddManySerializer(data=request.data)
         serializer.is_valid(raise_exception=True)
-        instance = self.perform_create(serializer)
-        serializer = self.get_serializer(instance=instance)
-        headers = self.get_success_headers(serializer.data)
-        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+        try:
+            plts = playlist.insert_many(serializer.validated_data['tracks'])
+        except exceptions.ValidationError as e:
+            payload = {'playlist': e.detail}
+            return Response(payload, status=400)
+        ids = [p.id for p in plts]
+        plts = models.PlaylistTrack.objects.filter(
+            pk__in=ids).order_by('index').for_nested_serialization()
+        serializer = serializers.PlaylistTrackSerializer(plts, many=True)
+        data = {
+            'count': len(plts),
+            'results': serializer.data
+        }
+        return Response(data, status=201)
+
+    @detail_route(methods=['delete'])
+    @transaction.atomic
+    def clear(self, request, *args, **kwargs):
+        playlist = self.get_object()
+        playlist.playlist_tracks.all().delete()
+        playlist.save(update_fields=['modification_date'])
+        return Response(status=204)
 
     def get_queryset(self):
-        return self.queryset.filter(user=self.request.user)
+        return self.queryset.filter(
+            fields.privacy_level_query(self.request.user))
 
     def perform_create(self, serializer):
-        return serializer.save(user=self.request.user)
+        return serializer.save(
+            user=self.request.user,
+            privacy_level=serializer.validated_data.get(
+                'privacy_level', self.request.user.privacy_level)
+        )
 
 
 class PlaylistTrackViewSet(
+        mixins.RetrieveModelMixin,
         mixins.CreateModelMixin,
+        mixins.UpdateModelMixin,
         mixins.DestroyModelMixin,
         mixins.ListModelMixin,
         viewsets.GenericViewSet):
 
     serializer_class = serializers.PlaylistTrackSerializer
-    queryset = (models.PlaylistTrack.objects.all())
-    permission_classes = [ConditionalAuthentication]
+    queryset = (models.PlaylistTrack.objects.all().for_nested_serialization())
+    permission_classes = [
+        permissions.ConditionalAuthentication,
+        permissions.OwnerPermission,
+        IsAuthenticatedOrReadOnly,
+    ]
+    owner_field = 'playlist.user'
+    owner_checks = ['write']
 
-    def create(self, request, *args, **kwargs):
-        serializer = serializers.PlaylistTrackCreateSerializer(
-            data=request.data)
-        serializer.is_valid(raise_exception=True)
-        instance = self.perform_create(serializer)
-        serializer = self.get_serializer(instance=instance)
-        headers = self.get_success_headers(serializer.data)
-        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+    def get_serializer_class(self):
+        if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']:
+            return serializers.PlaylistTrackWriteSerializer
+        return self.serializer_class
 
     def get_queryset(self):
-        return self.queryset.filter(playlist__user=self.request.user)
+        return self.queryset.filter(
+            fields.privacy_level_query(
+                self.request.user,
+                lookup_field='playlist__privacy_level'))
+
+    def perform_destroy(self, instance):
+        instance.delete(update_indexes=True)
diff --git a/api/funkwhale_api/requests/admin.py b/api/funkwhale_api/requests/admin.py
index 71933eaa940d09c8482e6d7b2a2b73cf22afc5b5..8ca008a03a43607240e3102ade0b2ea8a75ac2d6 100644
--- a/api/funkwhale_api/requests/admin.py
+++ b/api/funkwhale_api/requests/admin.py
@@ -7,8 +7,7 @@ from . import models
 class ImportRequestAdmin(admin.ModelAdmin):
     list_display = ['artist_name', 'user', 'status', 'creation_date']
     list_select_related = [
-        'user',
-        'track'
+        'user'
     ]
     list_filter = [
         'status',
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index a5478656b178c94037ca232834cc15d36c6b1f9d..9516c108f896275837be3161fe80e07dda6273e7 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -10,15 +10,9 @@ from django.db import models
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 
+from funkwhale_api.common import fields
 
 
-PRIVACY_LEVEL_CHOICES = [
-    ('me', 'Only me'),
-    ('followers', 'Me and my followers'),
-    ('instance', 'Everyone on my instance, and my followers'),
-    ('everyone', 'Everyone, including people on other instances'),
-]
-
 @python_2_unicode_compatible
 class User(AbstractUser):
 
@@ -39,8 +33,8 @@ class User(AbstractUser):
         },
     }
 
-    privacy_level = models.CharField(
-        max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
+    privacy_level = fields.get_privacy_field()
+
 
     def __str__(self):
         return self.username
diff --git a/api/install_python_dependencies.sh b/api/install_python_dependencies.sh
deleted file mode 100755
index 34929e60790b7a6708729fdc35f834d414597da9..0000000000000000000000000000000000000000
--- a/api/install_python_dependencies.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-
-pip --version >/dev/null 2>&1 || {
-    echo >&2 -e "\npip is required but it's not installed."
-    echo >&2 -e "You can install it by running the following command:\n"
-    echo >&2 "wget https://bootstrap.pypa.io/get-pip.py --output-document=get-pip.py; chmod +x get-pip.py; sudo -H python3 get-pip.py"
-    echo >&2 -e "\n"
-    echo >&2 -e "\nFor more information, see pip documentation: https://pip.pypa.io/en/latest/"
-    exit 1;
-}
-
-virtualenv --version >/dev/null 2>&1 || {
-    echo >&2 -e "\nvirtualenv is required but it's not installed."
-    echo >&2 -e "You can install it by running the following command:\n"
-    echo >&2 "sudo -H pip3 install virtualenv"
-    echo >&2 -e "\n"
-    echo >&2 -e "\nFor more information, see virtualenv documentation: https://virtualenv.pypa.io/en/latest/"
-    exit 1;
-}
-
-if [ -z "$VIRTUAL_ENV" ]; then
-    echo >&2 -e "\nYou need activate a virtualenv first"
-    echo >&2 -e 'If you do not have a virtualenv created, run the following command to create and automatically activate a new virtualenv named "venv" on current folder:\n'
-    echo >&2 -e "virtualenv venv --python=\`which python3\`"
-    echo >&2 -e "\nTo leave/disable the currently active virtualenv, run the following command:\n"
-    echo >&2  "deactivate"
-    echo >&2 -e "\nTo activate the virtualenv again, run the following command:\n"
-    echo >&2  "source venv/bin/activate"
-    echo >&2 -e "\nFor more information, see virtualenv documentation: https://virtualenv.pypa.io/en/latest/"
-    echo >&2 -e "\n"
-    exit 1;
-else
-
-    pip install -r requirements/local.txt
-    pip install -r requirements/test.txt
-    pip install -r requirements.txt
-fi
diff --git a/api/pytest.ini b/api/pytest.ini
deleted file mode 100644
index 9be63d3531626526f46f196b840c02a9cae1c8b3..0000000000000000000000000000000000000000
--- a/api/pytest.ini
+++ /dev/null
@@ -1,6 +0,0 @@
-[pytest]
-DJANGO_SETTINGS_MODULE=config.settings.test
-
-# -- recommended but optional:
-python_files = tests.py test_*.py *_tests.py
-testpatsh = tests
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index d6800f3b50d9d6e662a2514b46f76815f3c8d84d..efcc4eea40e66fadd74b25dc8cfa1c89f4e8abe7 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -33,7 +33,6 @@ musicbrainzngs==0.6
 youtube_dl>=2017.12.14
 djangorestframework>=3.7,<3.8
 djangorestframework-jwt>=1.11,<1.12
-django-mptt>=0.9,<0.10
 google-api-python-client>=1.6,<1.7
 arrow>=0.12,<0.13
 persisting-theory>=0.2,<0.3
@@ -58,3 +57,6 @@ python-magic==0.4.15
 ffmpeg-python==0.1.10
 channels>=2,<2.1
 channels_redis>=2.1,<2.2
+django-cacheops>=4,<4.1
+
+daphne==2.0.4
diff --git a/api/requirements/local.txt b/api/requirements/local.txt
index b466b20fdfc20c5815fb2f7be8daedccc3f360ac..c5f2ad0b7f7921a3af127f971f93eb4a3c80ad30 100644
--- a/api/requirements/local.txt
+++ b/api/requirements/local.txt
@@ -2,9 +2,6 @@
 
 coverage>=4.4,<4.5
 django_coverage_plugin>=1.5,<1.6
-Sphinx>=1.6,<1.7
-django-extensions>=1.9,<1.10
-Werkzeug>=0.13,<0.14
 factory_boy>=2.8.1
 
 # django-debug-toolbar that works with Django 1.5+
diff --git a/api/requirements/production.txt b/api/requirements/production.txt
index 4ad8edf940dc4524f77c6ac9b339e55c7b1c5a7c..d51ee863ad7e465c5ddcfcf9de5ac8cfcfa51e4f 100644
--- a/api/requirements/production.txt
+++ b/api/requirements/production.txt
@@ -3,5 +3,3 @@
 
 # WSGI Handler
 # ------------------------------------------------
-
-daphne==2.0.4
diff --git a/api/runtests b/api/runtests
deleted file mode 100755
index 48e7b82679e6cab413f4d435cfc0b8f794019bb0..0000000000000000000000000000000000000000
--- a/api/runtests
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/bash
-
-DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-
-docker-compose -f $DIR/test.yml run test pytest "$@"
diff --git a/api/setup.cfg b/api/setup.cfg
index c18b80d95bcd54716236d261e572b6b2143d6e3a..34daa8c6834452229971467c7876400b842b64c1 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -5,3 +5,8 @@ exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
 [pep8]
 max-line-length = 120
 exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
+
+[tool:pytest]
+DJANGO_SETTINGS_MODULE=config.settings.test
+python_files = tests.py test_*.py *_tests.py
+testpaths = tests
diff --git a/api/test.yml b/api/test.yml
deleted file mode 100644
index 5e785cb1ac3e20eba418a5d89673a207a84519de..0000000000000000000000000000000000000000
--- a/api/test.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-version: '2'
-services:
-  test:
-    build:
-      dockerfile: docker/Dockerfile.test
-      context: .
-    command: pytest
-    depends_on:
-      - postgres
-    volumes:
-      - .:/app
-    environment:
-      - "DJANGO_ALLOWED_HOSTS=localhost"
-      - "DATABASE_URL=postgresql://postgres@postgres/postgres"
-      - "FUNKWHALE_URL=https://funkwhale.test"
-  postgres:
-    image: postgres
diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c63431a38ae4e5eaf41c5285966afd80a2dd6e2
--- /dev/null
+++ b/api/tests/common/test_fields.py
@@ -0,0 +1,17 @@
+import pytest
+
+from django.contrib.auth.models import AnonymousUser
+from django.db.models import Q
+
+from funkwhale_api.common import fields
+from funkwhale_api.users.factories import UserFactory
+
+
+@pytest.mark.parametrize('user,expected', [
+    (AnonymousUser(), Q(privacy_level='everyone')),
+    (UserFactory.build(pk=1),
+     Q(privacy_level__in=['me', 'followers', 'instance', 'everyone'])),
+])
+def test_privacy_level_query(user,expected):
+    query = fields.privacy_level_query(user)
+    assert query == expected
diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..b5c5160f8accdf6e0bbeb29f9ee4d464962dff5b
--- /dev/null
+++ b/api/tests/common/test_permissions.py
@@ -0,0 +1,42 @@
+import pytest
+
+from rest_framework.views import APIView
+
+from django.contrib.auth.models import AnonymousUser
+from django.http import Http404
+
+from funkwhale_api.common import permissions
+
+
+def test_owner_permission_owner_field_ok(nodb_factories, api_request):
+    playlist = nodb_factories['playlists.Playlist']()
+    view = APIView.as_view()
+    permission = permissions.OwnerPermission()
+    request = api_request.get('/')
+    setattr(request, 'user', playlist.user)
+    check = permission.has_object_permission(request, view, playlist)
+
+    assert check is True
+
+
+def test_owner_permission_owner_field_not_ok(nodb_factories, api_request):
+    playlist = nodb_factories['playlists.Playlist']()
+    view = APIView.as_view()
+    permission = permissions.OwnerPermission()
+    request = api_request.get('/')
+    setattr(request, 'user', AnonymousUser())
+
+    with pytest.raises(Http404):
+        permission.has_object_permission(request, view, playlist)
+
+
+def test_owner_permission_read_only(nodb_factories, api_request):
+    playlist = nodb_factories['playlists.Playlist']()
+    view = APIView.as_view()
+    setattr(view, 'owner_checks', ['write'])
+    permission = permissions.OwnerPermission()
+    request = api_request.get('/')
+    setattr(request, 'user', AnonymousUser())
+    check = permission.has_object_permission(request, view, playlist)
+
+    assert check is True
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 2d655f23f28dd2ea1ad56d4073df1147451c603c..62bc5ada676327aa1d5044c7bd31eaea45904dea 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -1,9 +1,13 @@
+import factory
 import tempfile
 import shutil
 import pytest
+
 from django.core.cache import cache as django_cache
 from dynamic_preferences.registries import global_preferences_registry
+
 from rest_framework.test import APIClient
+from rest_framework.test import APIRequestFactory
 
 from funkwhale_api.activity import record
 from funkwhale_api.taskapp import celery
@@ -26,6 +30,16 @@ def cache():
 @pytest.fixture
 def factories(db):
     from funkwhale_api import factories
+    for v in factories.registry.values():
+        v._meta.strategy = factory.CREATE_STRATEGY
+    yield factories.registry
+
+
+@pytest.fixture
+def nodb_factories():
+    from funkwhale_api import factories
+    for v in factories.registry.values():
+        v._meta.strategy = factory.BUILD_STRATEGY
     yield factories.registry
 
 
@@ -84,6 +98,11 @@ def superuser_client(db, factories, client):
     delattr(client, 'user')
 
 
+@pytest.fixture
+def api_request():
+    return APIRequestFactory()
+
+
 @pytest.fixture
 def activity_registry():
     r = record.registry
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..2956046168ca30487976512518bedce919752f2e
--- /dev/null
+++ b/api/tests/music/test_views.py
@@ -0,0 +1,45 @@
+import pytest
+
+from funkwhale_api.music import views
+
+
+@pytest.mark.parametrize('param,expected', [
+    ('true', 'full'),
+    ('false', 'empty'),
+])
+def test_artist_view_filter_listenable(
+        param, expected, factories, api_request):
+    artists = {
+        'empty': factories['music.Artist'](),
+        'full': factories['music.TrackFile']().track.artist,
+    }
+
+    request = api_request.get('/', {'listenable': param})
+    view = views.ArtistViewSet()
+    view.action_map = {'get': 'list'}
+    expected = [artists[expected]]
+    view.request = view.initialize_request(request)
+    queryset = view.filter_queryset(view.get_queryset())
+
+    assert list(queryset) == expected
+
+
+@pytest.mark.parametrize('param,expected', [
+    ('true', 'full'),
+    ('false', 'empty'),
+])
+def test_album_view_filter_listenable(
+        param, expected, factories, api_request):
+    artists = {
+        'empty': factories['music.Album'](),
+        'full': factories['music.TrackFile']().track.album,
+    }
+
+    request = api_request.get('/', {'listenable': param})
+    view = views.AlbumViewSet()
+    view.action_map = {'get': 'list'}
+    expected = [artists[expected]]
+    view.request = view.initialize_request(request)
+    queryset = view.filter_queryset(view.get_queryset())
+
+    assert list(queryset) == expected
diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..c9def4dab85c4798c337b8876387c2ff5a6f05ed
--- /dev/null
+++ b/api/tests/playlists/test_models.py
@@ -0,0 +1,126 @@
+import pytest
+
+from rest_framework import exceptions
+
+
+def test_can_insert_plt(factories):
+    plt = factories['playlists.PlaylistTrack']()
+    modification_date = plt.playlist.modification_date
+
+    assert plt.index is None
+
+    plt.playlist.insert(plt)
+    plt.refresh_from_db()
+
+    assert plt.index == 0
+    assert plt.playlist.modification_date > modification_date
+
+
+def test_insert_use_last_idx_by_default(factories):
+    playlist = factories['playlists.Playlist']()
+    plts = factories['playlists.PlaylistTrack'].create_batch(
+        size=3, playlist=playlist)
+
+    for i, plt in enumerate(plts):
+        index = playlist.insert(plt)
+        plt.refresh_from_db()
+
+        assert index == i
+        assert plt.index == i
+
+def test_can_insert_at_index(factories):
+    playlist = factories['playlists.Playlist']()
+    first = factories['playlists.PlaylistTrack'](playlist=playlist)
+    playlist.insert(first)
+    new_first = factories['playlists.PlaylistTrack'](playlist=playlist)
+    index = playlist.insert(new_first, index=0)
+    first.refresh_from_db()
+    new_first.refresh_from_db()
+
+    assert index == 0
+    assert first.index == 1
+    assert new_first.index == 0
+
+
+def test_can_insert_and_move(factories):
+    playlist = factories['playlists.Playlist']()
+    first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
+    second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
+    third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
+
+    playlist.insert(second, index=0)
+
+    first.refresh_from_db()
+    second.refresh_from_db()
+    third.refresh_from_db()
+
+    assert third.index == 2
+    assert second.index == 0
+    assert first.index == 1
+
+
+def test_can_insert_and_move_last_to_0(factories):
+    playlist = factories['playlists.Playlist']()
+    first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
+    second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
+    third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
+
+    playlist.insert(third, index=0)
+
+    first.refresh_from_db()
+    second.refresh_from_db()
+    third.refresh_from_db()
+
+    assert third.index == 0
+    assert first.index == 1
+    assert second.index == 2
+
+
+def test_cannot_insert_at_wrong_index(factories):
+    plt = factories['playlists.PlaylistTrack']()
+    new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
+    with pytest.raises(exceptions.ValidationError):
+        plt.playlist.insert(new, 2)
+
+
+def test_cannot_insert_at_negative_index(factories):
+    plt = factories['playlists.PlaylistTrack']()
+    new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
+    with pytest.raises(exceptions.ValidationError):
+        plt.playlist.insert(new, -1)
+
+
+def test_remove_update_indexes(factories):
+    playlist = factories['playlists.Playlist']()
+    first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
+    second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
+    third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
+
+    second.delete(update_indexes=True)
+
+    first.refresh_from_db()
+    third.refresh_from_db()
+
+    assert first.index == 0
+    assert third.index == 1
+
+
+def test_can_insert_many(factories):
+    playlist = factories['playlists.Playlist']()
+    existing = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
+    tracks = factories['music.Track'].create_batch(size=3)
+    plts = playlist.insert_many(tracks)
+    for i, plt in enumerate(plts):
+        assert plt.index == i + 1
+        assert plt.track == tracks[i]
+        assert plt.playlist == playlist
+
+
+def test_insert_many_honor_max_tracks(factories, settings):
+    settings.PLAYLISTS_MAX_TRACKS = 4
+    playlist = factories['playlists.Playlist']()
+    plts = factories['playlists.PlaylistTrack'].create_batch(
+        size=2, playlist=playlist)
+    track = factories['music.Track']()
+    with pytest.raises(exceptions.ValidationError):
+        playlist.insert_many([track, track, track])
diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e30919e6eaab42e3d8111c59cff3a2118271abc
--- /dev/null
+++ b/api/tests/playlists/test_serializers.py
@@ -0,0 +1,74 @@
+from funkwhale_api.playlists import models
+from funkwhale_api.playlists import serializers
+
+
+def test_cannot_max_500_tracks_per_playlist(factories, settings):
+    settings.PLAYLISTS_MAX_TRACKS = 2
+    playlist = factories['playlists.Playlist']()
+    plts = factories['playlists.PlaylistTrack'].create_batch(
+        size=2, playlist=playlist)
+    track = factories['music.Track']()
+    serializer = serializers.PlaylistTrackWriteSerializer(data={
+        'playlist': playlist.pk,
+        'track': track.pk,
+    })
+
+    assert serializer.is_valid() is False
+    assert 'playlist' in serializer.errors
+
+
+def test_create_insert_is_called_when_index_is_None(factories, mocker):
+    insert = mocker.spy(models.Playlist, 'insert')
+    playlist = factories['playlists.Playlist']()
+    track = factories['music.Track']()
+    serializer = serializers.PlaylistTrackWriteSerializer(data={
+        'playlist': playlist.pk,
+        'track': track.pk,
+        'index': None,
+    })
+    assert serializer.is_valid() is True
+
+    plt = serializer.save()
+    insert.assert_called_once_with(playlist, plt, None)
+    assert plt.index == 0
+
+
+def test_create_insert_is_called_when_index_is_provided(factories, mocker):
+    playlist = factories['playlists.Playlist']()
+    first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
+    insert = mocker.spy(models.Playlist, 'insert')
+    factories['playlists.Playlist']()
+    track = factories['music.Track']()
+    serializer = serializers.PlaylistTrackWriteSerializer(data={
+        'playlist': playlist.pk,
+        'track': track.pk,
+        'index': 0,
+    })
+    assert serializer.is_valid() is True
+
+    plt = serializer.save()
+    first.refresh_from_db()
+    insert.assert_called_once_with(playlist, plt, 0)
+    assert plt.index == 0
+    assert first.index == 1
+
+
+def test_update_insert_is_called_when_index_is_provided(factories, mocker):
+    playlist = factories['playlists.Playlist']()
+    first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
+    second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
+    insert = mocker.spy(models.Playlist, 'insert')
+    factories['playlists.Playlist']()
+    track = factories['music.Track']()
+    serializer = serializers.PlaylistTrackWriteSerializer(second, data={
+        'playlist': playlist.pk,
+        'track': second.track.pk,
+        'index': 0,
+    })
+    assert serializer.is_valid() is True
+
+    plt = serializer.save()
+    first.refresh_from_db()
+    insert.assert_called_once_with(playlist, plt, 0)
+    assert plt.index == 0
+    assert first.index == 1
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..5bf83488859177aaf4e756a5f25668f568641b35
--- /dev/null
+++ b/api/tests/playlists/test_views.py
@@ -0,0 +1,197 @@
+import json
+import pytest
+
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+from django.utils import timezone
+
+from funkwhale_api.playlists import models
+from funkwhale_api.playlists import serializers
+
+
+def test_can_create_playlist_via_api(logged_in_api_client):
+    url = reverse('api:v1:playlists-list')
+    data = {
+        'name': 'test',
+        'privacy_level': 'everyone'
+    }
+
+    response = logged_in_api_client.post(url, data)
+
+    playlist = logged_in_api_client.user.playlists.latest('id')
+    assert playlist.name == 'test'
+    assert playlist.privacy_level == 'everyone'
+
+
+def test_serializer_includes_tracks_count(factories, logged_in_api_client):
+    playlist = factories['playlists.Playlist']()
+    plt = factories['playlists.PlaylistTrack'](playlist=playlist)
+
+    url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
+    response = logged_in_api_client.get(url)
+
+    assert response.data['tracks_count'] == 1
+
+
+def test_playlist_inherits_user_privacy(logged_in_api_client):
+    url = reverse('api:v1:playlists-list')
+    user = logged_in_api_client.user
+    user.privacy_level = 'me'
+    user.save()
+
+    data = {
+        'name': 'test',
+    }
+
+    response = logged_in_api_client.post(url, data)
+    playlist = user.playlists.latest('id')
+    assert playlist.privacy_level == user.privacy_level
+
+
+def test_can_add_playlist_track_via_api(factories, logged_in_api_client):
+    tracks = factories['music.Track'].create_batch(5)
+    playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
+    url = reverse('api:v1:playlist-tracks-list')
+    data = {
+        'playlist': playlist.pk,
+        'track': tracks[0].pk
+    }
+
+    response = logged_in_api_client.post(url, data)
+    assert response.status_code == 201
+    plts = logged_in_api_client.user.playlists.latest('id').playlist_tracks.all()
+    assert plts.first().track == tracks[0]
+
+
+@pytest.mark.parametrize('name,method', [
+    ('api:v1:playlist-tracks-list', 'post'),
+    ('api:v1:playlists-list', 'post'),
+])
+def test_url_requires_login(name, method, factories, api_client):
+    url = reverse(name)
+
+    response = getattr(api_client, method)(url, {})
+
+    assert response.status_code == 401
+
+
+def test_only_can_add_track_on_own_playlist_via_api(
+        factories, logged_in_api_client):
+    track = factories['music.Track']()
+    playlist = factories['playlists.Playlist']()
+    url = reverse('api:v1:playlist-tracks-list')
+    data = {
+        'playlist': playlist.pk,
+        'track': track.pk
+    }
+
+    response = logged_in_api_client.post(url, data)
+    assert response.status_code == 400
+    assert playlist.playlist_tracks.count() == 0
+
+
+def test_deleting_plt_updates_indexes(
+        mocker, factories, logged_in_api_client):
+    remove = mocker.spy(models.Playlist, 'remove')
+    track = factories['music.Track']()
+    plt = factories['playlists.PlaylistTrack'](
+        index=0,
+        playlist__user=logged_in_api_client.user)
+    url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+    remove.assert_called_once_with(plt.playlist, 0)
+
+
+@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
+def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
+    factories['playlists.Playlist'](privacy_level=level)
+    url = reverse('api:v1:playlists-list')
+    response = api_client.get(url)
+
+    assert response.data['count'] == 0
+
+
+@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
+def test_only_owner_can_edit_playlist(method, factories, api_client):
+    playlist = factories['playlists.Playlist']()
+    url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
+    response = api_client.get(url)
+
+    assert response.status_code == 404
+
+
+@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
+def test_only_owner_can_edit_playlist_track(method, factories, api_client):
+    plt = factories['playlists.PlaylistTrack']()
+    url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
+    response = api_client.get(url)
+
+    assert response.status_code == 404
+
+
+@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
+def test_playlist_track_privacy_respected_in_list_anon(
+        level, factories, api_client):
+    factories['playlists.PlaylistTrack'](playlist__privacy_level=level)
+    url = reverse('api:v1:playlist-tracks-list')
+    response = api_client.get(url)
+
+    assert response.data['count'] == 0
+
+
+@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
+def test_can_list_tracks_from_playlist(
+        level, factories, logged_in_api_client):
+    plt = factories['playlists.PlaylistTrack'](
+        playlist__user=logged_in_api_client.user)
+    url = reverse('api:v1:playlists-tracks', kwargs={'pk': plt.playlist.pk})
+    response = logged_in_api_client.get(url)
+    serialized_plt = serializers.PlaylistTrackSerializer(plt).data
+
+    assert response.data['count'] == 1
+    assert response.data['results'][0] == serialized_plt
+
+
+def test_can_add_multiple_tracks_at_once_via_api(
+        factories, mocker, logged_in_api_client):
+    playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
+    tracks = factories['music.Track'].create_batch(size=5)
+    track_ids = [t.id for t in tracks]
+    mocker.spy(playlist, 'insert_many')
+    url = reverse('api:v1:playlists-add', kwargs={'pk': playlist.pk})
+    response = logged_in_api_client.post(url, {'tracks': track_ids})
+
+    assert response.status_code == 201
+    assert playlist.playlist_tracks.count() == len(track_ids)
+
+    for plt in playlist.playlist_tracks.order_by('index'):
+        assert response.data['results'][plt.index]['id'] == plt.id
+        assert plt.track == tracks[plt.index]
+
+
+def test_can_clear_playlist_from_api(
+        factories, mocker, logged_in_api_client):
+    playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
+    plts = factories['playlists.PlaylistTrack'].create_batch(
+        size=5, playlist=playlist)
+    url = reverse('api:v1:playlists-clear', kwargs={'pk': playlist.pk})
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+    assert playlist.playlist_tracks.count() == 0
+
+
+def test_update_playlist_from_api(
+        factories, mocker, logged_in_api_client):
+    playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
+    plts = factories['playlists.PlaylistTrack'].create_batch(
+        size=5, playlist=playlist)
+    url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
+    response = logged_in_api_client.patch(url, {'name': 'test'})
+    playlist.refresh_from_db()
+
+    assert response.status_code == 200
+    assert response.data['user']['username'] == playlist.user.username
diff --git a/api/tests/test_playlists.py b/api/tests/test_playlists.py
deleted file mode 100644
index f496a64cb5d93a14aacc8bb73ddc2a58e3c7b50e..0000000000000000000000000000000000000000
--- a/api/tests/test_playlists.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import json
-from django.urls import reverse
-from django.core.exceptions import ValidationError
-from django.utils import timezone
-
-from funkwhale_api.playlists import models
-from funkwhale_api.playlists.serializers import PlaylistSerializer
-
-
-
-def test_can_create_playlist(factories):
-    tracks = factories['music.Track'].create_batch(5)
-    playlist = factories['playlists.Playlist']()
-
-    previous = None
-    for track in tracks:
-        previous = playlist.add_track(track, previous=previous)
-
-    playlist_tracks = list(playlist.playlist_tracks.all())
-
-    previous = None
-    for idx, track in enumerate(tracks):
-        plt = playlist_tracks[idx]
-        assert plt.position == idx
-        assert plt.track == track
-        if previous:
-            assert playlist_tracks[idx + 1] == previous
-        assert plt.playlist == playlist
-
-
-def test_can_create_playlist_via_api(logged_in_client):
-    url = reverse('api:v1:playlists-list')
-    data = {
-        'name': 'test',
-    }
-
-    response = logged_in_client.post(url, data)
-
-    playlist = logged_in_client.user.playlists.latest('id')
-    assert playlist.name == 'test'
-
-
-def test_can_add_playlist_track_via_api(factories, logged_in_client):
-    tracks = factories['music.Track'].create_batch(5)
-    playlist = factories['playlists.Playlist'](user=logged_in_client.user)
-    url = reverse('api:v1:playlist-tracks-list')
-    data = {
-        'playlist': playlist.pk,
-        'track': tracks[0].pk
-    }
-
-    response = logged_in_client.post(url, data)
-    plts = logged_in_client.user.playlists.latest('id').playlist_tracks.all()
-    assert plts.first().track == tracks[0]
diff --git a/dev.yml b/dev.yml
index 2c102f3aea33e790f292ac1ec9755648690cc584..8d2129bef978e78bc8c1a874ae19692d8ee62997 100644
--- a/dev.yml
+++ b/dev.yml
@@ -71,3 +71,12 @@ services:
       - ./api/funkwhale_api/media:/protected/media
     ports:
       - "0.0.0.0:6001:6001"
+
+  docs:
+    build: docs
+    command: python serve.py
+    volumes:
+      - ".:/app/"
+    ports:
+      - '35730:35730'
+      - '8001:8001'
diff --git a/docs/Dockerfile b/docs/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..1067eb8be427bcb66cb4ad824993ef300ba04cb6
--- /dev/null
+++ b/docs/Dockerfile
@@ -0,0 +1,4 @@
+FROM python:3.6-alpine
+
+RUN pip install sphinx livereload
+WORKDIR /app/docs
diff --git a/docs/serve.py b/docs/serve.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a381c74be8caffbadbc04bc7d38764e535fefdb
--- /dev/null
+++ b/docs/serve.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python
+from subprocess import call
+# initial make
+call(["python", "-m", "sphinx", ".", "/tmp/_build"])
+from livereload import Server, shell
+
+server = Server()
+server.watch('.', shell('python -m sphinx . /tmp/_build'))
+server.serve(
+    root='/tmp/_build/',
+    liveport=35730,
+    port=8001,
+host='0.0.0.0')
diff --git a/front/src/App.vue b/front/src/App.vue
index b26959fe7006a72c45cac37e72d468c8f3c614ff..d15eebdba69db25580d2d86a055819439a7c113a 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -29,6 +29,8 @@
       v-if="$store.state.instance.settings.raven.front_enabled.value"
       :dsn="$store.state.instance.settings.raven.front_dsn.value">
     </raven>
+    <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
+
   </div>
 </template>
 
@@ -39,11 +41,14 @@ import logger from '@/logging'
 import Sidebar from '@/components/Sidebar'
 import Raven from '@/components/Raven'
 
+import PlaylistModal from '@/components/playlists/PlaylistModal'
+
 export default {
   name: 'app',
   components: {
     Sidebar,
-    Raven
+    Raven,
+    PlaylistModal
   },
   created () {
     this.$store.dispatch('instance/fetchSettings')
@@ -56,6 +61,9 @@ export default {
   },
   methods: {
     openWebsocket () {
+      if (!this.$store.state.auth.authenticated) {
+        return
+      }
       let self = this
       let token = this.$store.state.auth.token
       // let token = 'test'
diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue
index 0cea1c25ab4361d019d6df6be7c4cdad4b1c43a5..ad1ad93cb08c2a29cff5b84859b769345dee9872 100644
--- a/front/src/components/Home.vue
+++ b/front/src/components/Home.vue
@@ -94,9 +94,9 @@
         <p>Funkwhale is dead simple to use.</p>
         <div class="ui list">
           <div class="item">
-            <i class="libraryr icon"></i>
+            <i class="book icon"></i>
             <div class="content">
-              No add-ons, no plugins : you only need a web libraryr
+              No add-ons, no plugins : you only need a web library
             </div>
           </div>
           <div class="item">
diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue
index 71813a18a400ec2c712d19889d8e1a2a4372d414..47cf5183ab31aeb6fa4575139d42db73fc53a550 100644
--- a/front/src/components/Pagination.vue
+++ b/front/src/components/Pagination.vue
@@ -1,6 +1,7 @@
 <template>
   <div class="ui pagination borderless menu">
     <a
+      v-if="current - 1 >= 1"
       @click="selectPage(current - 1)"
       :class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></a>
     <template>
@@ -16,6 +17,7 @@
       </a>
     </template>
     <a
+      v-if="current + 1 <= maxPage"
       @click="selectPage(current + 1)"
       :class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></a>
   </div>
@@ -62,7 +64,6 @@ export default {
           }
         }
       })
-      console.log(final)
       return final
     },
     maxPage: function () {
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index f5229b2075c1811c24fcd758dc8db3611b2724a7..f225313b6b5f64139ed668473a6ebea51bc58eec 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -36,6 +36,12 @@
         <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
         <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
         <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
+        <a
+          @click="$store.commit('playlists/chooseTrack', null)"
+          v-if="$store.state.auth.authenticated"
+          class="item">
+          <i class="list icon"></i> Playlists
+        </a>
         <router-link
           v-if="$store.state.auth.authenticated"
           class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
@@ -70,7 +76,7 @@
               <td>
                 <template v-if="$store.getters['favorites/isFavorite'](track.id)">
                   <i class="pink heart icon"></i>
-                </template
+                </template>
               </td>
               <td>
                   <i @click.stop="cleanTrack(index)" class="circular trash icon"></i>
@@ -148,7 +154,7 @@ export default {
 <style scoped lang="scss">
 @import '../style/vendor/media';
 
-$sidebar-color: #1B1C1D;
+$sidebar-color: #3D3E3F;
 
 .sidebar {
 	background: $sidebar-color;
@@ -159,7 +165,7 @@ $sidebar-color: #1B1C1D;
   }
   @include media(">desktop") {
     .collapse.button {
-      display: none;
+      display: none !important;
     }
   }
   @include media("<desktop") {
@@ -176,16 +182,26 @@ $sidebar-color: #1B1C1D;
     margin: 0;
     background-color: $sidebar-color;
   }
-  .menu {
+  .menu.vertical {
+    background: $sidebar-color;
   }
 }
 
 .menu-area {
-  padding: 0.5rem;
   .menu .item:not(.active):not(:hover) {
-    background-color: rgba(255, 255, 255, 0.06);
+    opacity: 0.75;
+  }
+
+  .menu .item {
+    border-radius: 0;
   }
 
+  .menu .item.active {
+    background-color: $sidebar-color;
+    &:hover {
+      background-color: rgba(255, 255, 255, 0.06);
+    }
+  }
 }
 .tabs {
   overflow-y: auto;
@@ -216,14 +232,33 @@ $sidebar-color: #1B1C1D;
 .logo {
   cursor: pointer;
   display: inline-block;
+  margin: 0px;
 }
 
 .ui.search {
-  display: block;
-  .collapse.button {
-    margin-right: 0.5rem;
-    margin-top: 0.5rem;
-    float: right;
+  display: flex;
+
+  .collapse.button, .collapse.button:hover, .collapse.button:active {
+    box-shadow: none !important;
+    margin: 0px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+  }
+}
+
+.ui.message.black {
+  background: $sidebar-color;
+}
+</style>
+
+<style lang="scss">
+.sidebar {
+  .ui.search .input {
+    flex: 1;
+    .prompt {
+      border-radius: 0;
+    }
   }
 }
 </style>
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index 679f8b795f01cf83031998d2a5078e7cfca5350d..f2a3898622c09852eb6e05b77c549a76bbf9a6ee 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -3,11 +3,11 @@
     <button
       title="Add to current queue"
       @click="add"
-      :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: playableTracks.length === 0}, 'button']">
+      :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
       <i class="ui play icon"></i>
       <template v-if="!discrete"><slot>Play</slot></template>
     </button>
-    <div v-if="!discrete" class="ui floating dropdown icon button">
+    <div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
       <i class="dropdown icon"></i>
       <div class="menu">
         <div class="item"@click="add"><i class="plus icon"></i> Add to queue</div>
@@ -19,6 +19,7 @@
 </template>
 
 <script>
+import axios from 'axios'
 import logger from '@/logging'
 import jQuery from 'jquery'
 
@@ -27,6 +28,7 @@ export default {
     // we can either have a single or multiple tracks to play when clicked
     tracks: {type: Array, required: false},
     track: {type: Object, required: false},
+    playlist: {type: Object, required: false},
     discrete: {type: Boolean, default: false}
   },
   data () {
@@ -35,8 +37,8 @@ export default {
     }
   },
   created () {
-    if (!this.track & !this.tracks) {
-      logger.default.error('You have to provide either a track or tracks property')
+    if (!this.playlist && !this.track && !this.tracks) {
+      logger.default.error('You have to provide either a track playlist or tracks property')
     }
   },
   mounted () {
@@ -45,19 +47,40 @@ export default {
     }
   },
   computed: {
-    playableTracks () {
-      let tracks
+    playable () {
       if (this.track) {
-        tracks = [this.track]
-      } else {
-        tracks = this.tracks
+        return true
+      } else if (this.tracks) {
+        return this.tracks.length > 0
+      } else if (this.playlist) {
+        return true
       }
-      return tracks.filter(e => {
-        return e.files.length > 0
-      })
+      return false
     }
   },
   methods: {
+    getPlayableTracks () {
+      let self = this
+      let getTracks = new Promise((resolve, reject) => {
+        if (self.track) {
+          resolve([self.track])
+        } else if (self.tracks) {
+          resolve(self.tracks)
+        } else if (self.playlist) {
+          let url = 'playlists/' + self.playlist.id + '/'
+          axios.get(url + 'tracks').then((response) => {
+            resolve(response.data.results.map(plt => {
+              return plt.track
+            }))
+          })
+        }
+      })
+      return getTracks.then((tracks) => {
+        return tracks.filter(e => {
+          return e.files.length > 0
+        })
+      })
+    },
     triggerLoad () {
       let self = this
       this.isLoading = true
@@ -66,15 +89,21 @@ export default {
       }, 500)
     },
     add () {
+      let self = this
       this.triggerLoad()
-      this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks})
+      this.getPlayableTracks().then((tracks) => {
+        self.$store.dispatch('queue/appendMany', {tracks: tracks})
+      })
     },
     addNext (next) {
+      let self = this
       this.triggerLoad()
-      this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1})
-      if (next) {
-        this.$store.dispatch('queue/next')
-      }
+      this.getPlayableTracks().then((tracks) => {
+        self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1})
+        if (next) {
+          self.$store.dispatch('queue/next')
+        }
+      })
     }
   }
 }
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index b5cbd8f81e0bcd1c9bca8cdc7dd192044f94d968..75a01c52e015b419d4c919b90bfe97e7db8ab02b 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -3,7 +3,7 @@
     <div class="player">
       <audio-track
         ref="currentAudio"
-        v-if="currentTrack"
+        v-if="renderAudio && currentTrack"
         :key="(currentIndex, currentTrack.id)"
         :is-current="true"
         :start-time="$store.state.player.currentTime"
@@ -30,7 +30,12 @@
               </router-link>
             </div>
             <div class="description">
-              <track-favorite-icon :track="currentTrack"></track-favorite-icon>
+              <track-favorite-icon
+                v-if="$store.state.auth.authenticated"
+                :track="currentTrack"></track-favorite-icon>
+              <track-playlist-icon
+                v-if="$store.state.auth.authenticated"
+                :track="currentTrack"></track-playlist-icon>
             </div>
           </div>
         </div>
@@ -140,17 +145,20 @@ import ColorThief from '@/vendor/color-thief'
 import Track from '@/audio/track'
 import AudioTrack from '@/components/audio/Track'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
+import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
 
 export default {
   name: 'player',
   components: {
     TrackFavoriteIcon,
+    TrackPlaylistIcon,
     GlobalEvents,
     AudioTrack
   },
   data () {
     let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]]
     return {
+      renderAudio: true,
       sliderVolume: this.volume,
       Track: Track,
       defaultAmbiantColors: defaultAmbiantColors,
@@ -163,7 +171,6 @@ export default {
   },
   methods: {
     ...mapActions({
-      pause: 'player/pause',
       togglePlay: 'player/togglePlay',
       clean: 'queue/clean',
       next: 'queue/next',
@@ -230,6 +237,17 @@ export default {
         this.ambiantColors = this.defaultAmbiantColors
       }
     },
+    currentIndex (newValue, oldValue) {
+      if (newValue !== oldValue) {
+        // why this? to ensure the audio tag is deleted and fully
+        // rerendered, so we don't have any issues with cached position
+        // or whatever
+        this.renderAudio = false
+        this.$nextTick(() => {
+          this.renderAudio = true
+        })
+      }
+    },
     volume (newValue) {
       this.sliderVolume = newValue
     },
@@ -270,6 +288,7 @@ export default {
     cursor: pointer
 }
 .track-area {
+  margin-top: 0;
   .header, .meta, .artist, .album {
     color: white !important;
   }
@@ -373,4 +392,5 @@ export default {
 .ui.feed.icon {
   margin: 0;
 }
+
 </style>
diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue
index 988ff0a7d7ccb78707e3b66c3474e380e6838e72..99896d04beda7bc51a559fb10b8205a45e254610 100644
--- a/front/src/components/audio/SearchBar.vue
+++ b/front/src/components/audio/SearchBar.vue
@@ -30,6 +30,9 @@ export default {
       },
       apiSettings: {
         beforeXHR: function (xhrObject) {
+          if (!self.$store.state.auth.authenticated) {
+            return xhrObject
+          }
           xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
           return xhrObject
         },
diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue
index ea42f06a8392871cca63b6d5fbe62d5f26d75455..bb37b2b2f86635e3324652f4245b08e3fa4a26a9 100644
--- a/front/src/components/audio/album/Card.vue
+++ b/front/src/components/audio/album/Card.vue
@@ -6,12 +6,13 @@
           <img v-else src="../../../assets/audio/default-cover.png">
         </div>
         <div class="header">
-          <router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">{{ album.title }}</router-link>
+          <router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">{{ album.title }} </router-link>
         </div>
         <div class="meta">
-          By <router-link :to="{name: 'library.artists.detail', params: {id: album.artist.id }}">
-            {{ album.artist.name }}
-          </router-link>
+          <span>
+            By <router-link tag="span" :to="{name: 'library.artists.detail', params: {id: album.artist.id }}">
+              {{ album.artist.name }}</router-link>
+          </span><span class="time" v-if="album.release_date">– {{ album.release_date | year }}</span>
         </div>
         <div class="description" v-if="mode === 'rich'">
           <table class="ui very basic fixed single line compact unstackable table">
diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8310e89c4a4ad6ab749f2386003f1c59ae7dccea
--- /dev/null
+++ b/front/src/components/audio/track/Row.vue
@@ -0,0 +1,70 @@
+<template>
+  <tr>
+    <td>
+      <play-button class="basic icon" :discrete="true" :track="track"></play-button>
+    </td>
+    <td>
+      <img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
+      <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
+    </td>
+    <td colspan="6">
+      <router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
+        <template v-if="displayPosition && track.position">
+          {{ track.position }}.
+        </template>
+        {{ track.title }}
+      </router-link>
+    </td>
+    <td colspan="6">
+      <router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
+        {{ track.artist.name }}
+      </router-link>
+    </td>
+    <td colspan="6">
+      <router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
+        {{ track.album.title }}
+      </router-link>
+    </td>
+    <td>
+      <track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon>
+      <track-playlist-icon
+        v-if="$store.state.auth.authenticated"
+        :track="track"></track-playlist-icon>
+    </td>
+  </tr>
+</template>
+
+<script>
+import backend from '@/audio/backend'
+
+import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
+import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
+import PlayButton from '@/components/audio/PlayButton'
+
+export default {
+  props: {
+    track: {type: Object, required: true},
+    displayPosition: {type: Boolean, default: false}
+  },
+  components: {
+    TrackFavoriteIcon,
+    TrackPlaylistIcon,
+    PlayButton
+  },
+  data () {
+    return {
+      backend: backend
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style lang="scss" scoped>
+
+tr:not(:hover) {
+  .favorite-icon:not(.favorited), .playlist-icon {
+    display: none;
+  }
+}
+</style>
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index 00bcf9f7de239ab6f54f1925d7550760aba95c23..512ba1b493d35f71b098d112959a9ac9d3c5e4ef 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -11,34 +11,11 @@
       </tr>
     </thead>
     <tbody>
-      <tr v-for="track in tracks">
-        <td>
-          <play-button class="basic icon" :discrete="true" :track="track"></play-button>
-        </td>
-        <td>
-          <img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
-          <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
-        </td>
-        <td colspan="6">
-          <router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
-            <template v-if="displayPosition && track.position">
-              {{ track.position }}.
-            </template>
-            {{ track.title }}
-          </router-link>
-        </td>
-        <td colspan="6">
-          <router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
-            {{ track.artist.name }}
-          </router-link>
-        </td>
-        <td colspan="6">
-          <router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
-            {{ track.album.title }}
-          </router-link>
-        </td>
-        <td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td>
-      </tr>
+      <track-row
+        :display-position="displayPosition"
+        :track="track"
+        :key="index + '-' + track.id"
+        v-for="(track, index) in tracks"></track-row>
     </tbody>
     <tfoot class="full-width">
       <tr>
@@ -83,9 +60,8 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.aut
 
 <script>
 import backend from '@/audio/backend'
-import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
-import PlayButton from '@/components/audio/PlayButton'
 
+import TrackRow from '@/components/audio/track/Row'
 import Modal from '@/components/semantic/Modal'
 
 export default {
@@ -95,8 +71,7 @@ export default {
   },
   components: {
     Modal,
-    TrackFavoriteIcon,
-    PlayButton
+    TrackRow
   },
   data () {
     return {
diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..525b4c48ff259babf599b54a2368ad2017c677d3
--- /dev/null
+++ b/front/src/components/common/DangerousButton.vue
@@ -0,0 +1,48 @@
+<template>
+  <div @click="showModal = true" :class="['ui', color, {disabled: disabled}, 'button']" :disabled="disabled">
+    <slot></slot>
+
+    <modal class="small" :show.sync="showModal">
+      <div class="header">
+        <slot name="modal-header">Do you want to confirm this action?</slot>
+      </div>
+      <div class="scrolling content">
+        <div class="description">
+          <slot name="modal-content"></slot>
+        </div>
+      </div>
+      <div class="actions">
+        <div class="ui cancel button">Cancel</div>
+        <div :class="['ui', 'confirm', color, 'button']" @click="confirm">
+          <slot name="modal-confirm">Confirm</slot>
+        </div>
+      </div>
+    </modal>
+  </div>
+
+</template>
+<script>
+import Modal from '@/components/semantic/Modal'
+
+export default {
+  props: {
+    action: {type: Function, required: true},
+    disabled: {type: Boolean, default: false},
+    color: {type: String, default: 'red'}
+  },
+  components: {
+    Modal
+  },
+  data () {
+    return {
+      showModal: false
+    }
+  },
+  methods: {
+    confirm () {
+      this.showModal = false
+      this.action()
+    }
+  }
+}
+</script>
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
index b1d7d61041d31df9f9e47076e6ae273c8119c33b..79bbcf1b93a4a74ecf3c3b3d3d4e724870d7120c 100644
--- a/front/src/components/globals.js
+++ b/front/src/components/globals.js
@@ -8,4 +8,8 @@ import Username from '@/components/common/Username'
 
 Vue.component('username', Username)
 
+import DangerousButton from '@/components/common/DangerousButton'
+
+Vue.component('dangerous-button', DangerousButton)
+
 export default {}
diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue
index 7724428cac36ced03671c461dd76bea3acdd6364..9a546aa0e9fa98aa4bdda13fcde57fd6ad4b2986 100644
--- a/front/src/components/library/Artist.vue
+++ b/front/src/components/library/Artist.vue
@@ -31,7 +31,7 @@
       <div class="ui vertical stripe segment">
         <h2>Albums by this artist</h2>
         <div class="ui stackable doubling three column grid">
-          <div class="column" :key="album.id" v-for="album in albums">
+          <div class="column" :key="album.id" v-for="album in sortedAlbums">
             <album-card :mode="'rich'" class="fluid" :album="album"></album-card>
           </div>
         </div>
@@ -41,6 +41,7 @@
 </template>
 
 <script>
+import _ from 'lodash'
 import axios from 'axios'
 import logger from '@/logging'
 import backend from '@/audio/backend'
@@ -83,6 +84,10 @@ export default {
     }
   },
   computed: {
+    sortedAlbums () {
+      let a = this.albums || []
+      return _.orderBy(a, ['release_date'], ['asc'])
+    },
     totalTracks () {
       return this.albums.map((album) => {
         return album.tracks.length
diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue
index 3cf123447128d8198853036ab1be5c0b52ebbc0a..52ccbdd7465c654b28eb53c141f50a6e62ca0209 100644
--- a/front/src/components/library/Artists.vue
+++ b/front/src/components/library/Artists.vue
@@ -129,7 +129,8 @@ export default {
         page: this.page,
         page_size: this.paginateBy,
         name__icontains: this.query,
-        ordering: this.getOrderingAsString()
+        ordering: this.getOrderingAsString(),
+        listenable: 'true'
       }
       logger.default.debug('Fetching artists')
       axios.get(url, {params: params}).then((response) => {
diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue
index e4e22fc09919f815038795762f408ac0a760aa58..40f6808f98f3c382353fd9bb205d3c09cbbcb8bd 100644
--- a/front/src/components/library/Home.vue
+++ b/front/src/components/library/Home.vue
@@ -20,7 +20,7 @@
         </div>
         <div class="column">
           <h2 class="ui header">Music requests</h2>
-          <request-form></request-form>
+          <request-form v-if="$store.state.auth.authenticated"></request-form>
         </div>
       </div>
     </div>
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index 6cd156493f4487683df91fd9314f31fb8807de1d..161d4519b2fab561022fc4a8b8f706e67dc3258c 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -4,8 +4,9 @@
       <router-link class="ui item" to="/library" exact>Browse</router-link>
       <router-link class="ui item" to="/library/artists" exact>Artists</router-link>
       <router-link class="ui item" to="/library/radios" exact>Radios</router-link>
+      <router-link class="ui item" to="/library/playlists" exact>Playlists</router-link>
       <div class="ui secondary right menu">
-        <router-link class="ui item" to="/library/requests/" exact>
+        <router-link v-if="$store.state.auth.authenticated" class="ui item" to="/library/requests/" exact>
           Requests
           <div class="ui teal label">{{ requestsCount }}</div>
         </router-link>
@@ -32,8 +33,11 @@ export default {
   },
   methods: {
     fetchRequestsCount () {
+      if (!this.$store.state.authenticated) {
+        return
+      }
       let self = this
-      axios.get('requests/import-requests', {params: {status: 'pending'}}).then(response => {
+      axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
         self.requestsCount = response.data.count
       })
     }
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index a40409615dc75bb0e29ebe230f626a1757b7a9f4..0437ac88151ad166ea6704579b8069adf35007f2 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -24,6 +24,11 @@
 
           <play-button class="orange" :track="track">Play</play-button>
           <track-favorite-icon :track="track" :button="true"></track-favorite-icon>
+          <track-playlist-icon
+            :button="true"
+            v-if="$store.state.auth.authenticated"
+            :track="track"></track-playlist-icon>
+
           <a :href="wikipediaUrl" target="_blank" class="ui button">
             <i class="wikipedia icon"></i>
             Search on wikipedia
@@ -66,6 +71,7 @@ import logger from '@/logging'
 import backend from '@/audio/backend'
 import PlayButton from '@/components/audio/PlayButton'
 import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
+import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
 
 const FETCH_URL = 'tracks/'
 
@@ -73,6 +79,7 @@ export default {
   props: ['id'],
   components: {
     PlayButton,
+    TrackPlaylistIcon,
     TrackFavoriteIcon
   },
   data () {
diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6dd1b0a0ce477713af1e9dd9e7b33cff40906730
--- /dev/null
+++ b/front/src/components/playlists/Card.vue
@@ -0,0 +1,40 @@
+<template>
+  <div class="ui card">
+    <div class="content">
+      <div class="header">
+        <router-link class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">
+          {{ playlist.name }}
+        </router-link>
+      </div>
+      <div class="meta">
+        <i class="user icon"></i> {{ playlist.user.username }}
+      </div>
+      <div class="meta">
+        <i class="clock icon"></i> Updated <human-date :date="playlist.modification_date"></human-date>
+      </div>
+    </div>
+    <div class="extra content">
+      <span>
+        <i class="sound icon"></i>
+        {{ playlist.tracks_count }} tracks
+      </span>
+      <play-button class="mini basic orange right floated" :playlist="playlist">Play all</play-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import PlayButton from '@/components/audio/PlayButton'
+
+export default {
+  props: ['playlist'],
+  components: {
+    PlayButton
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+
+</style>
diff --git a/front/src/components/playlists/CardList.vue b/front/src/components/playlists/CardList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4d4746090f757dddaf5d33cd4d924440e725ca09
--- /dev/null
+++ b/front/src/components/playlists/CardList.vue
@@ -0,0 +1,34 @@
+<template>
+  <div
+    v-if="playlists.length > 0"
+    v-masonry
+    transition-duration="0"
+    item-selector=".column"
+    percent-position="true"
+    stagger="0"
+    class="ui stackable three column doubling grid">
+    <div
+      v-masonry-tile
+      v-for="playlist in playlists"
+      :key="playlist.id"
+      class="column">
+      <playlist-card class="fluid" :playlist="playlist"></playlist-card>
+    </div>
+  </div>
+</template>
+
+<script>
+
+import PlaylistCard from '@/components/playlists/Card'
+
+export default {
+  props: ['playlists'],
+  components: {
+    PlaylistCard
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c668857ea1f88557b22d77ecb81acdfd5fe52162
--- /dev/null
+++ b/front/src/components/playlists/Editor.vue
@@ -0,0 +1,178 @@
+<template>
+  <div class="ui text container">
+    <playlist-form @updated="$emit('playlist-updated', $event)" :title="false" :playlist="playlist"></playlist-form>
+    <h3 class="ui top attached header">
+      Playlist editor
+    </h3>
+    <div class="ui attached segment">
+      <template v-if="status === 'loading'">
+        <div class="ui active tiny inline loader"></div>
+        Syncing changes to server...
+      </template>
+      <template v-else-if="status === 'errored'">
+        <i class="red close icon"></i>
+        An error occured while saving your changes
+        <div v-if="errors.length > 0" class="ui negative message">
+          <ul class="list">
+            <li v-for="error in errors">{{ error }}</li>
+          </ul>
+        </div>
+      </template>
+      <template v-else-if="status === 'saved'">
+        <i class="green check icon"></i> Changes synced with server
+      </template>
+    </div>
+    <div class="ui bottom attached segment">
+      <div
+        @click="insertMany(queueTracks)"
+        :disabled="queueTracks.length === 0"
+        :class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
+        title="Copy tracks from current queue to playlist">
+          <i class="plus icon"></i> Insert from queue ({{ queueTracks.length }} tracks)</div>
+
+      <dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
+        <i class="eraser icon"></i> Clear playlist
+        <p slot="modal-header">Do you want to clear the playlist "{{ playlist.name }}"?</p>
+        <p slot="modal-content">This will remove all tracks from this playlist and cannot be undone.</p>
+        <p slot="modal-confirm">Clear playlist</p>
+      </dangerous-button>
+      <div class="ui hidden divider"></div>
+      <template v-if="plts.length > 0">
+        <p>Drag and drop rows to reorder tracks in the playlist</p>
+        <table class="ui compact very basic fixed single line unstackable table">
+          <draggable v-model="plts" element="tbody" @update="reorder">
+            <tr v-for="(plt, index) in plts" :key="plt.id">
+              <td class="left aligned">{{ plt.index + 1}}</td>
+              <td class="center aligned">
+                <img class="ui mini image" v-if="plt.track.album.cover" :src="plt.track.album.cover">
+                <img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
+              </td>
+              <td colspan="4">
+                <strong>{{ plt.track.title }}</strong><br />
+                  {{ plt.track.artist.name }}
+              </td>
+              <td class="right aligned">
+                <i @click.stop="removePlt(index)" class="circular red trash icon"></i>
+              </td>
+            </tr>
+          </draggable>
+        </table>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script>
+import {mapState} from 'vuex'
+import axios from 'axios'
+import PlaylistForm from '@/components/playlists/Form'
+
+import draggable from 'vuedraggable'
+
+export default {
+  components: {
+    draggable,
+    PlaylistForm
+  },
+  props: ['playlist', 'playlistTracks'],
+  data () {
+    return {
+      plts: this.playlistTracks,
+      isLoading: false,
+      errors: []
+    }
+  },
+  methods: {
+    success () {
+      this.isLoading = false
+      this.errors = []
+    },
+    errored (errors) {
+      this.isLoading = false
+      this.errors = errors
+    },
+    reorder ({oldIndex, newIndex}) {
+      let self = this
+      self.isLoading = true
+      let plt = this.plts[newIndex]
+      let url = 'playlist-tracks/' + plt.id + '/'
+      axios.patch(url, {index: newIndex}).then((response) => {
+        self.success()
+      }, error => {
+        self.errored(error.backendErrors)
+      })
+    },
+    removePlt (index) {
+      let plt = this.plts[index]
+      this.plts.splice(index, 1)
+      let self = this
+      self.isLoading = true
+      let url = 'playlist-tracks/' + plt.id + '/'
+      axios.delete(url).then((response) => {
+        self.success()
+        self.$store.dispatch('playlists/fetchOwn')
+      }, error => {
+        self.errored(error.backendErrors)
+      })
+    },
+    clearPlaylist () {
+      this.plts = []
+      let self = this
+      self.isLoading = true
+      let url = 'playlists/' + this.playlist.id + '/clear'
+      axios.delete(url).then((response) => {
+        self.success()
+        self.$store.dispatch('playlists/fetchOwn')
+      }, error => {
+        self.errored(error.backendErrors)
+      })
+    },
+    insertMany (tracks) {
+      let self = this
+      let ids = tracks.map(t => {
+        return t.id
+      })
+      self.isLoading = true
+      let url = 'playlists/' + this.playlist.id + '/add/'
+      axios.post(url, {tracks: ids}).then((response) => {
+        response.data.results.forEach(r => {
+          self.plts.push(r)
+        })
+        self.success()
+        self.$store.dispatch('playlists/fetchOwn')
+      }, error => {
+        self.errored(error.backendErrors)
+      })
+    }
+  },
+  computed: {
+    ...mapState({
+      queueTracks: state => state.queue.tracks
+    }),
+    status () {
+      if (this.isLoading) {
+        return 'loading'
+      }
+      if (this.errors.length > 0) {
+        return 'errored'
+      }
+      return 'saved'
+    }
+  },
+  watch: {
+    plts: {
+      handler (newValue) {
+        newValue.forEach((e, i) => {
+          e.index = i
+        })
+        this.$emit('tracks-updated', newValue)
+      },
+      deep: true
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/playlists/Form.vue b/front/src/components/playlists/Form.vue
new file mode 100644
index 0000000000000000000000000000000000000000..634e310bcdc2bb57484a8e048f65cfd8834da0d0
--- /dev/null
+++ b/front/src/components/playlists/Form.vue
@@ -0,0 +1,125 @@
+<template>
+  <form class="ui form" @submit.prevent="submit()">
+    <h4 v-if="title" class="ui header">Create a new playlist</h4>
+    <div v-if="success" class="ui positive message">
+      <div class="header">
+        <template v-if="playlist">
+          Playlist updated
+        </template>
+        <template v-else>
+          Playlist created
+        </template>
+      </div>
+    </div>
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header">We cannot create the playlist</div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div class="three fields">
+      <div class="field">
+        <label>Playlist name</label>
+        <input v-model="name" required type="text" placeholder="My awesome playlist" />
+      </div>
+      <div class="field">
+        <label>Playlist visibility</label>
+        <select class="ui dropdown" v-model="privacyLevel">
+          <option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option>
+        </select>
+      </div>
+      <div class="field">
+        <label>&nbsp;</label>
+        <button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit">
+          <template v-if="playlist">Update playlist</template>
+          <template v-else>Create playlist</template>
+        </button>
+      </div>
+    </div>
+  </form>
+</template>
+
+<script>
+import $ from 'jquery'
+import axios from 'axios'
+
+import logger from '@/logging'
+
+export default {
+  props: {
+    title: {type: Boolean, default: true},
+    playlist: {type: Object, default: null}
+  },
+  mounted () {
+    $(this.$el).find('.dropdown').dropdown()
+  },
+  data () {
+    let d = {
+      errors: [],
+      success: false,
+      isLoading: false,
+      privacyLevelChoices: [
+        {
+          value: 'me',
+          label: 'Nobody except me'
+        },
+        {
+          value: 'instance',
+          label: 'Everyone on this instance'
+        },
+        {
+          value: 'everyone',
+          label: 'Everyone'
+        }
+      ]
+    }
+    if (this.playlist) {
+      d.name = this.playlist.name
+      d.privacyLevel = this.playlist.privacy_level
+    } else {
+      d.privacyLevel = this.$store.state.auth.profile.privacy_level
+      d.name = ''
+    }
+    return d
+  },
+  methods: {
+    submit () {
+      this.isLoading = true
+      this.success = false
+      this.errors = []
+      let self = this
+      let payload = {
+        name: this.name,
+        privacy_level: this.privacyLevel
+      }
+
+      let promise
+      let url
+      if (this.playlist) {
+        url = `playlists/${this.playlist.id}/`
+        promise = axios.patch(url, payload)
+      } else {
+        url = 'playlists/'
+        promise = axios.post(url, payload)
+      }
+      return promise.then(response => {
+        self.success = true
+        self.isLoading = false
+        if (!self.playlist) {
+          self.name = ''
+        }
+        self.$emit('updated', response.data)
+        self.$store.dispatch('playlists/fetchOwn')
+      }, error => {
+        logger.default.error('Error while creating playlist')
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5fdf585dfbd7ce44ee9fcb4a3a9f6610e9d00d6c
--- /dev/null
+++ b/front/src/components/playlists/PlaylistModal.vue
@@ -0,0 +1,127 @@
+<template>
+  <modal @update:show="update" :show="$store.state.playlists.showModal">
+    <div class="header">
+      Manage playlists
+    </div>
+    <div class="scrolling content">
+      <div class="description">
+        <template v-if="track">
+          <h4 class="ui header">Current track</h4>
+          <div>
+            "{{ track.title }}" by {{ track.artist.name }}
+          </div>
+          <div class="ui divider"></div>
+        </template>
+
+        <playlist-form></playlist-form>
+        <div class="ui divider"></div>
+        <div v-if="errors.length > 0" class="ui negative message">
+          <div class="header">We cannot add the track to a playlist</div>
+          <ul class="list">
+            <li v-for="error in errors">{{ error }}</li>
+          </ul>
+        </div>
+        </div>
+        <h4 class="ui header">Available playlists</h4>
+        <table class="ui unstackable very basic table">
+          <thead>
+            <tr>
+              <th></th>
+              <th>Name</th>
+              <th class="sorted descending">Last modification</th>
+              <th>Tracks</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="playlist in sortedPlaylists">
+              <td>
+                <router-link
+                  class="ui icon basic small button"
+                  :to="{name: 'library.playlists.detail', params: {id: playlist.id }, query: {mode: 'edit'}}"><i class="ui pencil icon"></i></router-link>
+              </td>
+              <td>
+                <router-link :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">{{ playlist.name }}</router-link></td>
+              <td><human-date :date="playlist.modification_date"></human-date></td>
+              <td>{{ playlist.tracks_count }}</td>
+              <td>
+                <div
+                  v-if="track"
+                  class="ui green icon basic small right floated button"
+                  title="Add to this playlist"
+                  @click="addToPlaylist(playlist.id)">
+                  <i class="plus icon"></i> Add track
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+    <div class="actions">
+      <div class="ui cancel button">Cancel</div>
+    </div>
+  </modal>
+</template>
+
+<script>
+import _ from 'lodash'
+import axios from 'axios'
+import {mapState} from 'vuex'
+
+import logger from '@/logging'
+import Modal from '@/components/semantic/Modal'
+import PlaylistForm from '@/components/playlists/Form'
+
+export default {
+  components: {
+    Modal,
+    PlaylistForm
+  },
+  data () {
+    return {
+      errors: []
+    }
+  },
+  methods: {
+    update (v) {
+      this.$store.commit('playlists/showModal', v)
+    },
+    addToPlaylist (playlistId) {
+      let self = this
+      let payload = {
+        track: this.track.id,
+        playlist: playlistId
+      }
+      return axios.post('playlist-tracks/', payload).then(response => {
+        logger.default.info('Successfully added track to playlist')
+        self.update(false)
+        self.$store.dispatch('playlists/fetchOwn')
+      }, error => {
+        logger.default.error('Error while adding track to playlist')
+        self.errors = error.backendErrors
+      })
+    }
+  },
+  computed: {
+    ...mapState({
+      playlists: state => state.playlists.playlists,
+      track: state => state.playlists.modalTrack
+    }),
+    sortedPlaylists () {
+      let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }])
+      p.reverse()
+      return p
+    }
+  },
+  watch: {
+    '$store.state.route.path' () {
+      this.$store.commit('playlists/showModal', false)
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/playlists/TrackPlaylistIcon.vue b/front/src/components/playlists/TrackPlaylistIcon.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bba4c515b0c90644182016f786c06e60b233b552
--- /dev/null
+++ b/front/src/components/playlists/TrackPlaylistIcon.vue
@@ -0,0 +1,34 @@
+<template>
+  <button
+    @click="$store.commit('playlists/chooseTrack', track)"
+    v-if="button"
+    :class="['ui', 'button']">
+    <i class="list icon"></i>
+    Add to playlist...
+  </button>
+  <i
+    v-else
+    @click="$store.commit('playlists/chooseTrack', track)"
+    :class="['playlist-icon', 'list', 'link', 'icon']"
+    title="Add to playlist...">
+  </i>
+</template>
+
+<script>
+
+export default {
+  props: {
+    track: {type: Object},
+    button: {type: Boolean, default: false}
+  },
+  data () {
+    return {
+      showModal: false
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue
index 819aa8651f3509827b0460c54c9cec9ded18d23b..0869313a875eb153812a5b9fce0cf1ea1a932d81 100644
--- a/front/src/components/radios/Button.vue
+++ b/front/src/components/radios/Button.vue
@@ -31,7 +31,7 @@ export default {
       if (!state.running) {
         return false
       } else {
-        return current.type === this.type & current.objectId === this.objectId
+        return current.type === this.type && current.objectId === this.objectId && current.customRadioId === this.customRadioId
       }
     }
   }
diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue
index ec7a5a0884262ac2437570cd6a360e231eaccaba..fec8fdd0595ffd8866d93e54bd2cffaedd2f52d5 100644
--- a/front/src/components/semantic/Modal.vue
+++ b/front/src/components/semantic/Modal.vue
@@ -2,7 +2,7 @@
   <div :class="['ui', {'active': show}, 'modal']">
     <i class="close icon"></i>
     <slot>
-      
+
     </slot>
   </div>
 </template>
@@ -19,26 +19,38 @@ export default {
       control: null
     }
   },
-  mounted () {
-    this.control = $(this.$el).modal({
-      onApprove: function () {
-        this.$emit('approved')
-      }.bind(this),
-      onDeny: function () {
-        this.$emit('deny')
-      }.bind(this),
-      onHidden: function () {
-        this.$emit('update:show', false)
-      }.bind(this)
-    })
+  beforeDestroy () {
+    if (this.control) {
+      this.control.remove()
+    }
+  },
+  methods: {
+    initModal () {
+      this.control = $(this.$el).modal({
+        duration: 100,
+        onApprove: function () {
+          this.$emit('approved')
+        }.bind(this),
+        onDeny: function () {
+          this.$emit('deny')
+        }.bind(this),
+        onHidden: function () {
+          this.$emit('update:show', false)
+        }.bind(this)
+      })
+    }
   },
   watch: {
     show: {
       handler (newValue) {
         if (newValue) {
+          this.initModal()
           this.control.modal('show')
         } else {
-          this.control.modal('hide')
+          if (this.control) {
+            this.control.modal('hide')
+            this.control.remove()
+          }
         }
       }
     }
diff --git a/front/src/filters.js b/front/src/filters.js
index 22d93149bb402657e56d37d9ef68cf2959d9c767..afc393d402e8f80e046d2bd4443e3f03da2f07a7 100644
--- a/front/src/filters.js
+++ b/front/src/filters.js
@@ -35,6 +35,12 @@ export function momentFormat (date, format) {
 
 Vue.filter('moment', momentFormat)
 
+export function year (date) {
+  return moment(date).year()
+}
+
+Vue.filter('year', year)
+
 export function capitalize (str) {
   return str.charAt(0).toUpperCase() + str.slice(1)
 }
diff --git a/front/src/router/index.js b/front/src/router/index.js
index ba9aadd981d844972a874a5fe0870a640b2f95e8..802844461325560a48cf8002f21ef8e3f5462594 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -21,7 +21,8 @@ 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 PlaylistDetail from '@/views/playlists/Detail'
+import PlaylistList from '@/views/playlists/List'
 import Favorites from '@/components/favorites/List'
 
 Vue.use(Router)
@@ -110,6 +111,25 @@ export default new Router({
         },
         { path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true },
         { path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true },
+        {
+          path: 'playlists/',
+          name: 'library.playlists.browse',
+          component: PlaylistList,
+          props: (route) => ({
+            defaultOrdering: route.query.ordering,
+            defaultQuery: route.query.query,
+            defaultPaginateBy: route.query.paginateBy,
+            defaultPage: route.query.page
+          })
+        },
+        {
+          path: 'playlists/:id',
+          name: 'library.playlists.detail',
+          component: PlaylistDetail,
+          props: (route) => ({
+            id: route.params.id,
+            defaultEdit: route.query.mode === 'edit' })
+        },
         { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
         { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
         { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index 7944cae0836f58a6eed111b2134fb9ab702123ef..e72e1968f52f1ca3593abfbbdabf4e06fda7b1d5 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -91,6 +91,7 @@ export default {
         commit('profile', data)
         commit('username', data.username)
         dispatch('favorites/fetch', null, {root: true})
+        dispatch('playlists/fetchOwn', null, {root: true})
         Object.keys(data.permissions).forEach(function (key) {
           // this makes it easier to check for permissions in templates
           commit('permission', {key, status: data.permissions[String(key)].status})
diff --git a/front/src/store/index.js b/front/src/store/index.js
index 2453c0e7134124e3f9d9dc3ffac47d2558a9827d..298fa04ec13166fead7a12955636d3bc4340948a 100644
--- a/front/src/store/index.js
+++ b/front/src/store/index.js
@@ -8,6 +8,7 @@ import instance from './instance'
 import queue from './queue'
 import radios from './radios'
 import player from './player'
+import playlists from './playlists'
 import ui from './ui'
 
 Vue.use(Vuex)
@@ -20,6 +21,7 @@ export default new Vuex.Store({
     instance,
     queue,
     radios,
+    playlists,
     player
   },
   plugins: [
diff --git a/front/src/store/playlists.js b/front/src/store/playlists.js
new file mode 100644
index 0000000000000000000000000000000000000000..b3ed3ab235bf09456a4b7cc9d7129963443488ca
--- /dev/null
+++ b/front/src/store/playlists.js
@@ -0,0 +1,33 @@
+import axios from 'axios'
+
+export default {
+  namespaced: true,
+  state: {
+    playlists: [],
+    showModal: false,
+    modalTrack: null
+  },
+  mutations: {
+    playlists (state, value) {
+      state.playlists = value
+    },
+    chooseTrack (state, value) {
+      state.showModal = true
+      state.modalTrack = value
+    },
+    showModal (state, value) {
+      state.showModal = value
+    }
+  },
+  actions: {
+    fetchOwn ({commit, rootState}) {
+      let userId = rootState.auth.profile.id
+      if (!userId) {
+        return
+      }
+      return axios.get('playlists/', {params: {user: userId}}).then((response) => {
+        commit('playlists', response.data.results)
+      })
+    }
+  }
+}
diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6c3a988fd195ae3df5d4e597a69f4bb3dee9c40b
--- /dev/null
+++ b/front/src/views/playlists/Detail.vue
@@ -0,0 +1,115 @@
+<template>
+  <div>
+    <div v-if="isLoading" class="ui vertical segment">
+      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+    </div>
+    <div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment">
+      <div class="segment-content">
+        <h2 class="ui center aligned icon header">
+          <i class="circular inverted list yellow icon"></i>
+          <div class="content">
+            {{ playlist.name }}
+            <div class="sub header">
+              Playlist containing {{ playlistTracks.length }} tracks,
+              by <username :username="playlist.user.username"></username>
+            </div>
+          </div>
+        </h2>
+        <div class="ui hidden divider"></div>
+        </button>
+        <play-button class="orange" :tracks="tracks">Play all</play-button>
+        <button
+          class="ui icon button"
+          v-if="playlist.user.id === $store.state.auth.profile.id"
+          @click="edit = !edit">
+          <i class="pencil icon"></i>
+          <template v-if="edit">End edition</template>
+          <template v-else>Edit...</template>
+        </button>
+        <dangerous-button class="labeled icon" :action="deletePlaylist">
+          <i class="trash icon"></i> Delete
+          <p slot="modal-header">Do you want to delete the playlist "{{ playlist.name }}"?</p>
+          <p slot="modal-content">This will completely delete this playlist and cannot be undone.</p>
+          <p slot="modal-confirm">Delete playlist</p>
+        </dangerous-button>
+      </div>
+    </div>
+    <div class="ui vertical stripe segment">
+      <template v-if="edit">
+        <playlist-editor
+          @playlist-updated="playlist = $event"
+          @tracks-updated="updatePlts"
+          :playlist="playlist" :playlist-tracks="playlistTracks"></playlist-editor>
+      </template>
+      <template v-else>
+        <h2>Tracks</h2>
+        <track-table :display-position="true" :tracks="tracks"></track-table>
+      </template>
+    </div>
+  </div>
+</template>
+<script>
+import axios from 'axios'
+import TrackTable from '@/components/audio/track/Table'
+import RadioButton from '@/components/radios/Button'
+import PlayButton from '@/components/audio/PlayButton'
+import PlaylistEditor from '@/components/playlists/Editor'
+
+export default {
+  props: {
+    id: {required: true},
+    defaultEdit: {type: Boolean, default: false}
+  },
+  components: {
+    PlaylistEditor,
+    TrackTable,
+    PlayButton,
+    RadioButton
+  },
+  data: function () {
+    return {
+      edit: this.defaultEdit,
+      isLoading: false,
+      playlist: null,
+      tracks: [],
+      playlistTracks: []
+    }
+  },
+  created: function () {
+    this.fetch()
+  },
+  methods: {
+    updatePlts (v) {
+      this.playlistTracks = v
+      this.tracks = v.map((e, i) => {
+        let track = e.track
+        track.position = i + 1
+        return track
+      })
+    },
+    fetch: function () {
+      let self = this
+      self.isLoading = true
+      let url = 'playlists/' + this.id + '/'
+      axios.get(url).then((response) => {
+        self.playlist = response.data
+        axios.get(url + 'tracks').then((response) => {
+          self.updatePlts(response.data.results)
+        }).then(() => {
+          self.isLoading = false
+        })
+      })
+    },
+    deletePlaylist () {
+      let self = this
+      let url = 'playlists/' + this.id + '/'
+      axios.delete(url).then((response) => {
+        self.$store.dispatch('playlists/fetchOwn')
+        self.$router.push({
+          path: '/library'
+        })
+      })
+    }
+  }
+}
+</script>
diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue
new file mode 100644
index 0000000000000000000000000000000000000000..fc5dcbe54b441c4ee2009ba4ebe7881173ab108a
--- /dev/null
+++ b/front/src/views/playlists/List.vue
@@ -0,0 +1,158 @@
+<template>
+  <div>
+    <div class="ui vertical stripe segment">
+      <h2 class="ui header">Browsing playlists</h2>
+      <div :class="['ui', {'loading': isLoading}, 'form']">
+        <template v-if="$store.state.auth.authenticated">
+          <button
+            @click="$store.commit('playlists/chooseTrack', null)"
+            class="ui basic green button">Manage your playlists</button>
+          <div class="ui hidden divider"></div>
+        </template>
+        <div class="fields">
+          <div class="field">
+            <label>Search</label>
+            <input type="text" v-model="query" placeholder="Enter an playlist name..."/>
+          </div>
+          <div class="field">
+            <label>Ordering</label>
+            <select class="ui dropdown" v-model="ordering">
+              <option v-for="option in orderingOptions" :value="option[0]">
+                {{ option[1] }}
+              </option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Ordering direction</label>
+            <select class="ui dropdown" v-model="orderingDirection">
+              <option value="">Ascending</option>
+              <option value="-">Descending</option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Results per page</label>
+            <select class="ui dropdown" v-model="paginateBy">
+              <option :value="parseInt(12)">12</option>
+              <option :value="parseInt(25)">25</option>
+              <option :value="parseInt(50)">50</option>
+            </select>
+          </div>
+        </div>
+      </div>
+      <div class="ui hidden divider"></div>
+      <playlist-card-list v-if="result" :playlists="result.results"></playlist-card-list>
+      <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 OrderingMixin from '@/components/mixins/Ordering'
+import PaginationMixin from '@/components/mixins/Pagination'
+import PlaylistCardList from '@/components/playlists/CardList'
+import Pagination from '@/components/Pagination'
+
+const FETCH_URL = 'playlists/'
+
+export default {
+  mixins: [OrderingMixin, PaginationMixin],
+  props: {
+    defaultQuery: {type: String, required: false, default: ''}
+  },
+  components: {
+    PlaylistCardList,
+    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'],
+        ['modification_date', 'Last modification date'],
+        ['name', 'Name']
+      ]
+    }
+  },
+  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,
+        q: this.query,
+        ordering: this.getOrderingAsString()
+      }
+      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/test/unit/specs/filters/filters.spec.js b/front/test/unit/specs/filters/filters.spec.js
index c2b43da44a83eb0a983d981c7891853a634fb31a..f4789ca48c640c0da83ab7d89cf148f60270854c 100644
--- a/front/test/unit/specs/filters/filters.spec.js
+++ b/front/test/unit/specs/filters/filters.spec.js
@@ -1,4 +1,4 @@
-import {truncate, markdown, ago, capitalize} from '@/filters'
+import {truncate, markdown, ago, capitalize, year} from '@/filters'
 
 describe('filters', () => {
   describe('truncate', () => {
@@ -32,6 +32,13 @@ describe('filters', () => {
       expect(output).to.equal('a few seconds ago')
     })
   })
+  describe('year', () => {
+    it('works', () => {
+      const input = '2017-07-13'
+      let output = year(input)
+      expect(output).to.equal(2017)
+    })
+  })
   describe('capitalize', () => {
     it('works', () => {
       const input = 'hello world'
diff --git a/front/test/unit/specs/store/auth.spec.js b/front/test/unit/specs/store/auth.spec.js
index 3271f5168f335a95f5e2799007bd3ec64cf77155..518dc10d4db29f680567d6c996853086cd6b4516 100644
--- a/front/test/unit/specs/store/auth.spec.js
+++ b/front/test/unit/specs/store/auth.spec.js
@@ -180,7 +180,8 @@ describe('store/auth', () => {
           { type: 'permission', payload: {key: 'admin', status: true} }
         ],
         expectedActions: [
-          { type: 'favorites/fetch', payload: null, options: {root: true} }
+          { type: 'favorites/fetch', payload: null, options: {root: true} },
+          { type: 'playlists/fetchOwn', payload: null, options: {root: true} },
         ]
       }, done)
     })
diff --git a/front/test/unit/specs/store/playlists.spec.js b/front/test/unit/specs/store/playlists.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..e82af60bbb470f59bae63d1b2fe89ee30ca6bbb5
--- /dev/null
+++ b/front/test/unit/specs/store/playlists.spec.js
@@ -0,0 +1,36 @@
+var sinon = require('sinon')
+import moxios from 'moxios'
+import store from '@/store/playlists'
+
+import { testAction } from '../../utils'
+
+describe('store/playlists', () => {
+  var sandbox
+
+  beforeEach(function () {
+    sandbox = sinon.sandbox.create()
+    moxios.install()
+  })
+  afterEach(function () {
+    sandbox.restore()
+    moxios.uninstall()
+  })
+
+  describe('mutations', () => {
+    it('set playlists', () => {
+      const state = { playlists: [] }
+      store.mutations.playlists(state, [{id: 1, name: 'test'}])
+      expect(state.playlists).to.deep.equal([{id: 1, name: 'test'}])
+    })
+  })
+  describe('actions', () => {
+    it('fetchOwn does nothing with no user', (done) => {
+      testAction({
+        action: store.actions.fetchOwn,
+        payload: null,
+        params: {state: { playlists: [] }, rootState: {auth: {profile: {}}}},
+        expectedMutations: []
+      }, done)
+    })
+  })
+})