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:
Eliot Berriot 2018-03-21 19:59:31 +00:00
commit 7093214be7
50 changed files with 2079 additions and 185 deletions

View File

@ -57,7 +57,6 @@ THIRD_PARTY_APPS = (
'taggit', 'taggit',
'rest_auth', 'rest_auth',
'rest_auth.registration', 'rest_auth.registration',
'mptt',
'dynamic_preferences', 'dynamic_preferences',
'django_filters', 'django_filters',
'cacheops', 'cacheops',
@ -383,3 +382,6 @@ CACHEOPS = {
# Custom Admin URL, use {% url 'admin:index' %} # Custom Admin URL, use {% url 'admin:index' %}
ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/') ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
CSRF_USE_SESSIONS = True CSRF_USE_SESSIONS = True
# Playlist settings
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)

View File

@ -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'
]
})

View File

@ -1,4 +1,7 @@
import operator
from django.conf import settings from django.conf import settings
from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions from rest_framework.permissions import BasePermission, DjangoModelPermissions
@ -20,3 +23,39 @@ class HasModelPermission(DjangoModelPermissions):
""" """
def get_required_permissions(self, method, model_cls): def get_required_permissions(self, method, model_cls):
return super().get_required_permissions(method, self.model) 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

View File

@ -5,13 +5,13 @@ from . import models
@admin.register(models.Playlist) @admin.register(models.Playlist)
class PlaylistAdmin(admin.ModelAdmin): class PlaylistAdmin(admin.ModelAdmin):
list_display = ['name', 'user', 'is_public', 'creation_date'] list_display = ['name', 'user', 'privacy_level', 'creation_date']
search_fields = ['name', ] search_fields = ['name', ]
list_select_related = True list_select_related = True
@admin.register(models.PlaylistTrack) @admin.register(models.PlaylistTrack)
class PlaylistTrackAdmin(admin.ModelAdmin): class PlaylistTrackAdmin(admin.ModelAdmin):
list_display = ['playlist', 'track', 'position', ] list_display = ['playlist', 'track', 'index']
search_fields = ['track__name', 'playlist__name'] search_fields = ['track__name', 'playlist__name']
list_select_related = True list_select_related = True

View File

@ -1,6 +1,7 @@
import factory import factory
from funkwhale_api.factories import registry from funkwhale_api.factories import registry
from funkwhale_api.music.factories import TrackFactory
from funkwhale_api.users.factories import UserFactory from funkwhale_api.users.factories import UserFactory
@ -11,3 +12,12 @@ class PlaylistFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = 'playlists.Playlist' model = 'playlists.Playlist'
@registry.register
class PlaylistTrackFactory(factory.django.DjangoModelFactory):
playlist = factory.SubFactory(PlaylistFactory)
track = factory.SubFactory(TrackFactory)
class Meta:
model = 'playlists.PlaylistTrack'

View File

@ -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)

View File

@ -4,7 +4,6 @@ from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
from django.conf import settings from django.conf import settings
import django.utils.timezone import django.utils.timezone
import mptt.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -34,7 +33,7 @@ class Migration(migrations.Migration):
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('position', 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)), ('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)), ('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
], ],
options={ options={

View File

@ -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),
),
]

View File

@ -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')},
),
]

View File

@ -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(),
),
]

View File

