diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 000289e515a154390d2f11b864a8746abee580ad..077566d1c6a82e329f334a7fe94764cafbd92a70 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -57,7 +57,6 @@ THIRD_PARTY_APPS = (
     'taggit',
     'rest_auth',
     'rest_auth.registration',
-    'mptt',
     'dynamic_preferences',
     'django_filters',
     'cacheops',
@@ -383,3 +382,6 @@ CACHEOPS = {
 # 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/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/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/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/requirements/base.txt b/api/requirements/base.txt
index bfa1a47db50ffbc21f4eb30e4926306f4953702a..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
diff --git a/api/setup.cfg b/api/setup.cfg
index 954b4d1966887553aaee23b084edbe3ad2833e7c..34daa8c6834452229971467c7876400b842b64c1 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -6,7 +6,7 @@ exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
 max-line-length = 120
 exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
 
-[pytest]
+[tool:pytest]
 DJANGO_SETTINGS_MODULE=config.settings.test
 python_files = tests.py test_*.py *_tests.py
 testpaths = tests
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 4ff1a8ee789fa0559c129d05ba0e87da723b8795..62bc5ada676327aa1d5044c7bd31eaea45904dea 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -1,8 +1,11 @@
+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
 
@@ -27,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
 
 
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/changes/changelog.d/3.feature b/changes/changelog.d/3.feature
new file mode 100644
index 0000000000000000000000000000000000000000..e2b962c4436186b4345c0316430a769602ae3c65
--- /dev/null
+++ b/changes/changelog.d/3.feature
@@ -0,0 +1 @@
+Playlists are here \o/ :tada: (#3, #93, #94)
diff --git a/front/src/App.vue b/front/src/App.vue
index 347f19e30ebbf071bb4c5014649d81650942c84b..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')
diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue
index 57ae4235dfe70c50854bc6d2bbbb44edb4b40ffb..47cf5183ab31aeb6fa4575139d42db73fc53a550 100644
--- a/front/src/components/Pagination.vue
+++ b/front/src/components/Pagination.vue
@@ -64,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 179ffedd738d93c76322ea6e06809aae3afe174c..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>
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 0c5ed44b6ff3493518f21c745fac25c06bb4329d..75a01c52e015b419d4c919b90bfe97e7db8ab02b 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -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,11 +145,13 @@ 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
   },
@@ -281,6 +288,7 @@ export default {
     cursor: pointer
 }
 .track-area {
+  margin-top: 0;
   .header, .meta, .artist, .album {
     color: white !important;
   }
@@ -384,4 +392,5 @@ export default {
 .ui.feed.icon {
   margin: 0;
 }
+
 </style>
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/Library.vue b/front/src/components/library/Library.vue
index c209221274940f7fa73763cffa3f9566855cafa8..161d4519b2fab561022fc4a8b8f706e67dc3258c 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -4,6 +4,7 @@
       <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 v-if="$store.state.auth.authenticated" class="ui item" to="/library/requests/" exact>
           Requests
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/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/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/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)
+    })
+  })
+})