Merge branch '3-playlists' into 'develop'
Resolve "Playlists integration" Closes #3, #93, and #94 See merge request funkwhale/funkwhale!98
This commit is contained in:
commit
7093214be7
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
]
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
|
@ -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={
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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')},
|
||||
),
|
||||
]
|
|
@ -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(),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
||||
return plt
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
@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(MPTTModel):
|
||||
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
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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])
|
|
@ -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
|
|
@ -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
|
|
@ -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]
|
|
@ -0,0 +1 @@
|
|||
Playlists are here \o/ :tada: (#3, #93, #94)
|
|
@ -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')
|
||||
|
|
|
@ -64,7 +64,6 @@ export default {
|
|||
}
|
||||
}
|
||||
})
|
||||
console.log(final)
|
||||
return final
|
||||
},
|
||||
maxPage: function () {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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> </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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue