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',
|
'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)
|
||||||
|
|
|
@ -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.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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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.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={
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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"
|
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')
|
||||||
|
|
|
@ -64,7 +64,6 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log(final)
|
|
||||||
return final
|
return final
|
||||||
},
|
},
|
||||||
maxPage: function () {
|
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" 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>
|
||||||
|
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
</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 {
|
||||||
|
|
|
@ -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)
|
Vue.component('username', Username)
|
||||||
|
|
||||||
|
import DangerousButton from '@/components/common/DangerousButton'
|
||||||
|
|
||||||
|
Vue.component('dangerous-button', DangerousButton)
|
||||||
|
|
||||||
export default {}
|
export default {}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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>
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
|
@ -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} }
|
{ 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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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