@ -1,43 +1,130 @@
from django.conf import settings
from django.db import models from django.db import models
from django.db import transaction
from django.utils import timezone 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): class Playlist(models.Model):
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
is_public = models.BooleanField(default=False)
user = models.ForeignKey( user = models.ForeignKey(
'users.User', related_name="playlists", on_delete=models.CASCADE) 'users.User', related_name="playlists", on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(
auto_now=True)
privacy_level = fields.get_privacy_field()
def __str__(self): def __str__(self):
return self.name return self.name
def add_track(self, track, previous=None): @transaction.atomic
plt = PlaylistTrack(previous=previous, track=track, playlist=self) def insert(self, plt, index=None):
plt.save() """
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( track = models.ForeignKey(
'music.Track', 'music.Track',
related_name='playlist_tracks', related_name='playlist_tracks',
on_delete=models.CASCADE) on_delete=models.CASCADE)
previous = TreeOneToOneField( index = models.PositiveIntegerField(null=True, blank=True)
'self',
blank=True,
null=True,
related_name='next',
on_delete=models.CASCADE)
playlist = models.ForeignKey( playlist = models.ForeignKey(
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE) Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now)
class MPTTMeta: objects = PlaylistTrackQuerySet.as_manager()
level_attr = 'position'
parent_attr = 'previous'
class Meta: 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

View File

@ -1,8 +1,11 @@
from django.conf import settings
from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.music.models import Track
from funkwhale_api.music.serializers import TrackSerializerNested from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.users.serializers import UserBasicSerializer
from . import models from . import models
@ -11,20 +14,81 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.PlaylistTrack 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: class Meta:
model = models.PlaylistTrack 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): 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: class Meta:
model = models.Playlist model = models.Playlist
fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks') fields = (
read_only_fields = ['id', 'playlist_tracks', 'creation_date'] '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())

View File

@ -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 generics, mixins, viewsets
from rest_framework import status from rest_framework import status
from rest_framework.decorators import detail_route
from rest_framework.response import Response 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.music.models import Track
from funkwhale_api.common.permissions import ConditionalAuthentication
from . import filters
from . import models from . import models
from . import serializers from . import serializers
class PlaylistViewSet( class PlaylistViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
serializer_class = serializers.PlaylistSerializer serializer_class = serializers.PlaylistSerializer
queryset = (models.Playlist.objects.all()) queryset = (
permission_classes = [ConditionalAuthentication] 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): @detail_route(methods=['get'])
serializer = self.get_serializer(data=request.data) 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) serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer) try:
serializer = self.get_serializer(instance=instance) plts = playlist.insert_many(serializer.validated_data['tracks'])
headers = self.get_success_headers(serializer.data) except exceptions.ValidationError as e:
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) 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): 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): 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( class PlaylistTrackViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
serializer_class = serializers.PlaylistTrackSerializer serializer_class = serializers.PlaylistTrackSerializer
queryset = (models.PlaylistTrack.objects.all()) queryset = (models.PlaylistTrack.objects.all().for_nested_serialization())
permission_classes = [ConditionalAuthentication] permission_classes = [
permissions.ConditionalAuthentication,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
owner_field = 'playlist.user'
owner_checks = ['write']
def create(self, request, *args, **kwargs): def get_serializer_class(self):
serializer = serializers.PlaylistTrackCreateSerializer( if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']:
data=request.data) return serializers.PlaylistTrackWriteSerializer
serializer.is_valid(raise_exception=True) return self.serializer_class
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_queryset(self): 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)

View File

@ -10,15 +10,9 @@ from django.db import models
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _ 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 @python_2_unicode_compatible
class User(AbstractUser): class User(AbstractUser):
@ -39,8 +33,8 @@ class User(AbstractUser):
}, },
} }
privacy_level = models.CharField( privacy_level = fields.get_privacy_field()
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
def __str__(self): def __str__(self):
return self.username return self.username

View File

@ -33,7 +33,6 @@ musicbrainzngs==0.6
youtube_dl>=2017.12.14 youtube_dl>=2017.12.14
djangorestframework>=3.7,<3.8 djangorestframework>=3.7,<3.8
djangorestframework-jwt>=1.11,<1.12 djangorestframework-jwt>=1.11,<1.12
django-mptt>=0.9,<0.10
google-api-python-client>=1.6,<1.7 google-api-python-client>=1.6,<1.7
arrow>=0.12,<0.13 arrow>=0.12,<0.13
persisting-theory>=0.2,<0.3 persisting-theory>=0.2,<0.3

View File

@ -6,7 +6,7 @@ exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
max-line-length = 120 max-line-length = 120
exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
[pytest] [tool:pytest]
DJANGO_SETTINGS_MODULE=config.settings.test DJANGO_SETTINGS_MODULE=config.settings.test
python_files = tests.py test_*.py *_tests.py python_files = tests.py test_*.py *_tests.py
testpaths = tests testpaths = tests

View File

@ -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

View File

@ -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

View File

@ -1,8 +1,11 @@
import factory
import tempfile import tempfile
import shutil import shutil
import pytest import pytest
from django.core.cache import cache as django_cache from django.core.cache import cache as django_cache
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
@ -27,6 +30,16 @@ def cache():
@pytest.fixture @pytest.fixture
def factories(db): def factories(db):
from funkwhale_api import factories 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 yield factories.registry

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -0,0 +1 @@
Playlists are here \o/ :tada: (#3, #93, #94)

View File

@ -29,6 +29,8 @@
v-if="$store.state.instance.settings.raven.front_enabled.value" v-if="$store.state.instance.settings.raven.front_enabled.value"
:dsn="$store.state.instance.settings.raven.front_dsn.value"> :dsn="$store.state.instance.settings.raven.front_dsn.value">
</raven> </raven>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
</div> </div>
</template> </template>
@ -39,11 +41,14 @@ import logger from '@/logging'
import Sidebar from '@/components/Sidebar' import Sidebar from '@/components/Sidebar'
import Raven from '@/components/Raven' import Raven from '@/components/Raven'
import PlaylistModal from '@/components/playlists/PlaylistModal'
export default { export default {
name: 'app', name: 'app',
components: { components: {
Sidebar, Sidebar,
Raven Raven,
PlaylistModal
}, },
created () { created () {
this.$store.dispatch('instance/fetchSettings') this.$store.dispatch('instance/fetchSettings')

View File

@ -64,7 +64,6 @@ export default {
} }
} }
}) })
console.log(final)
return final return final
}, },
maxPage: function () { maxPage: function () {

View File

@ -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" 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: '/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> <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 <router-link
v-if="$store.state.auth.authenticated" v-if="$store.state.auth.authenticated"
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link> class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>

View File

@ -3,11 +3,11 @@
<button <button
title="Add to current queue" title="Add to current queue"
@click="add" @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> <i class="ui play icon"></i>
<template v-if="!discrete"><slot>Play</slot></template> <template v-if="!discrete"><slot>Play</slot></template>
</button> </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> <i class="dropdown icon"></i>
<div class="menu"> <div class="menu">
<div class="item"@click="add"><i class="plus icon"></i> Add to queue</div> <div class="item"@click="add"><i class="plus icon"></i> Add to queue</div>
@ -19,6 +19,7 @@
</template> </template>
<script> <script>
import axios from 'axios'
import logger from '@/logging' import logger from '@/logging'
import jQuery from 'jquery' import jQuery from 'jquery'
@ -27,6 +28,7 @@ export default {
// we can either have a single or multiple tracks to play when clicked // we can either have a single or multiple tracks to play when clicked
tracks: {type: Array, required: false}, tracks: {type: Array, required: false},
track: {type: Object, required: false}, track: {type: Object, required: false},
playlist: {type: Object, required: false},
discrete: {type: Boolean, default: false} discrete: {type: Boolean, default: false}
}, },
data () { data () {
@ -35,8 +37,8 @@ export default {
} }
}, },
created () { created () {
if (!this.track & !this.tracks) { if (!this.playlist && !this.track && !this.tracks) {
logger.default.error('You have to provide either a track or tracks property') logger.default.error('You have to provide either a track playlist or tracks property')
} }
}, },
mounted () { mounted () {
@ -45,19 +47,40 @@ export default {
} }
}, },
computed: { computed: {
playableTracks () { playable () {
let tracks
if (this.track) { if (this.track) {
tracks = [this.track] return true
} else { } else if (this.tracks) {
tracks = this.tracks return this.tracks.length > 0
} else if (this.playlist) {
return true
} }
return tracks.filter(e => { return false
return e.files.length > 0
})
} }
}, },
methods: { 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 () { triggerLoad () {
let self = this let self = this
this.isLoading = true this.isLoading = true
@ -66,15 +89,21 @@ export default {
}, 500) }, 500)
}, },
add () { add () {
let self = this
this.triggerLoad() this.triggerLoad()
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks}) this.getPlayableTracks().then((tracks) => {
self.$store.dispatch('queue/appendMany', {tracks: tracks})
})
}, },
addNext (next) { addNext (next) {
let self = this
this.triggerLoad() this.triggerLoad()
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1}) this.getPlayableTracks().then((tracks) => {
self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1})
if (next) { if (next) {
this.$store.dispatch('queue/next') self.$store.dispatch('queue/next')
} }
})
} }
} }
} }

View File

@ -30,7 +30,12 @@
</router-link> </router-link>
</div> </div>
<div class="description"> <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> </div>
</div> </div>
@ -140,11 +145,13 @@ import ColorThief from '@/vendor/color-thief'
import Track from '@/audio/track' import Track from '@/audio/track'
import AudioTrack from '@/components/audio/Track' import AudioTrack from '@/components/audio/Track'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
export default { export default {
name: 'player', name: 'player',
components: { components: {
TrackFavoriteIcon, TrackFavoriteIcon,
TrackPlaylistIcon,
GlobalEvents, GlobalEvents,
AudioTrack AudioTrack
}, },
@ -281,6 +288,7 @@ export default {
cursor: pointer cursor: pointer
} }
.track-area { .track-area {
margin-top: 0;
.header, .meta, .artist, .album { .header, .meta, .artist, .album {
color: white !important; color: white !important;
} }
@ -384,4 +392,5 @@ export default {
.ui.feed.icon { .ui.feed.icon {
margin: 0; margin: 0;
} }
</style> </style>

View File

@ -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>

View File

@ -11,34 +11,11 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="track in tracks"> <track-row
<td> :display-position="displayPosition"
<play-button class="basic icon" :discrete="true" :track="track"></play-button> :track="track"
</td> :key="index + '-' + track.id"
<td> v-for="(track, index) in tracks"></track-row>
<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>
</tbody> </tbody>
<tfoot class="full-width"> <tfoot class="full-width">
<tr> <tr>
@ -83,9 +60,8 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.aut
<script> <script>
import backend from '@/audio/backend' 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' import Modal from '@/components/semantic/Modal'
export default { export default {
@ -95,8 +71,7 @@ export default {
}, },
components: { components: {
Modal, Modal,
TrackFavoriteIcon, TrackRow
PlayButton
}, },
data () { data () {
return { return {

View File

@ -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>

View File

@ -8,4 +8,8 @@ import Username from '@/components/common/Username'
Vue.component('username', Username) Vue.component('username', Username)
import DangerousButton from '@/components/common/DangerousButton'
Vue.component('dangerous-button', DangerousButton)
export default {} export default {}

View File

@ -4,6 +4,7 @@
<router-link class="ui item" to="/library" exact>Browse</router-link> <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/artists" exact>Artists</router-link>
<router-link class="ui item" to="/library/radios" exact>Radios</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"> <div class="ui secondary right menu">
<router-link v-if="$store.state.auth.authenticated" class="ui item" to="/library/requests/" exact> <router-link v-if="$store.state.auth.authenticated" class="ui item" to="/library/requests/" exact>
Requests Requests

View File

@ -24,6 +24,11 @@
<play-button class="orange" :track="track">Play</play-button> <play-button class="orange" :track="track">Play</play-button>
<track-favorite-icon :track="track" :button="true"></track-favorite-icon> <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"> <a :href="wikipediaUrl" target="_blank" class="ui button">
<i class="wikipedia icon"></i> <i class="wikipedia icon"></i>
Search on wikipedia Search on wikipedia
@ -66,6 +71,7 @@ import logger from '@/logging'
import backend from '@/audio/backend' import backend from '@/audio/backend'
import PlayButton from '@/components/audio/PlayButton' import PlayButton from '@/components/audio/PlayButton'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
const FETCH_URL = 'tracks/' const FETCH_URL = 'tracks/'
@ -73,6 +79,7 @@ export default {
props: ['id'], props: ['id'],
components: { components: {
PlayButton, PlayButton,
TrackPlaylistIcon,
TrackFavoriteIcon TrackFavoriteIcon
}, },
data () { data () {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -19,8 +19,15 @@ export default {
control: null control: null
} }
}, },
mounted () { beforeDestroy () {
if (this.control) {
this.control.remove()
}
},
methods: {
initModal () {
this.control = $(this.$el).modal({ this.control = $(this.$el).modal({
duration: 100,
onApprove: function () { onApprove: function () {
this.$emit('approved') this.$emit('approved')
}.bind(this), }.bind(this),
@ -31,14 +38,19 @@ export default {
this.$emit('update:show', false) this.$emit('update:show', false)
}.bind(this) }.bind(this)
}) })
}
}, },
watch: { watch: {
show: { show: {
handler (newValue) { handler (newValue) {
if (newValue) { if (newValue) {
this.initModal()
this.control.modal('show') this.control.modal('show')
} else { } else {
if (this.control) {
this.control.modal('hide') this.control.modal('hide')
this.control.remove()
}
} }
} }
} }

View File

@ -21,7 +21,8 @@ import RadioBuilder from '@/components/library/radios/Builder'
import BatchList from '@/components/library/import/BatchList' import BatchList from '@/components/library/import/BatchList'
import BatchDetail from '@/components/library/import/BatchDetail' import BatchDetail from '@/components/library/import/BatchDetail'
import RequestsList from '@/components/requests/RequestsList' import RequestsList from '@/components/requests/RequestsList'
import PlaylistDetail from '@/views/playlists/Detail'
import PlaylistList from '@/views/playlists/List'
import Favorites from '@/components/favorites/List' import Favorites from '@/components/favorites/List'
Vue.use(Router) 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', name: 'library.radios.build', component: RadioBuilder, props: true },
{ path: 'radios/build/:id', name: 'library.radios.edit', 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: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true }, { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },

View File

@ -91,6 +91,7 @@ export default {
commit('profile', data) commit('profile', data)
commit('username', data.username) commit('username', data.username)
dispatch('favorites/fetch', null, {root: true}) dispatch('favorites/fetch', null, {root: true})
dispatch('playlists/fetchOwn', null, {root: true})
Object.keys(data.permissions).forEach(function (key) { Object.keys(data.permissions).forEach(function (key) {
// this makes it easier to check for permissions in templates // this makes it easier to check for permissions in templates
commit('permission', {key, status: data.permissions[String(key)].status}) commit('permission', {key, status: data.permissions[String(key)].status})

View File

@ -8,6 +8,7 @@ import instance from './instance'
import queue from './queue' import queue from './queue'
import radios from './radios' import radios from './radios'
import player from './player' import player from './player'
import playlists from './playlists'
import ui from './ui' import ui from './ui'
Vue.use(Vuex) Vue.use(Vuex)
@ -20,6 +21,7 @@ export default new Vuex.Store({
instance, instance,
queue, queue,
radios, radios,
playlists,
player player
}, },
plugins: [ plugins: [

View File

@ -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)
})
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -180,7 +180,8 @@ describe('store/auth', () => {
{ type: 'permission', payload: {key: 'admin', status: true} } { type: 'permission', payload: {key: 'admin', status: true} }
], ],
expectedActions: [ 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) }, done)
}) })

View File

@ -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)
})
})
})