Merge branch 'release/0.7'

This commit is contained in:
Eliot Berriot 2018-03-21 21:05:14 +01:00
commit 3673f624dd
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
79 changed files with 2513 additions and 360 deletions

View File

@ -16,13 +16,15 @@ test_api:
stage: test
image: funkwhale/funkwhale:latest
cache:
key: "$CI_PROJECT_ID/pip_cache"
key: "$CI_PROJECT_ID__pip_cache"
paths:
- "$PIP_CACHE_DIR"
variables:
DJANGO_ALLOWED_HOSTS: "localhost"
DATABASE_URL: "postgresql://postgres@postgres/postgres"
FUNKWHALE_URL: "https://funkwhale.ci"
CACHEOPS_ENABLED: "false"
before_script:
- cd api
- pip install -r requirements/base.txt
@ -44,7 +46,7 @@ test_front:
- yarn install
- yarn run unit
cache:
key: "$CI_PROJECT_ID/front_dependencies"
key: "$CI_PROJECT_ID__front_dependencies"
paths:
- front/node_modules
- front/yarn.lock
@ -66,7 +68,7 @@ build_front:
- yarn install
- yarn run build
cache:
key: "$CI_PROJECT_ID/front_dependencies"
key: "$CI_PROJECT_ID__front_dependencies"
paths:
- front/node_modules
- front/yarn.lock
@ -84,15 +86,12 @@ build_front:
pages:
stage: test
image: alpine
image: python:3.6-alpine
before_script:
- cd docs
script:
- apk --no-cache add py2-pip python-dev
- pip install sphinx
- apk --no-cache add make
- make html
- mv _build/html/ ../public
- python -m sphinx . ../public
artifacts:
paths:
- public

View File

@ -3,7 +3,37 @@ Changelog
.. towncrier
0.6.1 (unreleased)
0.7 (2018-03-21)
----------------
Features:
- Can now filter artists and albums with no listenable tracks (#114)
- Improve the style of the sidebar to make it easier to understand which tab is
selected (#118)
- On artist page, albums are not sorted by release date, if any (#116)
- Playlists are here \o/ :tada: (#3, #93, #94)
- Use django-cacheops to cache common ORM requests (#117)
Bugfixes:
- Fixed broken import request admin (#115)
- Fixed forced redirection to login event with
API_AUTHENTICATION_REQUIRED=False (#119)
- Fixed position not being reseted properly when playing the same track
multiple times in a row
- Fixed synchronized start/stop radio buttons for all custom radios (#103)
- Fixed typo and missing icon on homepage (#96)
Documentation:
- Up-to-date and complete development and contribution instructions in
README.rst (#123)
0.6.1 (2018-03-06)
------------------
Features:

View File

@ -5,27 +5,107 @@ A self-hosted tribute to Grooveshark.com.
LICENSE: BSD
Setting up a development environment (docker)
----------------------------------------------
Getting help
------------
First of all, pull the repository.
We offer various Matrix.org rooms to discuss about funkwhale:
Then, pull and build all the containers::
- `#funkwhale:matrix.org <https://riot.im/app/#/room/#funkwhale:matrix.org>`_ for general questions about funkwhale
- `#funkwhale-dev:matrix.org <https://riot.im/app/#/room/#funkwhale-dev:matrix.org>`_ for development-focused discussion
Please join those rooms if you have any questions!
Running the development version
-------------------------------
If you want to fix a bug or implement a feature, you'll need
to run a local, development copy of funkwhale.
We provide a docker based development environment, which should
be both easy to setup and work similarly regardless of your
development machine setup.
Instructions for bare-metal setup will come in the future (Merge requests
are welcome).
Installing docker and docker-compose
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is already cover in the relevant documentations:
- https://docs.docker.com/install/
- https://docs.docker.com/compose/install/
Cloning the project
^^^^^^^^^^^^^^^^^^^
Visit https://code.eliotberriot.com/funkwhale/funkwhale and clone the repository using SSH or HTTPS. Exemple using SSH::
git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
cd funkwhale
A note about branches
^^^^^^^^^^^^^^^^^^^^^
Next release development occurs on the "develop" branch, and releases are made on the "master" branch. Therefor, when submitting Merge Requests, ensure you are merging on the develop branch.
Working with docker
^^^^^^^^^^^^^^^^^^^
In developpement, we use the docker-compose file named ``dev.yml``, and this is why all our docker-compose commands will look like this::
docker-compose -f dev.yml logs
If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this command before starting your work::
export COMPOSE_FILE=dev.yml
Building the containers
^^^^^^^^^^^^^^^^^^^^^^^
On your initial clone, or if there have been some changes in the
app dependencies, you will have to rebuild your containers. This is done
via the following command::
docker-compose -f dev.yml build
docker-compose -f dev.yml pull
API setup
^^^^^^^^^^
Database management
^^^^^^^^^^^^^^^^^^^
You'll have apply database migrations::
To setup funkwhale's database schema, run this::
docker-compose -f dev.yml run celeryworker python manage.py migrate
docker-compose -f dev.yml run --rm api python manage.py migrate
And to create an admin user::
This will create all the tables needed for the API to run proprely.
You will also need to run this whenever changes are made on the database
schema.
docker-compose -f dev.yml run celeryworker python manage.py createsuperuser
It is safe to run this command multiple times, so you can run it whenever
you fetch develop.
Development data
^^^^^^^^^^^^^^^^
You'll need at least an admin user and some artists/tracks/albums to work
locally.
Create an admin user with the following command::
docker-compose -f dev.yml run --rm api python manage.py createsuperuser
Injecting fake data is done by running the fllowing script::
artists=25
command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)"
echo $command | docker-compose -f dev.yml run --rm api python manage.py shell -i python
The previous command will create 25 artists with random albums, tracks
and metadata.
Launch all services
@ -33,18 +113,83 @@ Launch all services
Then you can run everything with::
docker-compose up
docker-compose -f dev.yml up
This will launch all services, and output the logs in your current terminal window.
If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``.
Once everything is up, you can access the various funkwhale's components:
- The Vue webapp, on http://localhost:8080
- The API, on http://localhost:8080/api/v1/
- The django admin, on http://localhost:8080/api/admin/
The API server will be accessible at http://localhost:6001, and the front-end at http://localhost:8080.
Running API tests
------------------
^^^^^^^^^^^^^^^^^
Everything is managed using docker and docker-compose, just run::
To run the pytest test suite, use the following command::
./api/runtests
docker-compose -f dev.yml run --rm api pytest
This bash script invoke `python manage.py test` in a docker container under the hood, so you can use
traditional django test arguments and options, such as::
This is regular pytest, so you can use any arguments/options that pytest usually accept::
./api/runtests funkwhale_api.music # run a specific app test
# get some help
docker-compose -f dev.yml run --rm api pytest -h
# Stop on first failure
docker-compose -f dev.yml run --rm api pytest -x
# Run a specific test file
docker-compose -f dev.yml run --rm api pytest tests/test_acoustid.py
Running front-end tests
^^^^^^^^^^^^^^^^^^^^^^^
To run the front-end test suite, use the following command::
docker-compose -f dev.yml run --rm front yarn run unit
We also support a "watch and test" mode were we continually relaunch
tests when changes are recorded on the file system::
docker-compose -f dev.yml run --rm front yarn run unit-watch
The latter is especially useful when you are debugging failing tests.
.. note::
The front-end test suite coverage is still pretty low
Stopping everything
^^^^^^^^^^^^^^^^^^^
Once you're down with your work, you can stop running containers, if any, with::
docker-compose -f dev.yml stop
Removing everything
^^^^^^^^^^^^^^^^^^^
If you want to wipe your development environment completely (e.g. if you want to start over from scratch), just run::
docker-compose -f dev.yml down -v
This will wipe your containers and data, so please be careful before running it.
You can keep your data by removing the ``-v`` flag.
Typical workflow for a merge request
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
0. Fork the project if you did not already or if you do not have access to the main repository
1. Checkout the development branch and pull most recent changes: ``git checkout develop && git pull``
2. Create a dedicated branch for your work ``42-awesome-fix``. It is good practice to prefix your branch name with the ID of the issue you are solving.
3. Work on your stuff
4. Commit small, atomic changes to make it easier to review your contribution
5. Add a changelog fragment to summarize your changes: ``echo "Implemented awesome stuff (#42)" > changes/changelog.d/42.feature"``
6. Push your branch
7. Create your merge request
8. Take a step back and enjoy, we're really grateful you did all of this and took the time to contribute!

View File

@ -0,0 +1,7 @@
#!/bin/bash
set -e
if [ $1 = "pytest" ]; then
# let pytest.ini handle it
unset DJANGO_SETTINGS_MODULE
fi
exec "$@"

View File

@ -57,9 +57,9 @@ THIRD_PARTY_APPS = (
'taggit',
'rest_auth',
'rest_auth.registration',
'mptt',
'dynamic_preferences',
'django_filters',
'cacheops',
)
@ -369,7 +369,19 @@ MUSICBRAINZ_CACHE_DURATION = env.int(
'MUSICBRAINZ_CACHE_DURATION',
default=300
)
CACHEOPS_REDIS = env('CACHE_URL', default=CACHE_DEFAULT)
CACHEOPS_ENABLED = env.bool('CACHEOPS_ENABLED', default=True)
CACHEOPS = {
'music.artist': {'ops': 'all', 'timeout': 60 * 60},
'music.album': {'ops': 'all', 'timeout': 60 * 60},
'music.track': {'ops': 'all', 'timeout': 60 * 60},
'music.trackfile': {'ops': 'all', 'timeout': 60 * 60},
'taggit.tag': {'ops': 'all', 'timeout': 60 * 60},
}
# Custom Admin URL, use {% url 'admin:index' %}
ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
CSRF_USE_SESSIONS = True
# Playlist settings
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)

View File

@ -19,10 +19,6 @@ CACHES = {
CELERY_BROKER_URL = 'memory://'
# TESTING
# ------------------------------------------------------------------------------
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
########## CELERY
# In development, all tasks will be executed locally by blocking until the task returns
CELERY_TASK_ALWAYS_EAGER = True
@ -30,3 +26,4 @@ CELERY_TASK_ALWAYS_EAGER = True
# Your local stuff: Below this line define 3rd party library settings
API_AUTHENTICATION_REQUIRED = False
CACHEOPS_ENABLED = False

View File

@ -1,42 +0,0 @@
version: '2'
services:
postgres:
image: postgres:9.5
api:
build: .
links:
- postgres
- redis
command: ./compose/django/gunicorn.sh
env_file: .env
volumes:
- ./media:/app/funkwhale_api/media
- ./staticfiles:/app/staticfiles
- ./music:/music
ports:
- "127.0.0.1:6001:5000"
redis:
image: redis:3.0
celeryworker:
build: .
env_file: .env
links:
- postgres
- redis
command: celery -A funkwhale_api.taskapp worker -l INFO
volumes:
- ./media:/app/funkwhale_api/media
- ./music:/music
environment:
- C_FORCE_ROOT=True
celerybeat:
build: .
env_file: .env
links:
- postgres
- redis
command: celery -A funkwhale_api.taskapp beat -l INFO

View File

@ -23,3 +23,4 @@ RUN pip install -r /requirements/test.txt
COPY . /app
WORKDIR /app
ENTRYPOINT ["compose/django/dev-entrypoint.sh"]

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '0.6.1'
__version__ = '0.7'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])

View File

@ -0,0 +1,27 @@
from django.db import models
PRIVACY_LEVEL_CHOICES = [
('me', 'Only me'),
('followers', 'Me and my followers'),
('instance', 'Everyone on my instance, and my followers'),
('everyone', 'Everyone, including people on other instances'),
]
def get_privacy_field():
return models.CharField(
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
def privacy_level_query(user, lookup_field='privacy_level'):
if user.is_anonymous:
return models.Q(**{
lookup_field: 'everyone',
})
return models.Q(**{
'{}__in'.format(lookup_field): [
'me', 'followers', 'instance', 'everyone'
]
})

View File

@ -1,4 +1,7 @@
import operator
from django.conf import settings
from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions
@ -20,3 +23,39 @@ class HasModelPermission(DjangoModelPermissions):
"""
def get_required_permissions(self, method, model_cls):
return super().get_required_permissions(method, self.model)
class OwnerPermission(BasePermission):
"""
Ensure the request user is the owner of the object.
Usage:
class MyView(APIView):
model = MyModel
permission_classes = [OwnerPermission]
owner_field = 'owner'
owner_checks = ['read', 'write']
"""
perms_map = {
'GET': 'read',
'OPTIONS': 'read',
'HEAD': 'read',
'POST': 'write',
'PUT': 'write',
'PATCH': 'write',
'DELETE': 'write',
}
def has_object_permission(self, request, view, obj):
method_check = self.perms_map[request.method]
owner_checks = getattr(view, 'owner_checks', ['read', 'write'])
if method_check not in owner_checks:
# check not enabled
return True
owner_field = getattr(view, 'owner_field', 'user')
owner = operator.attrgetter(owner_field)(obj)
if owner != request.user:
raise Http404
return True

View File

@ -1,12 +1,36 @@
import django_filters
from django.db.models import Count
from django_filters import rest_framework as filters
from . import models
class ArtistFilter(django_filters.FilterSet):
class ListenableMixin(filters.FilterSet):
listenable = filters.BooleanFilter(name='_', method='filter_listenable')
def filter_listenable(self, queryset, name, value):
queryset = queryset.annotate(
files_count=Count('tracks__files')
)
if value:
return queryset.filter(files_count__gt=0)
else:
return queryset.filter(files_count=0)
class ArtistFilter(ListenableMixin):
class Meta:
model = models.Artist
fields = {
'name': ['exact', 'iexact', 'startswith', 'icontains']
'name': ['exact', 'iexact', 'startswith', 'icontains'],
'listenable': 'exact',
}
class AlbumFilter(ListenableMixin):
listenable = filters.BooleanFilter(name='_', method='filter_listenable')
class Meta:
model = models.Album
fields = ['listenable']

View File

@ -54,6 +54,7 @@ class TagViewSetMixin(object):
queryset = queryset.filter(tags__pk=tag)
return queryset
class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
queryset = (
models.Artist.objects.all()
@ -67,6 +68,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
filter_class = filters.ArtistFilter
ordering_fields = ('id', 'name', 'creation_date')
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
queryset = (
models.Album.objects.all()
@ -78,6 +80,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
permission_classes = [ConditionalAuthentication]
search_fields = ['title__unaccent']
ordering_fields = ('creation_date',)
filter_class = filters.AlbumFilter
class ImportBatchViewSet(
@ -237,6 +240,7 @@ class TagViewSet(viewsets.ReadOnlyModelViewSet):
class Search(views.APIView):
max_results = 3
permission_classes = [ConditionalAuthentication]
def get(self, request, *args, **kwargs):
query = request.GET['query']

View File

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

View File

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

View File

@ -0,0 +1,22 @@
from django_filters import rest_framework as filters
from funkwhale_api.music import utils
from . import models
class PlaylistFilter(filters.FilterSet):
q = filters.CharFilter(name='_', method='filter_q')
class Meta:
model = models.Playlist
fields = {
'user': ['exact'],
'name': ['exact', 'icontains'],
'q': 'exact',
}
def filter_q(self, queryset, name, value):
query = utils.get_query(value, ['name', 'user__username'])
return queryset.filter(query)

View File

@ -4,7 +4,6 @@ from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.utils.timezone
import mptt.fields
class Migration(migrations.Migration):
@ -34,7 +33,7 @@ class Migration(migrations.Migration):
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('position', models.PositiveIntegerField(db_index=True, editable=False)),
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)),
('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
('previous', models.OneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
],
options={

View File

@ -0,0 +1,22 @@
# Generated by Django 2.0.3 on 2018-03-16 22:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlists', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='playlist',
name='is_public',
),
migrations.AddField(
model_name='playlist',
name='privacy_level',
field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
),
]

View File

@ -0,0 +1,52 @@
# Generated by Django 2.0.3 on 2018-03-19 12:14
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('playlists', '0002_auto_20180316_2217'),
]
operations = [
migrations.AlterModelOptions(
name='playlisttrack',
options={'ordering': ('-playlist', 'index')},
),
migrations.AddField(
model_name='playlisttrack',
name='creation_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='playlisttrack',
name='index',
field=models.PositiveIntegerField(null=True),
),
migrations.RemoveField(
model_name='playlisttrack',
name='lft',
),
migrations.RemoveField(
model_name='playlisttrack',
name='position',
),
migrations.RemoveField(
model_name='playlisttrack',
name='previous',
),
migrations.RemoveField(
model_name='playlisttrack',
name='rght',
),
migrations.RemoveField(
model_name='playlisttrack',
name='tree_id',
),
migrations.AlterUniqueTogether(
name='playlisttrack',
unique_together={('playlist', 'index')},
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 2.0.3 on 2018-03-20 17:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('playlists', '0003_auto_20180319_1214'),
]
operations = [
migrations.AddField(
model_name='playlist',
name='modification_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name='playlisttrack',
name='index',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterUniqueTogether(
name='playlisttrack',
unique_together=set(),
),
]

View File

@ -1,43 +1,130 @@
from django.conf import settings
from django.db import models
from django.db import transaction
from django.utils import timezone
from mptt.models import MPTTModel, TreeOneToOneField
from rest_framework import exceptions
from funkwhale_api.common import fields
class Playlist(models.Model):
name = models.CharField(max_length=50)
is_public = models.BooleanField(default=False)
user = models.ForeignKey(
'users.User', related_name="playlists", on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(
auto_now=True)
privacy_level = fields.get_privacy_field()
def __str__(self):
return self.name
def add_track(self, track, previous=None):
plt = PlaylistTrack(previous=previous, track=track, playlist=self)
plt.save()
@transaction.atomic
def insert(self, plt, index=None):
"""
Given a PlaylistTrack, insert it at the correct index in the playlist,
and update other tracks index if necessary.
"""
old_index = plt.index
move = old_index is not None
if index is not None and index == old_index:
# moving at same position, just skip
return index
return plt
existing = self.playlist_tracks.select_for_update()
if move:
existing = existing.exclude(pk=plt.pk)
total = existing.filter(index__isnull=False).count()
if index is None:
# we simply increment the last track index by 1
index = total
if index > total:
raise exceptions.ValidationError('Index is not continuous')
if index < 0:
raise exceptions.ValidationError('Index must be zero or positive')
if move:
# we remove the index temporarily, to avoid integrity errors
plt.index = None
plt.save(update_fields=['index'])
if index > old_index:
# new index is higher than current, we decrement previous tracks
to_update = existing.filter(
index__gt=old_index, index__lte=index)
to_update.update(index=models.F('index') - 1)
if index < old_index:
# new index is lower than current, we increment next tracks
to_update = existing.filter(index__lt=old_index, index__gte=index)
to_update.update(index=models.F('index') + 1)
else:
to_update = existing.filter(index__gte=index)
to_update.update(index=models.F('index') + 1)
plt.index = index
plt.save(update_fields=['index'])
self.save(update_fields=['modification_date'])
return index
@transaction.atomic
def remove(self, index):
existing = self.playlist_tracks.select_for_update()
self.save(update_fields=['modification_date'])
to_update = existing.filter(index__gt=index)
return to_update.update(index=models.F('index') - 1)
@transaction.atomic
def insert_many(self, tracks):
existing = self.playlist_tracks.select_for_update()
now = timezone.now()
total = existing.filter(index__isnull=False).count()
if existing.count() + len(tracks) > settings.PLAYLISTS_MAX_TRACKS:
raise exceptions.ValidationError(
'Playlist would reach the maximum of {} tracks'.format(
settings.PLAYLISTS_MAX_TRACKS))
self.save(update_fields=['modification_date'])
start = total
plts = [
PlaylistTrack(
creation_date=now, playlist=self, track=track, index=start+i)
for i, track in enumerate(tracks)
]
return PlaylistTrack.objects.bulk_create(plts)
class PlaylistTrackQuerySet(models.QuerySet):
def for_nested_serialization(self):
return (self.select_related()
.select_related('track__album__artist')
.prefetch_related(
'track__tags',
'track__files',
'track__artist__albums__tracks__tags'))
class PlaylistTrack(MPTTModel):
class PlaylistTrack(models.Model):
track = models.ForeignKey(
'music.Track',
related_name='playlist_tracks',
on_delete=models.CASCADE)
previous = TreeOneToOneField(
'self',
blank=True,
null=True,
related_name='next',
on_delete=models.CASCADE)
index = models.PositiveIntegerField(null=True, blank=True)
playlist = models.ForeignKey(
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
creation_date = models.DateTimeField(default=timezone.now)
class MPTTMeta:
level_attr = 'position'
parent_attr = 'previous'
objects = PlaylistTrackQuerySet.as_manager()
class Meta:
ordering = ('-playlist', 'position')
ordering = ('-playlist', 'index')
unique_together = ('playlist', 'index')
def delete(self, *args, **kwargs):
playlist = self.playlist
index = self.index
update_indexes = kwargs.pop('update_indexes', False)
r = super().delete(*args, **kwargs)
if index is not None and update_indexes:
playlist.remove(index)
return r

View File

@ -1,8 +1,11 @@
from django.conf import settings
from django.db import transaction
from rest_framework import serializers
from taggit.models import Tag
from funkwhale_api.music.models import Track
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.users.serializers import UserBasicSerializer
from . import models
@ -11,20 +14,81 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
class Meta:
model = models.PlaylistTrack
fields = ('id', 'track', 'playlist', 'position')
fields = ('id', 'track', 'playlist', 'index', 'creation_date')
class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
index = serializers.IntegerField(
required=False, min_value=0, allow_null=True)
class Meta:
model = models.PlaylistTrack
fields = ('id', 'track', 'playlist', 'position')
fields = ('id', 'track', 'playlist', 'index')
def validate_playlist(self, value):
if self.context.get('request'):
# validate proper ownership on the playlist
if self.context['request'].user != value.user:
raise serializers.ValidationError(
'You do not have the permission to edit this playlist')
existing = value.playlist_tracks.count()
if existing >= settings.PLAYLISTS_MAX_TRACKS:
raise serializers.ValidationError(
'Playlist has reached the maximum of {} tracks'.format(
settings.PLAYLISTS_MAX_TRACKS))
return value
@transaction.atomic
def create(self, validated_data):
index = validated_data.pop('index', None)
instance = super().create(validated_data)
instance.playlist.insert(instance, index)
return instance
@transaction.atomic
def update(self, instance, validated_data):
update_index = 'index' in validated_data
index = validated_data.pop('index', None)
super().update(instance, validated_data)
if update_index:
instance.playlist.insert(instance, index)
return instance
def get_unique_together_validators(self):
"""
We explicitely disable unique together validation here
because it collides with our internal logic
"""
return []
class PlaylistSerializer(serializers.ModelSerializer):
playlist_tracks = PlaylistTrackSerializer(many=True, read_only=True)
tracks_count = serializers.SerializerMethodField(read_only=True)
user = UserBasicSerializer(read_only=True)
class Meta:
model = models.Playlist
fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks')
read_only_fields = ['id', 'playlist_tracks', 'creation_date']
fields = (
'id',
'name',
'tracks_count',
'user',
'modification_date',
'creation_date',
'privacy_level',)
read_only_fields = [
'id',
'modification_date',
'creation_date',]
def get_tracks_count(self, obj):
try:
return obj.tracks_count
except AttributeError:
# no annotation?
return obj.playlist_tracks.count()
class PlaylistAddManySerializer(serializers.Serializer):
tracks = serializers.PrimaryKeyRelatedField(
many=True, queryset=Track.objects.for_nested_serialization())

View File

@ -1,58 +1,123 @@
from django.db.models import Count
from django.db import transaction
from rest_framework import exceptions
from rest_framework import generics, mixins, viewsets
from rest_framework import status
from rest_framework.decorators import detail_route
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from funkwhale_api.common import permissions
from funkwhale_api.common import fields
from funkwhale_api.music.models import Track
from funkwhale_api.common.permissions import ConditionalAuthentication
from . import filters
from . import models
from . import serializers
class PlaylistViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.PlaylistSerializer
queryset = (models.Playlist.objects.all())
permission_classes = [ConditionalAuthentication]
queryset = (
models.Playlist.objects.all().select_related('user')
.annotate(tracks_count=Count('playlist_tracks'))
)
permission_classes = [
permissions.ConditionalAuthentication,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
owner_checks = ['write']
filter_class = filters.PlaylistFilter
ordering_fields = ('id', 'name', 'creation_date', 'modification_date')
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
@detail_route(methods=['get'])
def tracks(self, request, *args, **kwargs):
playlist = self.get_object()
plts = playlist.playlist_tracks.all().for_nested_serialization()
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
data = {
'count': len(plts),
'results': serializer.data
}
return Response(data, status=200)
@detail_route(methods=['post'])
@transaction.atomic
def add(self, request, *args, **kwargs):
playlist = self.get_object()
serializer = serializers.PlaylistAddManySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
try:
plts = playlist.insert_many(serializer.validated_data['tracks'])
except exceptions.ValidationError as e:
payload = {'playlist': e.detail}
return Response(payload, status=400)
ids = [p.id for p in plts]
plts = models.PlaylistTrack.objects.filter(
pk__in=ids).order_by('index').for_nested_serialization()
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
data = {
'count': len(plts),
'results': serializer.data
}
return Response(data, status=201)
@detail_route(methods=['delete'])
@transaction.atomic
def clear(self, request, *args, **kwargs):
playlist = self.get_object()
playlist.playlist_tracks.all().delete()
playlist.save(update_fields=['modification_date'])
return Response(status=204)
def get_queryset(self):
return self.queryset.filter(user=self.request.user)
return self.queryset.filter(
fields.privacy_level_query(self.request.user))
def perform_create(self, serializer):
return serializer.save(user=self.request.user)
return serializer.save(
user=self.request.user,
privacy_level=serializer.validated_data.get(
'privacy_level', self.request.user.privacy_level)
)
class PlaylistTrackViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.PlaylistTrackSerializer
queryset = (models.PlaylistTrack.objects.all())
permission_classes = [ConditionalAuthentication]
queryset = (models.PlaylistTrack.objects.all().for_nested_serialization())
permission_classes = [
permissions.ConditionalAuthentication,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
owner_field = 'playlist.user'
owner_checks = ['write']
def create(self, request, *args, **kwargs):
serializer = serializers.PlaylistTrackCreateSerializer(
data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_serializer_class(self):
if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']:
return serializers.PlaylistTrackWriteSerializer
return self.serializer_class
def get_queryset(self):
return self.queryset.filter(playlist__user=self.request.user)
return self.queryset.filter(
fields.privacy_level_query(
self.request.user,
lookup_field='playlist__privacy_level'))
def perform_destroy(self, instance):
instance.delete(update_indexes=True)

View File

@ -7,8 +7,7 @@ from . import models
class ImportRequestAdmin(admin.ModelAdmin):
list_display = ['artist_name', 'user', 'status', 'creation_date']
list_select_related = [
'user',
'track'
'user'
]
list_filter = [
'status',

View File

@ -10,15 +10,9 @@ from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from funkwhale_api.common import fields
PRIVACY_LEVEL_CHOICES = [
('me', 'Only me'),
('followers', 'Me and my followers'),
('instance', 'Everyone on my instance, and my followers'),
('everyone', 'Everyone, including people on other instances'),
]
@python_2_unicode_compatible
class User(AbstractUser):
@ -39,8 +33,8 @@ class User(AbstractUser):
},
}
privacy_level = models.CharField(
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
privacy_level = fields.get_privacy_field()
def __str__(self):
return self.username

View File

@ -1,37 +0,0 @@
#!/bin/bash
pip --version >/dev/null 2>&1 || {
echo >&2 -e "\npip is required but it's not installed."
echo >&2 -e "You can install it by running the following command:\n"
echo >&2 "wget https://bootstrap.pypa.io/get-pip.py --output-document=get-pip.py; chmod +x get-pip.py; sudo -H python3 get-pip.py"
echo >&2 -e "\n"
echo >&2 -e "\nFor more information, see pip documentation: https://pip.pypa.io/en/latest/"
exit 1;
}
virtualenv --version >/dev/null 2>&1 || {
echo >&2 -e "\nvirtualenv is required but it's not installed."
echo >&2 -e "You can install it by running the following command:\n"
echo >&2 "sudo -H pip3 install virtualenv"
echo >&2 -e "\n"
echo >&2 -e "\nFor more information, see virtualenv documentation: https://virtualenv.pypa.io/en/latest/"
exit 1;
}
if [ -z "$VIRTUAL_ENV" ]; then
echo >&2 -e "\nYou need activate a virtualenv first"
echo >&2 -e 'If you do not have a virtualenv created, run the following command to create and automatically activate a new virtualenv named "venv" on current folder:\n'
echo >&2 -e "virtualenv venv --python=\`which python3\`"
echo >&2 -e "\nTo leave/disable the currently active virtualenv, run the following command:\n"
echo >&2 "deactivate"
echo >&2 -e "\nTo activate the virtualenv again, run the following command:\n"
echo >&2 "source venv/bin/activate"
echo >&2 -e "\nFor more information, see virtualenv documentation: https://virtualenv.pypa.io/en/latest/"
echo >&2 -e "\n"
exit 1;
else
pip install -r requirements/local.txt
pip install -r requirements/test.txt
pip install -r requirements.txt
fi

View File

@ -1,6 +0,0 @@
[pytest]
DJANGO_SETTINGS_MODULE=config.settings.test
# -- recommended but optional:
python_files = tests.py test_*.py *_tests.py
testpatsh = tests

View File

@ -33,7 +33,6 @@ musicbrainzngs==0.6
youtube_dl>=2017.12.14
djangorestframework>=3.7,<3.8
djangorestframework-jwt>=1.11,<1.12
django-mptt>=0.9,<0.10
google-api-python-client>=1.6,<1.7
arrow>=0.12,<0.13
persisting-theory>=0.2,<0.3
@ -58,3 +57,6 @@ python-magic==0.4.15
ffmpeg-python==0.1.10
channels>=2,<2.1
channels_redis>=2.1,<2.2
django-cacheops>=4,<4.1
daphne==2.0.4

View File

@ -2,9 +2,6 @@
coverage>=4.4,<4.5
django_coverage_plugin>=1.5,<1.6
Sphinx>=1.6,<1.7
django-extensions>=1.9,<1.10
Werkzeug>=0.13,<0.14
factory_boy>=2.8.1
# django-debug-toolbar that works with Django 1.5+

View File

@ -3,5 +3,3 @@
# WSGI Handler
# ------------------------------------------------
daphne==2.0.4

View File

@ -1,5 +0,0 @@
#!/bin/bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
docker-compose -f $DIR/test.yml run test pytest "$@"

View File

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

View File

@ -1,17 +0,0 @@
version: '2'
services:
test:
build:
dockerfile: docker/Dockerfile.test
context: .
command: pytest
depends_on:
- postgres
volumes:
- .:/app
environment:
- "DJANGO_ALLOWED_HOSTS=localhost"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "FUNKWHALE_URL=https://funkwhale.test"
postgres:
image: postgres

View File

@ -0,0 +1,17 @@
import pytest
from django.contrib.auth.models import AnonymousUser
from django.db.models import Q
from funkwhale_api.common import fields
from funkwhale_api.users.factories import UserFactory
@pytest.mark.parametrize('user,expected', [
(AnonymousUser(), Q(privacy_level='everyone')),
(UserFactory.build(pk=1),
Q(privacy_level__in=['me', 'followers', 'instance', 'everyone'])),
])
def test_privacy_level_query(user,expected):
query = fields.privacy_level_query(user)
assert query == expected

View File

@ -0,0 +1,42 @@
import pytest
from rest_framework.views import APIView
from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from funkwhale_api.common import permissions
def test_owner_permission_owner_field_ok(nodb_factories, api_request):
playlist = nodb_factories['playlists.Playlist']()
view = APIView.as_view()
permission = permissions.OwnerPermission()
request = api_request.get('/')
setattr(request, 'user', playlist.user)
check = permission.has_object_permission(request, view, playlist)
assert check is True
def test_owner_permission_owner_field_not_ok(nodb_factories, api_request):
playlist = nodb_factories['playlists.Playlist']()
view = APIView.as_view()
permission = permissions.OwnerPermission()
request = api_request.get('/')
setattr(request, 'user', AnonymousUser())
with pytest.raises(Http404):
permission.has_object_permission(request, view, playlist)
def test_owner_permission_read_only(nodb_factories, api_request):
playlist = nodb_factories['playlists.Playlist']()
view = APIView.as_view()
setattr(view, 'owner_checks', ['write'])
permission = permissions.OwnerPermission()
request = api_request.get('/')
setattr(request, 'user', AnonymousUser())
check = permission.has_object_permission(request, view, playlist)
assert check is True

View File

@ -1,9 +1,13 @@
import factory
import tempfile
import shutil
import pytest
from django.core.cache import cache as django_cache
from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient
from rest_framework.test import APIRequestFactory
from funkwhale_api.activity import record
from funkwhale_api.taskapp import celery
@ -26,6 +30,16 @@ def cache():
@pytest.fixture
def factories(db):
from funkwhale_api import factories
for v in factories.registry.values():
v._meta.strategy = factory.CREATE_STRATEGY
yield factories.registry
@pytest.fixture
def nodb_factories():
from funkwhale_api import factories
for v in factories.registry.values():
v._meta.strategy = factory.BUILD_STRATEGY
yield factories.registry
@ -84,6 +98,11 @@ def superuser_client(db, factories, client):
delattr(client, 'user')
@pytest.fixture
def api_request():
return APIRequestFactory()
@pytest.fixture
def activity_registry():
r = record.registry

View File

@ -0,0 +1,45 @@
import pytest
from funkwhale_api.music import views
@pytest.mark.parametrize('param,expected', [
('true', 'full'),
('false', 'empty'),
])
def test_artist_view_filter_listenable(
param, expected, factories, api_request):
artists = {
'empty': factories['music.Artist'](),
'full': factories['music.TrackFile']().track.artist,
}
request = api_request.get('/', {'listenable': param})
view = views.ArtistViewSet()
view.action_map = {'get': 'list'}
expected = [artists[expected]]
view.request = view.initialize_request(request)
queryset = view.filter_queryset(view.get_queryset())
assert list(queryset) == expected
@pytest.mark.parametrize('param,expected', [
('true', 'full'),
('false', 'empty'),
])
def test_album_view_filter_listenable(
param, expected, factories, api_request):
artists = {
'empty': factories['music.Album'](),
'full': factories['music.TrackFile']().track.album,
}
request = api_request.get('/', {'listenable': param})
view = views.AlbumViewSet()
view.action_map = {'get': 'list'}
expected = [artists[expected]]
view.request = view.initialize_request(request)
queryset = view.filter_queryset(view.get_queryset())
assert list(queryset) == expected

View File

@ -0,0 +1,126 @@
import pytest
from rest_framework import exceptions
def test_can_insert_plt(factories):
plt = factories['playlists.PlaylistTrack']()
modification_date = plt.playlist.modification_date
assert plt.index is None
plt.playlist.insert(plt)
plt.refresh_from_db()
assert plt.index == 0
assert plt.playlist.modification_date > modification_date
def test_insert_use_last_idx_by_default(factories):
playlist = factories['playlists.Playlist']()
plts = factories['playlists.PlaylistTrack'].create_batch(
size=3, playlist=playlist)
for i, plt in enumerate(plts):
index = playlist.insert(plt)
plt.refresh_from_db()
assert index == i
assert plt.index == i
def test_can_insert_at_index(factories):
playlist = factories['playlists.Playlist']()
first = factories['playlists.PlaylistTrack'](playlist=playlist)
playlist.insert(first)
new_first = factories['playlists.PlaylistTrack'](playlist=playlist)
index = playlist.insert(new_first, index=0)
first.refresh_from_db()
new_first.refresh_from_db()
assert index == 0
assert first.index == 1
assert new_first.index == 0
def test_can_insert_and_move(factories):
playlist = factories['playlists.Playlist']()
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
playlist.insert(second, index=0)
first.refresh_from_db()
second.refresh_from_db()
third.refresh_from_db()
assert third.index == 2
assert second.index == 0
assert first.index == 1
def test_can_insert_and_move_last_to_0(factories):
playlist = factories['playlists.Playlist']()
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
playlist.insert(third, index=0)
first.refresh_from_db()
second.refresh_from_db()
third.refresh_from_db()
assert third.index == 0
assert first.index == 1
assert second.index == 2
def test_cannot_insert_at_wrong_index(factories):
plt = factories['playlists.PlaylistTrack']()
new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
with pytest.raises(exceptions.ValidationError):
plt.playlist.insert(new, 2)
def test_cannot_insert_at_negative_index(factories):
plt = factories['playlists.PlaylistTrack']()
new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
with pytest.raises(exceptions.ValidationError):
plt.playlist.insert(new, -1)
def test_remove_update_indexes(factories):
playlist = factories['playlists.Playlist']()
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
second.delete(update_indexes=True)
first.refresh_from_db()
third.refresh_from_db()
assert first.index == 0
assert third.index == 1
def test_can_insert_many(factories):
playlist = factories['playlists.Playlist']()
existing = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
tracks = factories['music.Track'].create_batch(size=3)
plts = playlist.insert_many(tracks)
for i, plt in enumerate(plts):
assert plt.index == i + 1
assert plt.track == tracks[i]
assert plt.playlist == playlist
def test_insert_many_honor_max_tracks(factories, settings):
settings.PLAYLISTS_MAX_TRACKS = 4
playlist = factories['playlists.Playlist']()
plts = factories['playlists.PlaylistTrack'].create_batch(
size=2, playlist=playlist)
track = factories['music.Track']()
with pytest.raises(exceptions.ValidationError):
playlist.insert_many([track, track, track])

View File

@ -0,0 +1,74 @@
from funkwhale_api.playlists import models
from funkwhale_api.playlists import serializers
def test_cannot_max_500_tracks_per_playlist(factories, settings):
settings.PLAYLISTS_MAX_TRACKS = 2
playlist = factories['playlists.Playlist']()
plts = factories['playlists.PlaylistTrack'].create_batch(
size=2, playlist=playlist)
track = factories['music.Track']()
serializer = serializers.PlaylistTrackWriteSerializer(data={
'playlist': playlist.pk,
'track': track.pk,
})
assert serializer.is_valid() is False
assert 'playlist' in serializer.errors
def test_create_insert_is_called_when_index_is_None(factories, mocker):
insert = mocker.spy(models.Playlist, 'insert')
playlist = factories['playlists.Playlist']()
track = factories['music.Track']()
serializer = serializers.PlaylistTrackWriteSerializer(data={
'playlist': playlist.pk,
'track': track.pk,
'index': None,
})
assert serializer.is_valid() is True
plt = serializer.save()
insert.assert_called_once_with(playlist, plt, None)
assert plt.index == 0
def test_create_insert_is_called_when_index_is_provided(factories, mocker):
playlist = factories['playlists.Playlist']()
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
insert = mocker.spy(models.Playlist, 'insert')
factories['playlists.Playlist']()
track = factories['music.Track']()
serializer = serializers.PlaylistTrackWriteSerializer(data={
'playlist': playlist.pk,
'track': track.pk,
'index': 0,
})
assert serializer.is_valid() is True
plt = serializer.save()
first.refresh_from_db()
insert.assert_called_once_with(playlist, plt, 0)
assert plt.index == 0
assert first.index == 1
def test_update_insert_is_called_when_index_is_provided(factories, mocker):
playlist = factories['playlists.Playlist']()
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
insert = mocker.spy(models.Playlist, 'insert')
factories['playlists.Playlist']()
track = factories['music.Track']()
serializer = serializers.PlaylistTrackWriteSerializer(second, data={
'playlist': playlist.pk,
'track': second.track.pk,
'index': 0,
})
assert serializer.is_valid() is True
plt = serializer.save()
first.refresh_from_db()
insert.assert_called_once_with(playlist, plt, 0)
assert plt.index == 0
assert first.index == 1

View File

@ -0,0 +1,197 @@
import json
import pytest
from django.urls import reverse
from django.core.exceptions import ValidationError
from django.utils import timezone
from funkwhale_api.playlists import models
from funkwhale_api.playlists import serializers
def test_can_create_playlist_via_api(logged_in_api_client):
url = reverse('api:v1:playlists-list')
data = {
'name': 'test',
'privacy_level': 'everyone'
}
response = logged_in_api_client.post(url, data)
playlist = logged_in_api_client.user.playlists.latest('id')
assert playlist.name == 'test'
assert playlist.privacy_level == 'everyone'
def test_serializer_includes_tracks_count(factories, logged_in_api_client):
playlist = factories['playlists.Playlist']()
plt = factories['playlists.PlaylistTrack'](playlist=playlist)
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
response = logged_in_api_client.get(url)
assert response.data['tracks_count'] == 1
def test_playlist_inherits_user_privacy(logged_in_api_client):
url = reverse('api:v1:playlists-list')
user = logged_in_api_client.user
user.privacy_level = 'me'
user.save()
data = {
'name': 'test',
}
response = logged_in_api_client.post(url, data)
playlist = user.playlists.latest('id')
assert playlist.privacy_level == user.privacy_level
def test_can_add_playlist_track_via_api(factories, logged_in_api_client):
tracks = factories['music.Track'].create_batch(5)
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
url = reverse('api:v1:playlist-tracks-list')
data = {
'playlist': playlist.pk,
'track': tracks[0].pk
}
response = logged_in_api_client.post(url, data)
assert response.status_code == 201
plts = logged_in_api_client.user.playlists.latest('id').playlist_tracks.all()
assert plts.first().track == tracks[0]
@pytest.mark.parametrize('name,method', [
('api:v1:playlist-tracks-list', 'post'),
('api:v1:playlists-list', 'post'),
])
def test_url_requires_login(name, method, factories, api_client):
url = reverse(name)
response = getattr(api_client, method)(url, {})
assert response.status_code == 401
def test_only_can_add_track_on_own_playlist_via_api(
factories, logged_in_api_client):
track = factories['music.Track']()
playlist = factories['playlists.Playlist']()
url = reverse('api:v1:playlist-tracks-list')
data = {
'playlist': playlist.pk,
'track': track.pk
}
response = logged_in_api_client.post(url, data)
assert response.status_code == 400
assert playlist.playlist_tracks.count() == 0
def test_deleting_plt_updates_indexes(
mocker, factories, logged_in_api_client):
remove = mocker.spy(models.Playlist, 'remove')
track = factories['music.Track']()
plt = factories['playlists.PlaylistTrack'](
index=0,
playlist__user=logged_in_api_client.user)
url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
remove.assert_called_once_with(plt.playlist, 0)
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
factories['playlists.Playlist'](privacy_level=level)
url = reverse('api:v1:playlists-list')
response = api_client.get(url)
assert response.data['count'] == 0
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
def test_only_owner_can_edit_playlist(method, factories, api_client):
playlist = factories['playlists.Playlist']()
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
response = api_client.get(url)
assert response.status_code == 404
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
def test_only_owner_can_edit_playlist_track(method, factories, api_client):
plt = factories['playlists.PlaylistTrack']()
url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
response = api_client.get(url)
assert response.status_code == 404
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_playlist_track_privacy_respected_in_list_anon(
level, factories, api_client):
factories['playlists.PlaylistTrack'](playlist__privacy_level=level)
url = reverse('api:v1:playlist-tracks-list')
response = api_client.get(url)
assert response.data['count'] == 0
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_can_list_tracks_from_playlist(
level, factories, logged_in_api_client):
plt = factories['playlists.PlaylistTrack'](
playlist__user=logged_in_api_client.user)
url = reverse('api:v1:playlists-tracks', kwargs={'pk': plt.playlist.pk})
response = logged_in_api_client.get(url)
serialized_plt = serializers.PlaylistTrackSerializer(plt).data
assert response.data['count'] == 1
assert response.data['results'][0] == serialized_plt
def test_can_add_multiple_tracks_at_once_via_api(
factories, mocker, logged_in_api_client):
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
tracks = factories['music.Track'].create_batch(size=5)
track_ids = [t.id for t in tracks]
mocker.spy(playlist, 'insert_many')
url = reverse('api:v1:playlists-add', kwargs={'pk': playlist.pk})
response = logged_in_api_client.post(url, {'tracks': track_ids})
assert response.status_code == 201
assert playlist.playlist_tracks.count() == len(track_ids)
for plt in playlist.playlist_tracks.order_by('index'):
assert response.data['results'][plt.index]['id'] == plt.id
assert plt.track == tracks[plt.index]
def test_can_clear_playlist_from_api(
factories, mocker, logged_in_api_client):
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
plts = factories['playlists.PlaylistTrack'].create_batch(
size=5, playlist=playlist)
url = reverse('api:v1:playlists-clear', kwargs={'pk': playlist.pk})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
assert playlist.playlist_tracks.count() == 0
def test_update_playlist_from_api(
factories, mocker, logged_in_api_client):
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
plts = factories['playlists.PlaylistTrack'].create_batch(
size=5, playlist=playlist)
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
response = logged_in_api_client.patch(url, {'name': 'test'})
playlist.refresh_from_db()
assert response.status_code == 200
assert response.data['user']['username'] == playlist.user.username

View File

@ -1,54 +0,0 @@
import json
from django.urls import reverse
from django.core.exceptions import ValidationError
from django.utils import timezone
from funkwhale_api.playlists import models
from funkwhale_api.playlists.serializers import PlaylistSerializer
def test_can_create_playlist(factories):
tracks = factories['music.Track'].create_batch(5)
playlist = factories['playlists.Playlist']()
previous = None
for track in tracks:
previous = playlist.add_track(track, previous=previous)
playlist_tracks = list(playlist.playlist_tracks.all())
previous = None
for idx, track in enumerate(tracks):
plt = playlist_tracks[idx]
assert plt.position == idx
assert plt.track == track
if previous:
assert playlist_tracks[idx + 1] == previous
assert plt.playlist == playlist
def test_can_create_playlist_via_api(logged_in_client):
url = reverse('api:v1:playlists-list')
data = {
'name': 'test',
}
response = logged_in_client.post(url, data)
playlist = logged_in_client.user.playlists.latest('id')
assert playlist.name == 'test'
def test_can_add_playlist_track_via_api(factories, logged_in_client):
tracks = factories['music.Track'].create_batch(5)
playlist = factories['playlists.Playlist'](user=logged_in_client.user)
url = reverse('api:v1:playlist-tracks-list')
data = {
'playlist': playlist.pk,
'track': tracks[0].pk
}
response = logged_in_client.post(url, data)
plts = logged_in_client.user.playlists.latest('id').playlist_tracks.all()
assert plts.first().track == tracks[0]

View File

@ -71,3 +71,12 @@ services:
- ./api/funkwhale_api/media:/protected/media
ports:
- "0.0.0.0:6001:6001"
docs:
build: docs
command: python serve.py
volumes:
- ".:/app/"
ports:
- '35730:35730'
- '8001:8001'

4
docs/Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM python:3.6-alpine
RUN pip install sphinx livereload
WORKDIR /app/docs

13
docs/serve.py Normal file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env python
from subprocess import call
# initial make
call(["python", "-m", "sphinx", ".", "/tmp/_build"])
from livereload import Server, shell
server = Server()
server.watch('.', shell('python -m sphinx . /tmp/_build'))
server.serve(
root='/tmp/_build/',
liveport=35730,
port=8001,
host='0.0.0.0')

View File

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

View File

@ -94,9 +94,9 @@
<p>Funkwhale is dead simple to use.</p>
<div class="ui list">
<div class="item">
<i class="libraryr icon"></i>
<i class="book icon"></i>
<div class="content">
No add-ons, no plugins : you only need a web libraryr
No add-ons, no plugins : you only need a web library
</div>
</div>
<div class="item">

View File

@ -1,6 +1,7 @@
<template>
<div class="ui pagination borderless menu">
<a
v-if="current - 1 >= 1"
@click="selectPage(current - 1)"
:class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></a>
<template>
@ -16,6 +17,7 @@
</a>
</template>
<a
v-if="current + 1 <= maxPage"
@click="selectPage(current + 1)"
:class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></a>
</div>
@ -62,7 +64,6 @@ export default {
}
}
})
console.log(final)
return final
},
maxPage: function () {

View File

@ -36,6 +36,12 @@
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
<a
@click="$store.commit('playlists/chooseTrack', null)"
v-if="$store.state.auth.authenticated"
class="item">
<i class="list icon"></i> Playlists
</a>
<router-link
v-if="$store.state.auth.authenticated"
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
@ -70,7 +76,7 @@
<td>
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
<i class="pink heart icon"></i>
</template
</template>
</td>
<td>
<i @click.stop="cleanTrack(index)" class="circular trash icon"></i>
@ -148,7 +154,7 @@ export default {
<style scoped lang="scss">
@import '../style/vendor/media';
$sidebar-color: #1B1C1D;
$sidebar-color: #3D3E3F;
.sidebar {
background: $sidebar-color;
@ -159,7 +165,7 @@ $sidebar-color: #1B1C1D;
}
@include media(">desktop") {
.collapse.button {
display: none;
display: none !important;
}
}
@include media("<desktop") {
@ -176,16 +182,26 @@ $sidebar-color: #1B1C1D;
margin: 0;
background-color: $sidebar-color;
}
.menu {
.menu.vertical {
background: $sidebar-color;
}
}
.menu-area {
padding: 0.5rem;
.menu .item:not(.active):not(:hover) {
background-color: rgba(255, 255, 255, 0.06);
opacity: 0.75;
}
.menu .item {
border-radius: 0;
}
.menu .item.active {
background-color: $sidebar-color;
&:hover {
background-color: rgba(255, 255, 255, 0.06);
}
}
}
.tabs {
overflow-y: auto;
@ -216,14 +232,33 @@ $sidebar-color: #1B1C1D;
.logo {
cursor: pointer;
display: inline-block;
margin: 0px;
}
.ui.search {
display: block;
.collapse.button {
margin-right: 0.5rem;
margin-top: 0.5rem;
float: right;
display: flex;
.collapse.button, .collapse.button:hover, .collapse.button:active {
box-shadow: none !important;
margin: 0px;
display: flex;
flex-direction: column;
justify-content: center;
}
}
.ui.message.black {
background: $sidebar-color;
}
</style>
<style lang="scss">
.sidebar {
.ui.search .input {
flex: 1;
.prompt {
border-radius: 0;
}
}
}
</style>

View File

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

View File

@ -3,7 +3,7 @@
<div class="player">
<audio-track
ref="currentAudio"
v-if="currentTrack"
v-if="renderAudio && currentTrack"
:key="(currentIndex, currentTrack.id)"
:is-current="true"
:start-time="$store.state.player.currentTime"
@ -30,7 +30,12 @@
</router-link>
</div>
<div class="description">
<track-favorite-icon :track="currentTrack"></track-favorite-icon>
<track-favorite-icon
v-if="$store.state.auth.authenticated"
:track="currentTrack"></track-favorite-icon>
<track-playlist-icon
v-if="$store.state.auth.authenticated"
:track="currentTrack"></track-playlist-icon>
</div>
</div>
</div>
@ -140,17 +145,20 @@ import ColorThief from '@/vendor/color-thief'
import Track from '@/audio/track'
import AudioTrack from '@/components/audio/Track'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
export default {
name: 'player',
components: {
TrackFavoriteIcon,
TrackPlaylistIcon,
GlobalEvents,
AudioTrack
},
data () {
let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]]
return {
renderAudio: true,
sliderVolume: this.volume,
Track: Track,
defaultAmbiantColors: defaultAmbiantColors,
@ -163,7 +171,6 @@ export default {
},
methods: {
...mapActions({
pause: 'player/pause',
togglePlay: 'player/togglePlay',
clean: 'queue/clean',
next: 'queue/next',
@ -230,6 +237,17 @@ export default {
this.ambiantColors = this.defaultAmbiantColors
}
},
currentIndex (newValue, oldValue) {
if (newValue !== oldValue) {
// why this? to ensure the audio tag is deleted and fully
// rerendered, so we don't have any issues with cached position
// or whatever
this.renderAudio = false
this.$nextTick(() => {
this.renderAudio = true
})
}
},
volume (newValue) {
this.sliderVolume = newValue
},
@ -270,6 +288,7 @@ export default {
cursor: pointer
}
.track-area {
margin-top: 0;
.header, .meta, .artist, .album {
color: white !important;
}
@ -373,4 +392,5 @@ export default {
.ui.feed.icon {
margin: 0;
}
</style>

View File

@ -30,6 +30,9 @@ export default {
},
apiSettings: {
beforeXHR: function (xhrObject) {
if (!self.$store.state.auth.authenticated) {
return xhrObject
}
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
return xhrObject
},

View File

@ -6,12 +6,13 @@
<img v-else src="../../../assets/audio/default-cover.png">
</div>
<div class="header">
<router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">{{ album.title }}</router-link>
<router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">{{ album.title }} </router-link>
</div>
<div class="meta">
By <router-link :to="{name: 'library.artists.detail', params: {id: album.artist.id }}">
{{ album.artist.name }}
</router-link>
<span>
By <router-link tag="span" :to="{name: 'library.artists.detail', params: {id: album.artist.id }}">
{{ album.artist.name }}</router-link>
</span><span class="time" v-if="album.release_date"> {{ album.release_date | year }}</span>
</div>
<div class="description" v-if="mode === 'rich'">
<table class="ui very basic fixed single line compact unstackable table">

View File

@ -0,0 +1,70 @@
<template>
<tr>
<td>
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
</td>
<td>
<img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
</td>
<td colspan="6">
<router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
<template v-if="displayPosition && track.position">
{{ track.position }}.
</template>
{{ track.title }}
</router-link>
</td>
<td colspan="6">
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
{{ track.artist.name }}
</router-link>
</td>
<td colspan="6">
<router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
{{ track.album.title }}
</router-link>
</td>
<td>
<track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon>
<track-playlist-icon
v-if="$store.state.auth.authenticated"
:track="track"></track-playlist-icon>
</td>
</tr>
</template>
<script>
import backend from '@/audio/backend'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
import PlayButton from '@/components/audio/PlayButton'
export default {
props: {
track: {type: Object, required: true},
displayPosition: {type: Boolean, default: false}
},
components: {
TrackFavoriteIcon,
TrackPlaylistIcon,
PlayButton
},
data () {
return {
backend: backend
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
tr:not(:hover) {
.favorite-icon:not(.favorited), .playlist-icon {
display: none;
}
}
</style>

View File

@ -11,34 +11,11 @@
</tr>
</thead>
<tbody>
<tr v-for="track in tracks">
<td>
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
</td>
<td>
<img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
</td>
<td colspan="6">
<router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
<template v-if="displayPosition && track.position">
{{ track.position }}.
</template>
{{ track.title }}
</router-link>
</td>
<td colspan="6">
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
{{ track.artist.name }}
</router-link>
</td>
<td colspan="6">
<router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
{{ track.album.title }}
</router-link>
</td>
<td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td>
</tr>
<track-row
:display-position="displayPosition"
:track="track"
:key="index + '-' + track.id"
v-for="(track, index) in tracks"></track-row>
</tbody>
<tfoot class="full-width">
<tr>
@ -83,9 +60,8 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.aut
<script>
import backend from '@/audio/backend'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import PlayButton from '@/components/audio/PlayButton'
import TrackRow from '@/components/audio/track/Row'
import Modal from '@/components/semantic/Modal'
export default {
@ -95,8 +71,7 @@ export default {
},
components: {
Modal,
TrackFavoriteIcon,
PlayButton
TrackRow
},
data () {
return {

View File

@ -0,0 +1,48 @@
<template>
<div @click="showModal = true" :class="['ui', color, {disabled: disabled}, 'button']" :disabled="disabled">
<slot></slot>
<modal class="small" :show.sync="showModal">
<div class="header">
<slot name="modal-header">Do you want to confirm this action?</slot>
</div>
<div class="scrolling content">
<div class="description">
<slot name="modal-content"></slot>
</div>
</div>
<div class="actions">
<div class="ui cancel button">Cancel</div>
<div :class="['ui', 'confirm', color, 'button']" @click="confirm">
<slot name="modal-confirm">Confirm</slot>
</div>
</div>
</modal>
</div>
</template>
<script>
import Modal from '@/components/semantic/Modal'
export default {
props: {
action: {type: Function, required: true},
disabled: {type: Boolean, default: false},
color: {type: String, default: 'red'}
},
components: {
Modal
},
data () {
return {
showModal: false
}
},
methods: {
confirm () {
this.showModal = false
this.action()
}
}
}
</script>

View File

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

View File

@ -31,7 +31,7 @@
<div class="ui vertical stripe segment">
<h2>Albums by this artist</h2>
<div class="ui stackable doubling three column grid">
<div class="column" :key="album.id" v-for="album in albums">
<div class="column" :key="album.id" v-for="album in sortedAlbums">
<album-card :mode="'rich'" class="fluid" :album="album"></album-card>
</div>
</div>
@ -41,6 +41,7 @@
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import logger from '@/logging'
import backend from '@/audio/backend'
@ -83,6 +84,10 @@ export default {
}
},
computed: {
sortedAlbums () {
let a = this.albums || []
return _.orderBy(a, ['release_date'], ['asc'])
},
totalTracks () {
return this.albums.map((album) => {
return album.tracks.length

View File

@ -129,7 +129,8 @@ export default {
page: this.page,
page_size: this.paginateBy,
name__icontains: this.query,
ordering: this.getOrderingAsString()
ordering: this.getOrderingAsString(),
listenable: 'true'
}
logger.default.debug('Fetching artists')
axios.get(url, {params: params}).then((response) => {

View File

@ -20,7 +20,7 @@
</div>
<div class="column">
<h2 class="ui header">Music requests</h2>
<request-form></request-form>
<request-form v-if="$store.state.auth.authenticated"></request-form>
</div>
</div>
</div>

View File

@ -4,8 +4,9 @@
<router-link class="ui item" to="/library" exact>Browse</router-link>
<router-link class="ui item" to="/library/artists" exact>Artists</router-link>
<router-link class="ui item" to="/library/radios" exact>Radios</router-link>
<router-link class="ui item" to="/library/playlists" exact>Playlists</router-link>
<div class="ui secondary right menu">
<router-link class="ui item" to="/library/requests/" exact>
<router-link v-if="$store.state.auth.authenticated" class="ui item" to="/library/requests/" exact>
Requests
<div class="ui teal label">{{ requestsCount }}</div>
</router-link>
@ -32,8 +33,11 @@ export default {
},
methods: {
fetchRequestsCount () {
if (!this.$store.state.authenticated) {
return
}
let self = this
axios.get('requests/import-requests', {params: {status: 'pending'}}).then(response => {
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
self.requestsCount = response.data.count
})
}

View File

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

View File

@ -0,0 +1,40 @@
<template>
<div class="ui card">
<div class="content">
<div class="header">
<router-link class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">
{{ playlist.name }}
</router-link>
</div>
<div class="meta">
<i class="user icon"></i> {{ playlist.user.username }}
</div>
<div class="meta">
<i class="clock icon"></i> Updated <human-date :date="playlist.modification_date"></human-date>
</div>
</div>
<div class="extra content">
<span>
<i class="sound icon"></i>
{{ playlist.tracks_count }} tracks
</span>
<play-button class="mini basic orange right floated" :playlist="playlist">Play all</play-button>
</div>
</div>
</template>
<script>
import PlayButton from '@/components/audio/PlayButton'
export default {
props: ['playlist'],
components: {
PlayButton
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,34 @@
<template>
<div
v-if="playlists.length > 0"
v-masonry
transition-duration="0"
item-selector=".column"
percent-position="true"
stagger="0"
class="ui stackable three column doubling grid">
<div
v-masonry-tile
v-for="playlist in playlists"
:key="playlist.id"
class="column">
<playlist-card class="fluid" :playlist="playlist"></playlist-card>
</div>
</div>
</template>
<script>
import PlaylistCard from '@/components/playlists/Card'
export default {
props: ['playlists'],
components: {
PlaylistCard
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,178 @@
<template>
<div class="ui text container">
<playlist-form @updated="$emit('playlist-updated', $event)" :title="false" :playlist="playlist"></playlist-form>
<h3 class="ui top attached header">
Playlist editor
</h3>
<div class="ui attached segment">
<template v-if="status === 'loading'">
<div class="ui active tiny inline loader"></div>
Syncing changes to server...
</template>
<template v-else-if="status === 'errored'">
<i class="red close icon"></i>
An error occured while saving your changes
<div v-if="errors.length > 0" class="ui negative message">
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
</template>
<template v-else-if="status === 'saved'">
<i class="green check icon"></i> Changes synced with server
</template>
</div>
<div class="ui bottom attached segment">
<div
@click="insertMany(queueTracks)"
:disabled="queueTracks.length === 0"
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
title="Copy tracks from current queue to playlist">
<i class="plus icon"></i> Insert from queue ({{ queueTracks.length }} tracks)</div>
<dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
<i class="eraser icon"></i> Clear playlist
<p slot="modal-header">Do you want to clear the playlist "{{ playlist.name }}"?</p>
<p slot="modal-content">This will remove all tracks from this playlist and cannot be undone.</p>
<p slot="modal-confirm">Clear playlist</p>
</dangerous-button>
<div class="ui hidden divider"></div>
<template v-if="plts.length > 0">
<p>Drag and drop rows to reorder tracks in the playlist</p>
<table class="ui compact very basic fixed single line unstackable table">
<draggable v-model="plts" element="tbody" @update="reorder">
<tr v-for="(plt, index) in plts" :key="plt.id">
<td class="left aligned">{{ plt.index + 1}}</td>
<td class="center aligned">
<img class="ui mini image" v-if="plt.track.album.cover" :src="plt.track.album.cover">
<img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
</td>
<td colspan="4">
<strong>{{ plt.track.title }}</strong><br />
{{ plt.track.artist.name }}
</td>
<td class="right aligned">
<i @click.stop="removePlt(index)" class="circular red trash icon"></i>
</td>
</tr>
</draggable>
</table>
</template>
</div>
</div>
</template>
<script>
import {mapState} from 'vuex'
import axios from 'axios'
import PlaylistForm from '@/components/playlists/Form'
import draggable from 'vuedraggable'
export default {
components: {
draggable,
PlaylistForm
},
props: ['playlist', 'playlistTracks'],
data () {
return {
plts: this.playlistTracks,
isLoading: false,
errors: []
}
},
methods: {
success () {
this.isLoading = false
this.errors = []
},
errored (errors) {
this.isLoading = false
this.errors = errors
},
reorder ({oldIndex, newIndex}) {
let self = this
self.isLoading = true
let plt = this.plts[newIndex]
let url = 'playlist-tracks/' + plt.id + '/'
axios.patch(url, {index: newIndex}).then((response) => {
self.success()
}, error => {
self.errored(error.backendErrors)
})
},
removePlt (index) {
let plt = this.plts[index]
this.plts.splice(index, 1)
let self = this
self.isLoading = true
let url = 'playlist-tracks/' + plt.id + '/'
axios.delete(url).then((response) => {
self.success()
self.$store.dispatch('playlists/fetchOwn')
}, error => {
self.errored(error.backendErrors)
})
},
clearPlaylist () {
this.plts = []
let self = this
self.isLoading = true
let url = 'playlists/' + this.playlist.id + '/clear'
axios.delete(url).then((response) => {
self.success()
self.$store.dispatch('playlists/fetchOwn')
}, error => {
self.errored(error.backendErrors)
})
},
insertMany (tracks) {
let self = this
let ids = tracks.map(t => {
return t.id
})
self.isLoading = true
let url = 'playlists/' + this.playlist.id + '/add/'
axios.post(url, {tracks: ids}).then((response) => {
response.data.results.forEach(r => {
self.plts.push(r)
})
self.success()
self.$store.dispatch('playlists/fetchOwn')
}, error => {
self.errored(error.backendErrors)
})
}
},
computed: {
...mapState({
queueTracks: state => state.queue.tracks
}),
status () {
if (this.isLoading) {
return 'loading'
}
if (this.errors.length > 0) {
return 'errored'
}
return 'saved'
}
},
watch: {
plts: {
handler (newValue) {
newValue.forEach((e, i) => {
e.index = i
})
this.$emit('tracks-updated', newValue)
},
deep: true
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,125 @@
<template>
<form class="ui form" @submit.prevent="submit()">
<h4 v-if="title" class="ui header">Create a new playlist</h4>
<div v-if="success" class="ui positive message">
<div class="header">
<template v-if="playlist">
Playlist updated
</template>
<template v-else>
Playlist created
</template>
</div>
</div>
<div v-if="errors.length > 0" class="ui negative message">
<div class="header">We cannot create the playlist</div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="three fields">
<div class="field">
<label>Playlist name</label>
<input v-model="name" required type="text" placeholder="My awesome playlist" />
</div>
<div class="field">
<label>Playlist visibility</label>
<select class="ui dropdown" v-model="privacyLevel">
<option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>&nbsp;</label>
<button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit">
<template v-if="playlist">Update playlist</template>
<template v-else>Create playlist</template>
</button>
</div>
</div>
</form>
</template>
<script>
import $ from 'jquery'
import axios from 'axios'
import logger from '@/logging'
export default {
props: {
title: {type: Boolean, default: true},
playlist: {type: Object, default: null}
},
mounted () {
$(this.$el).find('.dropdown').dropdown()
},
data () {
let d = {
errors: [],
success: false,
isLoading: false,
privacyLevelChoices: [
{
value: 'me',
label: 'Nobody except me'
},
{
value: 'instance',
label: 'Everyone on this instance'
},
{
value: 'everyone',
label: 'Everyone'
}
]
}
if (this.playlist) {
d.name = this.playlist.name
d.privacyLevel = this.playlist.privacy_level
} else {
d.privacyLevel = this.$store.state.auth.profile.privacy_level
d.name = ''
}
return d
},
methods: {
submit () {
this.isLoading = true
this.success = false
this.errors = []
let self = this
let payload = {
name: this.name,
privacy_level: this.privacyLevel
}
let promise
let url
if (this.playlist) {
url = `playlists/${this.playlist.id}/`
promise = axios.patch(url, payload)
} else {
url = 'playlists/'
promise = axios.post(url, payload)
}
return promise.then(response => {
self.success = true
self.isLoading = false
if (!self.playlist) {
self.name = ''
}
self.$emit('updated', response.data)
self.$store.dispatch('playlists/fetchOwn')
}, error => {
logger.default.error('Error while creating playlist')
self.isLoading = false
self.errors = error.backendErrors
})
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,127 @@
<template>
<modal @update:show="update" :show="$store.state.playlists.showModal">
<div class="header">
Manage playlists
</div>
<div class="scrolling content">
<div class="description">
<template v-if="track">
<h4 class="ui header">Current track</h4>
<div>
"{{ track.title }}" by {{ track.artist.name }}
</div>
<div class="ui divider"></div>
</template>
<playlist-form></playlist-form>
<div class="ui divider"></div>
<div v-if="errors.length > 0" class="ui negative message">
<div class="header">We cannot add the track to a playlist</div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
</div>
<h4 class="ui header">Available playlists</h4>
<table class="ui unstackable very basic table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th class="sorted descending">Last modification</th>
<th>Tracks</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="playlist in sortedPlaylists">
<td>
<router-link
class="ui icon basic small button"
:to="{name: 'library.playlists.detail', params: {id: playlist.id }, query: {mode: 'edit'}}"><i class="ui pencil icon"></i></router-link>
</td>
<td>
<router-link :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">{{ playlist.name }}</router-link></td>
<td><human-date :date="playlist.modification_date"></human-date></td>
<td>{{ playlist.tracks_count }}</td>
<td>
<div
v-if="track"
class="ui green icon basic small right floated button"
title="Add to this playlist"
@click="addToPlaylist(playlist.id)">
<i class="plus icon"></i> Add track
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="actions">
<div class="ui cancel button">Cancel</div>
</div>
</modal>
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import {mapState} from 'vuex'
import logger from '@/logging'
import Modal from '@/components/semantic/Modal'
import PlaylistForm from '@/components/playlists/Form'
export default {
components: {
Modal,
PlaylistForm
},
data () {
return {
errors: []
}
},
methods: {
update (v) {
this.$store.commit('playlists/showModal', v)
},
addToPlaylist (playlistId) {
let self = this
let payload = {
track: this.track.id,
playlist: playlistId
}
return axios.post('playlist-tracks/', payload).then(response => {
logger.default.info('Successfully added track to playlist')
self.update(false)
self.$store.dispatch('playlists/fetchOwn')
}, error => {
logger.default.error('Error while adding track to playlist')
self.errors = error.backendErrors
})
}
},
computed: {
...mapState({
playlists: state => state.playlists.playlists,
track: state => state.playlists.modalTrack
}),
sortedPlaylists () {
let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }])
p.reverse()
return p
}
},
watch: {
'$store.state.route.path' () {
this.$store.commit('playlists/showModal', false)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,34 @@
<template>
<button
@click="$store.commit('playlists/chooseTrack', track)"
v-if="button"
:class="['ui', 'button']">
<i class="list icon"></i>
Add to playlist...
</button>
<i
v-else
@click="$store.commit('playlists/chooseTrack', track)"
:class="['playlist-icon', 'list', 'link', 'icon']"
title="Add to playlist...">
</i>
</template>
<script>
export default {
props: {
track: {type: Object},
button: {type: Boolean, default: false}
},
data () {
return {
showModal: false
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -31,7 +31,7 @@ export default {
if (!state.running) {
return false
} else {
return current.type === this.type & current.objectId === this.objectId
return current.type === this.type && current.objectId === this.objectId && current.customRadioId === this.customRadioId
}
}
}

View File

@ -2,7 +2,7 @@
<div :class="['ui', {'active': show}, 'modal']">
<i class="close icon"></i>
<slot>
</slot>
</div>
</template>
@ -19,26 +19,38 @@ export default {
control: null
}
},
mounted () {
this.control = $(this.$el).modal({
onApprove: function () {
this.$emit('approved')
}.bind(this),
onDeny: function () {
this.$emit('deny')
}.bind(this),
onHidden: function () {
this.$emit('update:show', false)
}.bind(this)
})
beforeDestroy () {
if (this.control) {
this.control.remove()
}
},
methods: {
initModal () {
this.control = $(this.$el).modal({
duration: 100,
onApprove: function () {
this.$emit('approved')
}.bind(this),
onDeny: function () {
this.$emit('deny')
}.bind(this),
onHidden: function () {
this.$emit('update:show', false)
}.bind(this)
})
}
},
watch: {
show: {
handler (newValue) {
if (newValue) {
this.initModal()
this.control.modal('show')
} else {
this.control.modal('hide')
if (this.control) {
this.control.modal('hide')
this.control.remove()
}
}
}
}

View File

@ -35,6 +35,12 @@ export function momentFormat (date, format) {
Vue.filter('moment', momentFormat)
export function year (date) {
return moment(date).year()
}
Vue.filter('year', year)
export function capitalize (str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@ -21,7 +21,8 @@ import RadioBuilder from '@/components/library/radios/Builder'
import BatchList from '@/components/library/import/BatchList'
import BatchDetail from '@/components/library/import/BatchDetail'
import RequestsList from '@/components/requests/RequestsList'
import PlaylistDetail from '@/views/playlists/Detail'
import PlaylistList from '@/views/playlists/List'
import Favorites from '@/components/favorites/List'
Vue.use(Router)
@ -110,6 +111,25 @@ export default new Router({
},
{ path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true },
{ path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true },
{
path: 'playlists/',
name: 'library.playlists.browse',
component: PlaylistList,
props: (route) => ({
defaultOrdering: route.query.ordering,
defaultQuery: route.query.query,
defaultPaginateBy: route.query.paginateBy,
defaultPage: route.query.page
})
},
{
path: 'playlists/:id',
name: 'library.playlists.detail',
component: PlaylistDetail,
props: (route) => ({
id: route.params.id,
defaultEdit: route.query.mode === 'edit' })
},
{ path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },

View File

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

View File

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

View File

@ -0,0 +1,33 @@
import axios from 'axios'
export default {
namespaced: true,
state: {
playlists: [],
showModal: false,
modalTrack: null
},
mutations: {
playlists (state, value) {
state.playlists = value
},
chooseTrack (state, value) {
state.showModal = true
state.modalTrack = value
},
showModal (state, value) {
state.showModal = value
}
},
actions: {
fetchOwn ({commit, rootState}) {
let userId = rootState.auth.profile.id
if (!userId) {
return
}
return axios.get('playlists/', {params: {user: userId}}).then((response) => {
commit('playlists', response.data.results)
})
}
}
}

View File

@ -0,0 +1,115 @@
<template>
<div>
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment">
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted list yellow icon"></i>
<div class="content">
{{ playlist.name }}
<div class="sub header">
Playlist containing {{ playlistTracks.length }} tracks,
by <username :username="playlist.user.username"></username>
</div>
</div>
</h2>
<div class="ui hidden divider"></div>
</button>
<play-button class="orange" :tracks="tracks">Play all</play-button>
<button
class="ui icon button"
v-if="playlist.user.id === $store.state.auth.profile.id"
@click="edit = !edit">
<i class="pencil icon"></i>
<template v-if="edit">End edition</template>
<template v-else>Edit...</template>
</button>
<dangerous-button class="labeled icon" :action="deletePlaylist">
<i class="trash icon"></i> Delete
<p slot="modal-header">Do you want to delete the playlist "{{ playlist.name }}"?</p>
<p slot="modal-content">This will completely delete this playlist and cannot be undone.</p>
<p slot="modal-confirm">Delete playlist</p>
</dangerous-button>
</div>
</div>
<div class="ui vertical stripe segment">
<template v-if="edit">
<playlist-editor
@playlist-updated="playlist = $event"
@tracks-updated="updatePlts"
:playlist="playlist" :playlist-tracks="playlistTracks"></playlist-editor>
</template>
<template v-else>
<h2>Tracks</h2>
<track-table :display-position="true" :tracks="tracks"></track-table>
</template>
</div>
</div>
</template>
<script>
import axios from 'axios'
import TrackTable from '@/components/audio/track/Table'
import RadioButton from '@/components/radios/Button'
import PlayButton from '@/components/audio/PlayButton'
import PlaylistEditor from '@/components/playlists/Editor'
export default {
props: {
id: {required: true},
defaultEdit: {type: Boolean, default: false}
},
components: {
PlaylistEditor,
TrackTable,
PlayButton,
RadioButton
},
data: function () {
return {
edit: this.defaultEdit,
isLoading: false,
playlist: null,
tracks: [],
playlistTracks: []
}
},
created: function () {
this.fetch()
},
methods: {
updatePlts (v) {
this.playlistTracks = v
this.tracks = v.map((e, i) => {
let track = e.track
track.position = i + 1
return track
})
},
fetch: function () {
let self = this
self.isLoading = true
let url = 'playlists/' + this.id + '/'
axios.get(url).then((response) => {
self.playlist = response.data
axios.get(url + 'tracks').then((response) => {
self.updatePlts(response.data.results)
}).then(() => {
self.isLoading = false
})
})
},
deletePlaylist () {
let self = this
let url = 'playlists/' + this.id + '/'
axios.delete(url).then((response) => {
self.$store.dispatch('playlists/fetchOwn')
self.$router.push({
path: '/library'
})
})
}
}
}
</script>

View File

@ -0,0 +1,158 @@
<template>
<div>
<div class="ui vertical stripe segment">
<h2 class="ui header">Browsing playlists</h2>
<div :class="['ui', {'loading': isLoading}, 'form']">
<template v-if="$store.state.auth.authenticated">
<button
@click="$store.commit('playlists/chooseTrack', null)"
class="ui basic green button">Manage your playlists</button>
<div class="ui hidden divider"></div>
</template>
<div class="fields">
<div class="field">
<label>Search</label>
<input type="text" v-model="query" placeholder="Enter an playlist name..."/>
</div>
<div class="field">
<label>Ordering</label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ option[1] }}
</option>
</select>
</div>
<div class="field">
<label>Ordering direction</label>
<select class="ui dropdown" v-model="orderingDirection">
<option value="">Ascending</option>
<option value="-">Descending</option>
</select>
</div>
<div class="field">
<label>Results per page</label>
<select class="ui dropdown" v-model="paginateBy">
<option :value="parseInt(12)">12</option>
<option :value="parseInt(25)">25</option>
<option :value="parseInt(50)">50</option>
</select>
</div>
</div>
</div>
<div class="ui hidden divider"></div>
<playlist-card-list v-if="result" :playlists="result.results"></playlist-card-list>
<div class="ui center aligned basic segment">
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import _ from 'lodash'
import $ from 'jquery'
import OrderingMixin from '@/components/mixins/Ordering'
import PaginationMixin from '@/components/mixins/Pagination'
import PlaylistCardList from '@/components/playlists/CardList'
import Pagination from '@/components/Pagination'
const FETCH_URL = 'playlists/'
export default {
mixins: [OrderingMixin, PaginationMixin],
props: {
defaultQuery: {type: String, required: false, default: ''}
},
components: {
PlaylistCardList,
Pagination
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
isLoading: true,
result: null,
page: parseInt(this.defaultPage),
query: this.defaultQuery,
paginateBy: parseInt(this.defaultPaginateBy || 12),
orderingDirection: defaultOrdering.direction,
ordering: defaultOrdering.field,
orderingOptions: [
['creation_date', 'Creation date'],
['modification_date', 'Last modification date'],
['name', 'Name']
]
}
},
created () {
this.fetchData()
},
mounted () {
$('.ui.dropdown').dropdown()
},
methods: {
updateQueryString: _.debounce(function () {
this.$router.replace({
query: {
query: this.query,
page: this.page,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
}
})
}, 500),
fetchData: _.debounce(function () {
var self = this
this.isLoading = true
let url = FETCH_URL
let params = {
page: this.page,
page_size: this.paginateBy,
q: this.query,
ordering: this.getOrderingAsString()
}
axios.get(url, {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
})
}, 500),
selectPage: function (page) {
this.page = page
}
},
watch: {
page () {
this.updateQueryString()
this.fetchData()
},
paginateBy () {
this.updateQueryString()
this.fetchData()
},
ordering () {
this.updateQueryString()
this.fetchData()
},
orderingDirection () {
this.updateQueryString()
this.fetchData()
},
query () {
this.updateQueryString()
this.fetchData()
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -1,4 +1,4 @@
import {truncate, markdown, ago, capitalize} from '@/filters'
import {truncate, markdown, ago, capitalize, year} from '@/filters'
describe('filters', () => {
describe('truncate', () => {
@ -32,6 +32,13 @@ describe('filters', () => {
expect(output).to.equal('a few seconds ago')
})
})
describe('year', () => {
it('works', () => {
const input = '2017-07-13'
let output = year(input)
expect(output).to.equal(2017)
})
})
describe('capitalize', () => {
it('works', () => {
const input = 'hello world'

View File

@ -180,7 +180,8 @@ describe('store/auth', () => {
{ type: 'permission', payload: {key: 'admin', status: true} }
],
expectedActions: [
{ type: 'favorites/fetch', payload: null, options: {root: true} }
{ type: 'favorites/fetch', payload: null, options: {root: true} },
{ type: 'playlists/fetchOwn', payload: null, options: {root: true} },
]
}, done)
})

View File

@ -0,0 +1,36 @@
var sinon = require('sinon')
import moxios from 'moxios'
import store from '@/store/playlists'
import { testAction } from '../../utils'
describe('store/playlists', () => {
var sandbox
beforeEach(function () {
sandbox = sinon.sandbox.create()
moxios.install()
})
afterEach(function () {
sandbox.restore()
moxios.uninstall()
})
describe('mutations', () => {
it('set playlists', () => {
const state = { playlists: [] }
store.mutations.playlists(state, [{id: 1, name: 'test'}])
expect(state.playlists).to.deep.equal([{id: 1, name: 'test'}])
})
})
describe('actions', () => {
it('fetchOwn does nothing with no user', (done) => {
testAction({
action: store.actions.fetchOwn,
payload: null,
params: {state: { playlists: [] }, rootState: {auth: {profile: {}}}},
expectedMutations: []
}, done)
})
})
})