Merge branch 'release/0.7'
This commit is contained in:
commit
3673f624dd
|
@ -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
|
||||
|
|
32
CHANGELOG
32
CHANGELOG
|
@ -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:
|
||||
|
|
183
README.rst
183
README.rst
|
@ -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!
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
if [ $1 = "pytest" ]; then
|
||||
# let pytest.ini handle it
|
||||
unset DJANGO_SETTINGS_MODULE
|
||||
fi
|
||||
exec "$@"
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -23,3 +23,4 @@ RUN pip install -r /requirements/test.txt
|
|||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["compose/django/dev-entrypoint.sh"]
|
||||
|
|
|
@ -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('.')])
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
PRIVACY_LEVEL_CHOICES = [
|
||||
('me', 'Only me'),
|
||||
('followers', 'Me and my followers'),
|
||||
('instance', 'Everyone on my instance, and my followers'),
|
||||
('everyone', 'Everyone, including people on other instances'),
|
||||
]
|
||||
|
||||
|
||||
def get_privacy_field():
|
||||
return models.CharField(
|
||||
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
|
||||
|
||||
|
||||
def privacy_level_query(user, lookup_field='privacy_level'):
|
||||
if user.is_anonymous:
|
||||
return models.Q(**{
|
||||
lookup_field: 'everyone',
|
||||
})
|
||||
|
||||
return models.Q(**{
|
||||
'{}__in'.format(lookup_field): [
|
||||
'me', 'followers', 'instance', 'everyone'
|
||||
]
|
||||
})
|
|
@ -1,4 +1,7 @@
|
|||
import operator
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
|
||||
from rest_framework.permissions import BasePermission, DjangoModelPermissions
|
||||
|
||||
|
@ -20,3 +23,39 @@ class HasModelPermission(DjangoModelPermissions):
|
|||
"""
|
||||
def get_required_permissions(self, method, model_cls):
|
||||
return super().get_required_permissions(method, self.model)
|
||||
|
||||
|
||||
class OwnerPermission(BasePermission):
|
||||
"""
|
||||
Ensure the request user is the owner of the object.
|
||||
|
||||
Usage:
|
||||
|
||||
class MyView(APIView):
|
||||
model = MyModel
|
||||
permission_classes = [OwnerPermission]
|
||||
owner_field = 'owner'
|
||||
owner_checks = ['read', 'write']
|
||||
"""
|
||||
perms_map = {
|
||||
'GET': 'read',
|
||||
'OPTIONS': 'read',
|
||||
'HEAD': 'read',
|
||||
'POST': 'write',
|
||||
'PUT': 'write',
|
||||
'PATCH': 'write',
|
||||
'DELETE': 'write',
|
||||
}
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
method_check = self.perms_map[request.method]
|
||||
owner_checks = getattr(view, 'owner_checks', ['read', 'write'])
|
||||
if method_check not in owner_checks:
|
||||
# check not enabled
|
||||
return True
|
||||
|
||||
owner_field = getattr(view, 'owner_field', 'user')
|
||||
owner = operator.attrgetter(owner_field)(obj)
|
||||
if owner != request.user:
|
||||
raise Http404
|
||||
return True
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -5,13 +5,13 @@ from . import models
|
|||
|
||||
@admin.register(models.Playlist)
|
||||
class PlaylistAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'user', 'is_public', 'creation_date']
|
||||
list_display = ['name', 'user', 'privacy_level', 'creation_date']
|
||||
search_fields = ['name', ]
|
||||
list_select_related = True
|
||||
|
||||
|
||||
@admin.register(models.PlaylistTrack)
|
||||
class PlaylistTrackAdmin(admin.ModelAdmin):
|
||||
list_display = ['playlist', 'track', 'position', ]
|
||||
list_display = ['playlist', 'track', 'index']
|
||||
search_fields = ['track__name', 'playlist__name']
|
||||
list_select_related = True
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import factory
|
||||
|
||||
from funkwhale_api.factories import registry
|
||||
from funkwhale_api.music.factories import TrackFactory
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
|
||||
|
||||
|
@ -11,3 +12,12 @@ class PlaylistFactory(factory.django.DjangoModelFactory):
|
|||
|
||||
class Meta:
|
||||
model = 'playlists.Playlist'
|
||||
|
||||
|
||||
@registry.register
|
||||
class PlaylistTrackFactory(factory.django.DjangoModelFactory):
|
||||
playlist = factory.SubFactory(PlaylistFactory)
|
||||
track = factory.SubFactory(TrackFactory)
|
||||
|
||||
class Meta:
|
||||
model = 'playlists.PlaylistTrack'
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
from django_filters import rest_framework as filters
|
||||
|
||||
from funkwhale_api.music import utils
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
|
||||
class PlaylistFilter(filters.FilterSet):
|
||||
q = filters.CharFilter(name='_', method='filter_q')
|
||||
|
||||
class Meta:
|
||||
model = models.Playlist
|
||||
fields = {
|
||||
'user': ['exact'],
|
||||
'name': ['exact', 'icontains'],
|
||||
'q': 'exact',
|
||||
}
|
||||
|
||||
def filter_q(self, queryset, name, value):
|
||||
query = utils.get_query(value, ['name', 'user__username'])
|
||||
return queryset.filter(query)
|
|
@ -4,7 +4,6 @@ from __future__ import unicode_literals
|
|||
from django.db import migrations, models
|
||||
from django.conf import settings
|
||||
import django.utils.timezone
|
||||
import mptt.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -34,7 +33,7 @@ class Migration(migrations.Migration):
|
|||
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
|
||||
('position', models.PositiveIntegerField(db_index=True, editable=False)),
|
||||
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)),
|
||||
('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
|
||||
('previous', models.OneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
|
||||
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 2.0.3 on 2018-03-16 22:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('playlists', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='playlist',
|
||||
name='is_public',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='playlist',
|
||||
name='privacy_level',
|
||||
field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 2.0.3 on 2018-03-19 12:14
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('playlists', '0002_auto_20180316_2217'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='playlisttrack',
|
||||
options={'ordering': ('-playlist', 'index')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='playlisttrack',
|
||||
name='creation_date',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='playlisttrack',
|
||||
name='index',
|
||||
field=models.PositiveIntegerField(null=True),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='playlisttrack',
|
||||
name='lft',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='playlisttrack',
|
||||
name='position',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='playlisttrack',
|
||||
name='previous',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='playlisttrack',
|
||||
name='rght',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='playlisttrack',
|
||||
name='tree_id',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='playlisttrack',
|
||||
unique_together={('playlist', 'index')},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 2.0.3 on 2018-03-20 17:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('playlists', '0003_auto_20180319_1214'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='playlist',
|
||||
name='modification_date',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='playlisttrack',
|
||||
name='index',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='playlisttrack',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
|
@ -1,43 +1,130 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from mptt.models import MPTTModel, TreeOneToOneField
|
||||
from rest_framework import exceptions
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
|
||||
|
||||
class Playlist(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
is_public = models.BooleanField(default=False)
|
||||
user = models.ForeignKey(
|
||||
'users.User', related_name="playlists", on_delete=models.CASCADE)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
modification_date = models.DateTimeField(
|
||||
auto_now=True)
|
||||
privacy_level = fields.get_privacy_field()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def add_track(self, track, previous=None):
|
||||
plt = PlaylistTrack(previous=previous, track=track, playlist=self)
|
||||
plt.save()
|
||||
@transaction.atomic
|
||||
def insert(self, plt, index=None):
|
||||
"""
|
||||
Given a PlaylistTrack, insert it at the correct index in the playlist,
|
||||
and update other tracks index if necessary.
|
||||
"""
|
||||
old_index = plt.index
|
||||
move = old_index is not None
|
||||
if index is not None and index == old_index:
|
||||
# moving at same position, just skip
|
||||
return index
|
||||
|
||||
return plt
|
||||
existing = self.playlist_tracks.select_for_update()
|
||||
if move:
|
||||
existing = existing.exclude(pk=plt.pk)
|
||||
total = existing.filter(index__isnull=False).count()
|
||||
|
||||
if index is None:
|
||||
# we simply increment the last track index by 1
|
||||
index = total
|
||||
|
||||
if index > total:
|
||||
raise exceptions.ValidationError('Index is not continuous')
|
||||
|
||||
if index < 0:
|
||||
raise exceptions.ValidationError('Index must be zero or positive')
|
||||
|
||||
if move:
|
||||
# we remove the index temporarily, to avoid integrity errors
|
||||
plt.index = None
|
||||
plt.save(update_fields=['index'])
|
||||
if index > old_index:
|
||||
# new index is higher than current, we decrement previous tracks
|
||||
to_update = existing.filter(
|
||||
index__gt=old_index, index__lte=index)
|
||||
to_update.update(index=models.F('index') - 1)
|
||||
if index < old_index:
|
||||
# new index is lower than current, we increment next tracks
|
||||
to_update = existing.filter(index__lt=old_index, index__gte=index)
|
||||
to_update.update(index=models.F('index') + 1)
|
||||
else:
|
||||
to_update = existing.filter(index__gte=index)
|
||||
to_update.update(index=models.F('index') + 1)
|
||||
|
||||
plt.index = index
|
||||
plt.save(update_fields=['index'])
|
||||
self.save(update_fields=['modification_date'])
|
||||
return index
|
||||
|
||||
@transaction.atomic
|
||||
def remove(self, index):
|
||||
existing = self.playlist_tracks.select_for_update()
|
||||
self.save(update_fields=['modification_date'])
|
||||
to_update = existing.filter(index__gt=index)
|
||||
return to_update.update(index=models.F('index') - 1)
|
||||
|
||||
@transaction.atomic
|
||||
def insert_many(self, tracks):
|
||||
existing = self.playlist_tracks.select_for_update()
|
||||
now = timezone.now()
|
||||
total = existing.filter(index__isnull=False).count()
|
||||
if existing.count() + len(tracks) > settings.PLAYLISTS_MAX_TRACKS:
|
||||
raise exceptions.ValidationError(
|
||||
'Playlist would reach the maximum of {} tracks'.format(
|
||||
settings.PLAYLISTS_MAX_TRACKS))
|
||||
self.save(update_fields=['modification_date'])
|
||||
start = total
|
||||
plts = [
|
||||
PlaylistTrack(
|
||||
creation_date=now, playlist=self, track=track, index=start+i)
|
||||
for i, track in enumerate(tracks)
|
||||
]
|
||||
return PlaylistTrack.objects.bulk_create(plts)
|
||||
|
||||
class PlaylistTrackQuerySet(models.QuerySet):
|
||||
def for_nested_serialization(self):
|
||||
return (self.select_related()
|
||||
.select_related('track__album__artist')
|
||||
.prefetch_related(
|
||||
'track__tags',
|
||||
'track__files',
|
||||
'track__artist__albums__tracks__tags'))
|
||||
|
||||
|
||||
class PlaylistTrack(MPTTModel):
|
||||
class PlaylistTrack(models.Model):
|
||||
track = models.ForeignKey(
|
||||
'music.Track',
|
||||
related_name='playlist_tracks',
|
||||
on_delete=models.CASCADE)
|
||||
previous = TreeOneToOneField(
|
||||
'self',
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='next',
|
||||
on_delete=models.CASCADE)
|
||||
index = models.PositiveIntegerField(null=True, blank=True)
|
||||
playlist = models.ForeignKey(
|
||||
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
|
||||
class MPTTMeta:
|
||||
level_attr = 'position'
|
||||
parent_attr = 'previous'
|
||||
objects = PlaylistTrackQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('-playlist', 'position')
|
||||
ordering = ('-playlist', 'index')
|
||||
unique_together = ('playlist', 'index')
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
playlist = self.playlist
|
||||
index = self.index
|
||||
update_indexes = kwargs.pop('update_indexes', False)
|
||||
r = super().delete(*args, **kwargs)
|
||||
if index is not None and update_indexes:
|
||||
playlist.remove(index)
|
||||
return r
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
from taggit.models import Tag
|
||||
|
||||
from funkwhale_api.music.models import Track
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
|
||||
from funkwhale_api.users.serializers import UserBasicSerializer
|
||||
from . import models
|
||||
|
||||
|
||||
|
@ -11,20 +14,81 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = models.PlaylistTrack
|
||||
fields = ('id', 'track', 'playlist', 'position')
|
||||
fields = ('id', 'track', 'playlist', 'index', 'creation_date')
|
||||
|
||||
|
||||
class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
|
||||
class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
|
||||
index = serializers.IntegerField(
|
||||
required=False, min_value=0, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PlaylistTrack
|
||||
fields = ('id', 'track', 'playlist', 'position')
|
||||
fields = ('id', 'track', 'playlist', 'index')
|
||||
|
||||
def validate_playlist(self, value):
|
||||
if self.context.get('request'):
|
||||
# validate proper ownership on the playlist
|
||||
if self.context['request'].user != value.user:
|
||||
raise serializers.ValidationError(
|
||||
'You do not have the permission to edit this playlist')
|
||||
existing = value.playlist_tracks.count()
|
||||
if existing >= settings.PLAYLISTS_MAX_TRACKS:
|
||||
raise serializers.ValidationError(
|
||||
'Playlist has reached the maximum of {} tracks'.format(
|
||||
settings.PLAYLISTS_MAX_TRACKS))
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
index = validated_data.pop('index', None)
|
||||
instance = super().create(validated_data)
|
||||
instance.playlist.insert(instance, index)
|
||||
return instance
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
update_index = 'index' in validated_data
|
||||
index = validated_data.pop('index', None)
|
||||
super().update(instance, validated_data)
|
||||
if update_index:
|
||||
instance.playlist.insert(instance, index)
|
||||
return instance
|
||||
|
||||
def get_unique_together_validators(self):
|
||||
"""
|
||||
We explicitely disable unique together validation here
|
||||
because it collides with our internal logic
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class PlaylistSerializer(serializers.ModelSerializer):
|
||||
playlist_tracks = PlaylistTrackSerializer(many=True, read_only=True)
|
||||
tracks_count = serializers.SerializerMethodField(read_only=True)
|
||||
user = UserBasicSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Playlist
|
||||
fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks')
|
||||
read_only_fields = ['id', 'playlist_tracks', 'creation_date']
|
||||
fields = (
|
||||
'id',
|
||||
'name',
|
||||
'tracks_count',
|
||||
'user',
|
||||
'modification_date',
|
||||
'creation_date',
|
||||
'privacy_level',)
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'modification_date',
|
||||
'creation_date',]
|
||||
|
||||
def get_tracks_count(self, obj):
|
||||
try:
|
||||
return obj.tracks_count
|
||||
except AttributeError:
|
||||
# no annotation?
|
||||
return obj.playlist_tracks.count()
|
||||
|
||||
|
||||
class PlaylistAddManySerializer(serializers.Serializer):
|
||||
tracks = serializers.PrimaryKeyRelatedField(
|
||||
many=True, queryset=Track.objects.for_nested_serialization())
|
||||
|
|
|
@ -1,58 +1,123 @@
|
|||
from django.db.models import Count
|
||||
from django.db import transaction
|
||||
|
||||
from rest_framework import exceptions
|
||||
from rest_framework import generics, mixins, viewsets
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
|
||||
from funkwhale_api.common import permissions
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.music.models import Track
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
from . import serializers
|
||||
|
||||
|
||||
class PlaylistViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
|
||||
serializer_class = serializers.PlaylistSerializer
|
||||
queryset = (models.Playlist.objects.all())
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
queryset = (
|
||||
models.Playlist.objects.all().select_related('user')
|
||||
.annotate(tracks_count=Count('playlist_tracks'))
|
||||
)
|
||||
permission_classes = [
|
||||
permissions.ConditionalAuthentication,
|
||||
permissions.OwnerPermission,
|
||||
IsAuthenticatedOrReadOnly,
|
||||
]
|
||||
owner_checks = ['write']
|
||||
filter_class = filters.PlaylistFilter
|
||||
ordering_fields = ('id', 'name', 'creation_date', 'modification_date')
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
@detail_route(methods=['get'])
|
||||
def tracks(self, request, *args, **kwargs):
|
||||
playlist = self.get_object()
|
||||
plts = playlist.playlist_tracks.all().for_nested_serialization()
|
||||
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
|
||||
data = {
|
||||
'count': len(plts),
|
||||
'results': serializer.data
|
||||
}
|
||||
return Response(data, status=200)
|
||||
|
||||
@detail_route(methods=['post'])
|
||||
@transaction.atomic
|
||||
def add(self, request, *args, **kwargs):
|
||||
playlist = self.get_object()
|
||||
serializer = serializers.PlaylistAddManySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
instance = self.perform_create(serializer)
|
||||
serializer = self.get_serializer(instance=instance)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
try:
|
||||
plts = playlist.insert_many(serializer.validated_data['tracks'])
|
||||
except exceptions.ValidationError as e:
|
||||
payload = {'playlist': e.detail}
|
||||
return Response(payload, status=400)
|
||||
ids = [p.id for p in plts]
|
||||
plts = models.PlaylistTrack.objects.filter(
|
||||
pk__in=ids).order_by('index').for_nested_serialization()
|
||||
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
|
||||
data = {
|
||||
'count': len(plts),
|
||||
'results': serializer.data
|
||||
}
|
||||
return Response(data, status=201)
|
||||
|
||||
@detail_route(methods=['delete'])
|
||||
@transaction.atomic
|
||||
def clear(self, request, *args, **kwargs):
|
||||
playlist = self.get_object()
|
||||
playlist.playlist_tracks.all().delete()
|
||||
playlist.save(update_fields=['modification_date'])
|
||||
return Response(status=204)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
return self.queryset.filter(
|
||||
fields.privacy_level_query(self.request.user))
|
||||
|
||||
def perform_create(self, serializer):
|
||||
return serializer.save(user=self.request.user)
|
||||
return serializer.save(
|
||||
user=self.request.user,
|
||||
privacy_level=serializer.validated_data.get(
|
||||
'privacy_level', self.request.user.privacy_level)
|
||||
)
|
||||
|
||||
|
||||
class PlaylistTrackViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
|
||||
serializer_class = serializers.PlaylistTrackSerializer
|
||||
queryset = (models.PlaylistTrack.objects.all())
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
queryset = (models.PlaylistTrack.objects.all().for_nested_serialization())
|
||||
permission_classes = [
|
||||
permissions.ConditionalAuthentication,
|
||||
permissions.OwnerPermission,
|
||||
IsAuthenticatedOrReadOnly,
|
||||
]
|
||||
owner_field = 'playlist.user'
|
||||
owner_checks = ['write']
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = serializers.PlaylistTrackCreateSerializer(
|
||||
data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
instance = self.perform_create(serializer)
|
||||
serializer = self.get_serializer(instance=instance)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
def get_serializer_class(self):
|
||||
if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']:
|
||||
return serializers.PlaylistTrackWriteSerializer
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(playlist__user=self.request.user)
|
||||
return self.queryset.filter(
|
||||
fields.privacy_level_query(
|
||||
self.request.user,
|
||||
lookup_field='playlist__privacy_level'))
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.delete(update_indexes=True)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE=config.settings.test
|
||||
|
||||
# -- recommended but optional:
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
testpatsh = tests
|
|
@ -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
|
||||
|
|
|
@ -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+
|
||||
|
|
|
@ -3,5 +3,3 @@
|
|||
|
||||
# WSGI Handler
|
||||
# ------------------------------------------------
|
||||
|
||||
daphne==2.0.4
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
docker-compose -f $DIR/test.yml run test pytest "$@"
|
|
@ -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
|
||||
|
|
17
api/test.yml
17
api/test.yml
|
@ -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
|
|
@ -0,0 +1,17 @@
|
|||
import pytest
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import Q
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user,expected', [
|
||||
(AnonymousUser(), Q(privacy_level='everyone')),
|
||||
(UserFactory.build(pk=1),
|
||||
Q(privacy_level__in=['me', 'followers', 'instance', 'everyone'])),
|
||||
])
|
||||
def test_privacy_level_query(user,expected):
|
||||
query = fields.privacy_level_query(user)
|
||||
assert query == expected
|
|
@ -0,0 +1,42 @@
|
|||
import pytest
|
||||
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import Http404
|
||||
|
||||
from funkwhale_api.common import permissions
|
||||
|
||||
|
||||
def test_owner_permission_owner_field_ok(nodb_factories, api_request):
|
||||
playlist = nodb_factories['playlists.Playlist']()
|
||||
view = APIView.as_view()
|
||||
permission = permissions.OwnerPermission()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'user', playlist.user)
|
||||
check = permission.has_object_permission(request, view, playlist)
|
||||
|
||||
assert check is True
|
||||
|
||||
|
||||
def test_owner_permission_owner_field_not_ok(nodb_factories, api_request):
|
||||
playlist = nodb_factories['playlists.Playlist']()
|
||||
view = APIView.as_view()
|
||||
permission = permissions.OwnerPermission()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'user', AnonymousUser())
|
||||
|
||||
with pytest.raises(Http404):
|
||||
permission.has_object_permission(request, view, playlist)
|
||||
|
||||
|
||||
def test_owner_permission_read_only(nodb_factories, api_request):
|
||||
playlist = nodb_factories['playlists.Playlist']()
|
||||
view = APIView.as_view()
|
||||
setattr(view, 'owner_checks', ['write'])
|
||||
permission = permissions.OwnerPermission()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'user', AnonymousUser())
|
||||
check = permission.has_object_permission(request, view, playlist)
|
||||
|
||||
assert check is True
|
|
@ -1,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
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,126 @@
|
|||
import pytest
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
||||
|
||||
def test_can_insert_plt(factories):
|
||||
plt = factories['playlists.PlaylistTrack']()
|
||||
modification_date = plt.playlist.modification_date
|
||||
|
||||
assert plt.index is None
|
||||
|
||||
plt.playlist.insert(plt)
|
||||
plt.refresh_from_db()
|
||||
|
||||
assert plt.index == 0
|
||||
assert plt.playlist.modification_date > modification_date
|
||||
|
||||
|
||||
def test_insert_use_last_idx_by_default(factories):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
plts = factories['playlists.PlaylistTrack'].create_batch(
|
||||
size=3, playlist=playlist)
|
||||
|
||||
for i, plt in enumerate(plts):
|
||||
index = playlist.insert(plt)
|
||||
plt.refresh_from_db()
|
||||
|
||||
assert index == i
|
||||
assert plt.index == i
|
||||
|
||||
def test_can_insert_at_index(factories):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
first = factories['playlists.PlaylistTrack'](playlist=playlist)
|
||||
playlist.insert(first)
|
||||
new_first = factories['playlists.PlaylistTrack'](playlist=playlist)
|
||||
index = playlist.insert(new_first, index=0)
|
||||
first.refresh_from_db()
|
||||
new_first.refresh_from_db()
|
||||
|
||||
assert index == 0
|
||||
assert first.index == 1
|
||||
assert new_first.index == 0
|
||||
|
||||
|
||||
def test_can_insert_and_move(factories):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
|
||||
third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
|
||||
|
||||
playlist.insert(second, index=0)
|
||||
|
||||
first.refresh_from_db()
|
||||
second.refresh_from_db()
|
||||
third.refresh_from_db()
|
||||
|
||||
assert third.index == 2
|
||||
assert second.index == 0
|
||||
assert first.index == 1
|
||||
|
||||
|
||||
def test_can_insert_and_move_last_to_0(factories):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
|
||||
third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
|
||||
|
||||
playlist.insert(third, index=0)
|
||||
|
||||
first.refresh_from_db()
|
||||
second.refresh_from_db()
|
||||
third.refresh_from_db()
|
||||
|
||||
assert third.index == 0
|
||||
assert first.index == 1
|
||||
assert second.index == 2
|
||||
|
||||
|
||||
def test_cannot_insert_at_wrong_index(factories):
|
||||
plt = factories['playlists.PlaylistTrack']()
|
||||
new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
|
||||
with pytest.raises(exceptions.ValidationError):
|
||||
plt.playlist.insert(new, 2)
|
||||
|
||||
|
||||
def test_cannot_insert_at_negative_index(factories):
|
||||
plt = factories['playlists.PlaylistTrack']()
|
||||
new = factories['playlists.PlaylistTrack'](playlist=plt.playlist)
|
||||
with pytest.raises(exceptions.ValidationError):
|
||||
plt.playlist.insert(new, -1)
|
||||
|
||||
|
||||
def test_remove_update_indexes(factories):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
|
||||
third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2)
|
||||
|
||||
second.delete(update_indexes=True)
|
||||
|
||||
first.refresh_from_db()
|
||||
third.refresh_from_db()
|
||||
|
||||
assert first.index == 0
|
||||
assert third.index == 1
|
||||
|
||||
|
||||
def test_can_insert_many(factories):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
existing = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||
tracks = factories['music.Track'].create_batch(size=3)
|
||||
plts = playlist.insert_many(tracks)
|
||||
for i, plt in enumerate(plts):
|
||||
assert plt.index == i + 1
|
||||
assert plt.track == tracks[i]
|
||||
assert plt.playlist == playlist
|
||||
|
||||
|
||||
def test_insert_many_honor_max_tracks(factories, settings):
|
||||
settings.PLAYLISTS_MAX_TRACKS = 4
|
||||
playlist = factories['playlists.Playlist']()
|
||||
plts = factories['playlists.PlaylistTrack'].create_batch(
|
||||
size=2, playlist=playlist)
|
||||
track = factories['music.Track']()
|
||||
with pytest.raises(exceptions.ValidationError):
|
||||
playlist.insert_many([track, track, track])
|
|
@ -0,0 +1,74 @@
|
|||
from funkwhale_api.playlists import models
|
||||
from funkwhale_api.playlists import serializers
|
||||
|
||||
|
||||
def test_cannot_max_500_tracks_per_playlist(factories, settings):
|
||||
settings.PLAYLISTS_MAX_TRACKS = 2
|
||||
playlist = factories['playlists.Playlist']()
|
||||
plts = factories['playlists.PlaylistTrack'].create_batch(
|
||||
size=2, playlist=playlist)
|
||||
track = factories['music.Track']()
|
||||
serializer = serializers.PlaylistTrackWriteSerializer(data={
|
||||
'playlist': playlist.pk,
|
||||
'track': track.pk,
|
||||
})
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'playlist' in serializer.errors
|
||||
|
||||
|
||||
def test_create_insert_is_called_when_index_is_None(factories, mocker):
|
||||
insert = mocker.spy(models.Playlist, 'insert')
|
||||
playlist = factories['playlists.Playlist']()
|
||||
track = factories['music.Track']()
|
||||
serializer = serializers.PlaylistTrackWriteSerializer(data={
|
||||
'playlist': playlist.pk,
|
||||
'track': track.pk,
|
||||
'index': None,
|
||||
})
|
||||
assert serializer.is_valid() is True
|
||||
|
||||
plt = serializer.save()
|
||||
insert.assert_called_once_with(playlist, plt, None)
|
||||
assert plt.index == 0
|
||||
|
||||
|
||||
def test_create_insert_is_called_when_index_is_provided(factories, mocker):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||
insert = mocker.spy(models.Playlist, 'insert')
|
||||
factories['playlists.Playlist']()
|
||||
track = factories['music.Track']()
|
||||
serializer = serializers.PlaylistTrackWriteSerializer(data={
|
||||
'playlist': playlist.pk,
|
||||
'track': track.pk,
|
||||
'index': 0,
|
||||
})
|
||||
assert serializer.is_valid() is True
|
||||
|
||||
plt = serializer.save()
|
||||
first.refresh_from_db()
|
||||
insert.assert_called_once_with(playlist, plt, 0)
|
||||
assert plt.index == 0
|
||||
assert first.index == 1
|
||||
|
||||
|
||||
def test_update_insert_is_called_when_index_is_provided(factories, mocker):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0)
|
||||
second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1)
|
||||
insert = mocker.spy(models.Playlist, 'insert')
|
||||
factories['playlists.Playlist']()
|
||||
track = factories['music.Track']()
|
||||
serializer = serializers.PlaylistTrackWriteSerializer(second, data={
|
||||
'playlist': playlist.pk,
|
||||
'track': second.track.pk,
|
||||
'index': 0,
|
||||
})
|
||||
assert serializer.is_valid() is True
|
||||
|
||||
plt = serializer.save()
|
||||
first.refresh_from_db()
|
||||
insert.assert_called_once_with(playlist, plt, 0)
|
||||
assert plt.index == 0
|
||||
assert first.index == 1
|
|
@ -0,0 +1,197 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.playlists import models
|
||||
from funkwhale_api.playlists import serializers
|
||||
|
||||
|
||||
def test_can_create_playlist_via_api(logged_in_api_client):
|
||||
url = reverse('api:v1:playlists-list')
|
||||
data = {
|
||||
'name': 'test',
|
||||
'privacy_level': 'everyone'
|
||||
}
|
||||
|
||||
response = logged_in_api_client.post(url, data)
|
||||
|
||||
playlist = logged_in_api_client.user.playlists.latest('id')
|
||||
assert playlist.name == 'test'
|
||||
assert playlist.privacy_level == 'everyone'
|
||||
|
||||
|
||||
def test_serializer_includes_tracks_count(factories, logged_in_api_client):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
plt = factories['playlists.PlaylistTrack'](playlist=playlist)
|
||||
|
||||
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
|
||||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.data['tracks_count'] == 1
|
||||
|
||||
|
||||
def test_playlist_inherits_user_privacy(logged_in_api_client):
|
||||
url = reverse('api:v1:playlists-list')
|
||||
user = logged_in_api_client.user
|
||||
user.privacy_level = 'me'
|
||||
user.save()
|
||||
|
||||
data = {
|
||||
'name': 'test',
|
||||
}
|
||||
|
||||
response = logged_in_api_client.post(url, data)
|
||||
playlist = user.playlists.latest('id')
|
||||
assert playlist.privacy_level == user.privacy_level
|
||||
|
||||
|
||||
def test_can_add_playlist_track_via_api(factories, logged_in_api_client):
|
||||
tracks = factories['music.Track'].create_batch(5)
|
||||
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
|
||||
url = reverse('api:v1:playlist-tracks-list')
|
||||
data = {
|
||||
'playlist': playlist.pk,
|
||||
'track': tracks[0].pk
|
||||
}
|
||||
|
||||
response = logged_in_api_client.post(url, data)
|
||||
assert response.status_code == 201
|
||||
plts = logged_in_api_client.user.playlists.latest('id').playlist_tracks.all()
|
||||
assert plts.first().track == tracks[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,method', [
|
||||
('api:v1:playlist-tracks-list', 'post'),
|
||||
('api:v1:playlists-list', 'post'),
|
||||
])
|
||||
def test_url_requires_login(name, method, factories, api_client):
|
||||
url = reverse(name)
|
||||
|
||||
response = getattr(api_client, method)(url, {})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_only_can_add_track_on_own_playlist_via_api(
|
||||
factories, logged_in_api_client):
|
||||
track = factories['music.Track']()
|
||||
playlist = factories['playlists.Playlist']()
|
||||
url = reverse('api:v1:playlist-tracks-list')
|
||||
data = {
|
||||
'playlist': playlist.pk,
|
||||
'track': track.pk
|
||||
}
|
||||
|
||||
response = logged_in_api_client.post(url, data)
|
||||
assert response.status_code == 400
|
||||
assert playlist.playlist_tracks.count() == 0
|
||||
|
||||
|
||||
def test_deleting_plt_updates_indexes(
|
||||
mocker, factories, logged_in_api_client):
|
||||
remove = mocker.spy(models.Playlist, 'remove')
|
||||
track = factories['music.Track']()
|
||||
plt = factories['playlists.PlaylistTrack'](
|
||||
index=0,
|
||||
playlist__user=logged_in_api_client.user)
|
||||
url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
|
||||
|
||||
response = logged_in_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 204
|
||||
remove.assert_called_once_with(plt.playlist, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
|
||||
def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
|
||||
factories['playlists.Playlist'](privacy_level=level)
|
||||
url = reverse('api:v1:playlists-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.data['count'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
|
||||
def test_only_owner_can_edit_playlist(method, factories, api_client):
|
||||
playlist = factories['playlists.Playlist']()
|
||||
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
|
||||
def test_only_owner_can_edit_playlist_track(method, factories, api_client):
|
||||
plt = factories['playlists.PlaylistTrack']()
|
||||
url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
|
||||
def test_playlist_track_privacy_respected_in_list_anon(
|
||||
level, factories, api_client):
|
||||
factories['playlists.PlaylistTrack'](playlist__privacy_level=level)
|
||||
url = reverse('api:v1:playlist-tracks-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.data['count'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
|
||||
def test_can_list_tracks_from_playlist(
|
||||
level, factories, logged_in_api_client):
|
||||
plt = factories['playlists.PlaylistTrack'](
|
||||
playlist__user=logged_in_api_client.user)
|
||||
url = reverse('api:v1:playlists-tracks', kwargs={'pk': plt.playlist.pk})
|
||||
response = logged_in_api_client.get(url)
|
||||
serialized_plt = serializers.PlaylistTrackSerializer(plt).data
|
||||
|
||||
assert response.data['count'] == 1
|
||||
assert response.data['results'][0] == serialized_plt
|
||||
|
||||
|
||||
def test_can_add_multiple_tracks_at_once_via_api(
|
||||
factories, mocker, logged_in_api_client):
|
||||
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
|
||||
tracks = factories['music.Track'].create_batch(size=5)
|
||||
track_ids = [t.id for t in tracks]
|
||||
mocker.spy(playlist, 'insert_many')
|
||||
url = reverse('api:v1:playlists-add', kwargs={'pk': playlist.pk})
|
||||
response = logged_in_api_client.post(url, {'tracks': track_ids})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert playlist.playlist_tracks.count() == len(track_ids)
|
||||
|
||||
for plt in playlist.playlist_tracks.order_by('index'):
|
||||
assert response.data['results'][plt.index]['id'] == plt.id
|
||||
assert plt.track == tracks[plt.index]
|
||||
|
||||
|
||||
def test_can_clear_playlist_from_api(
|
||||
factories, mocker, logged_in_api_client):
|
||||
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
|
||||
plts = factories['playlists.PlaylistTrack'].create_batch(
|
||||
size=5, playlist=playlist)
|
||||
url = reverse('api:v1:playlists-clear', kwargs={'pk': playlist.pk})
|
||||
response = logged_in_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert playlist.playlist_tracks.count() == 0
|
||||
|
||||
|
||||
def test_update_playlist_from_api(
|
||||
factories, mocker, logged_in_api_client):
|
||||
playlist = factories['playlists.Playlist'](user=logged_in_api_client.user)
|
||||
plts = factories['playlists.PlaylistTrack'].create_batch(
|
||||
size=5, playlist=playlist)
|
||||
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
|
||||
response = logged_in_api_client.patch(url, {'name': 'test'})
|
||||
playlist.refresh_from_db()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data['user']['username'] == playlist.user.username
|
|
@ -1,54 +0,0 @@
|
|||
import json
|
||||
from django.urls import reverse
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.playlists import models
|
||||
from funkwhale_api.playlists.serializers import PlaylistSerializer
|
||||
|
||||
|
||||
|
||||
def test_can_create_playlist(factories):
|
||||
tracks = factories['music.Track'].create_batch(5)
|
||||
playlist = factories['playlists.Playlist']()
|
||||
|
||||
previous = None
|
||||
for track in tracks:
|
||||
previous = playlist.add_track(track, previous=previous)
|
||||
|
||||
playlist_tracks = list(playlist.playlist_tracks.all())
|
||||
|
||||
previous = None
|
||||
for idx, track in enumerate(tracks):
|
||||
plt = playlist_tracks[idx]
|
||||
assert plt.position == idx
|
||||
assert plt.track == track
|
||||
if previous:
|
||||
assert playlist_tracks[idx + 1] == previous
|
||||
assert plt.playlist == playlist
|
||||
|
||||
|
||||
def test_can_create_playlist_via_api(logged_in_client):
|
||||
url = reverse('api:v1:playlists-list')
|
||||
data = {
|
||||
'name': 'test',
|
||||
}
|
||||
|
||||
response = logged_in_client.post(url, data)
|
||||
|
||||
playlist = logged_in_client.user.playlists.latest('id')
|
||||
assert playlist.name == 'test'
|
||||
|
||||
|
||||
def test_can_add_playlist_track_via_api(factories, logged_in_client):
|
||||
tracks = factories['music.Track'].create_batch(5)
|
||||
playlist = factories['playlists.Playlist'](user=logged_in_client.user)
|
||||
url = reverse('api:v1:playlist-tracks-list')
|
||||
data = {
|
||||
'playlist': playlist.pk,
|
||||
'track': tracks[0].pk
|
||||
}
|
||||
|
||||
response = logged_in_client.post(url, data)
|
||||
plts = logged_in_client.user.playlists.latest('id').playlist_tracks.all()
|
||||
assert plts.first().track == tracks[0]
|
9
dev.yml
9
dev.yml
|
@ -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'
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
FROM python:3.6-alpine
|
||||
|
||||
RUN pip install sphinx livereload
|
||||
WORKDIR /app/docs
|
|
@ -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')
|
|
@ -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'
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<tr>
|
||||
<td>
|
||||
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
|
||||
</td>
|
||||
<td>
|
||||
<img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
|
||||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
|
||||
<template v-if="displayPosition && track.position">
|
||||
{{ track.position }}.
|
||||
</template>
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon>
|
||||
<track-playlist-icon
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:track="track"></track-playlist-icon>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
track: {type: Object, required: true},
|
||||
displayPosition: {type: Boolean, default: false}
|
||||
},
|
||||
components: {
|
||||
TrackFavoriteIcon,
|
||||
TrackPlaylistIcon,
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss" scoped>
|
||||
|
||||
tr:not(:hover) {
|
||||
.favorite-icon:not(.favorited), .playlist-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -11,34 +11,11 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="track in tracks">
|
||||
<td>
|
||||
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
|
||||
</td>
|
||||
<td>
|
||||
<img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
|
||||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
|
||||
<template v-if="displayPosition && track.position">
|
||||
{{ track.position }}.
|
||||
</template>
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td>
|
||||
</tr>
|
||||
<track-row
|
||||
:display-position="displayPosition"
|
||||
:track="track"
|
||||
:key="index + '-' + track.id"
|
||||
v-for="(track, index) in tracks"></track-row>
|
||||
</tbody>
|
||||
<tfoot class="full-width">
|
||||
<tr>
|
||||
|
@ -83,9 +60,8 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.aut
|
|||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
import TrackRow from '@/components/audio/track/Row'
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
|
||||
export default {
|
||||
|
@ -95,8 +71,7 @@ export default {
|
|||
},
|
||||
components: {
|
||||
Modal,
|
||||
TrackFavoriteIcon,
|
||||
PlayButton
|
||||
TrackRow
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div @click="showModal = true" :class="['ui', color, {disabled: disabled}, 'button']" :disabled="disabled">
|
||||
<slot></slot>
|
||||
|
||||
<modal class="small" :show.sync="showModal">
|
||||
<div class="header">
|
||||
<slot name="modal-header">Do you want to confirm this action?</slot>
|
||||
</div>
|
||||
<div class="scrolling content">
|
||||
<div class="description">
|
||||
<slot name="modal-content"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button">Cancel</div>
|
||||
<div :class="['ui', 'confirm', color, 'button']" @click="confirm">
|
||||
<slot name="modal-confirm">Confirm</slot>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script>
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
action: {type: Function, required: true},
|
||||
disabled: {type: Boolean, default: false},
|
||||
color: {type: String, default: 'red'}
|
||||
},
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showModal: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
confirm () {
|
||||
this.showModal = false
|
||||
this.action()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -8,4 +8,8 @@ import Username from '@/components/common/Username'
|
|||
|
||||
Vue.component('username', Username)
|
||||
|
||||
import DangerousButton from '@/components/common/DangerousButton'
|
||||
|
||||
Vue.component('dangerous-button', DangerousButton)
|
||||
|
||||
export default {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -24,6 +24,11 @@
|
|||
|
||||
<play-button class="orange" :track="track">Play</play-button>
|
||||
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
|
||||
<track-playlist-icon
|
||||
:button="true"
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:track="track"></track-playlist-icon>
|
||||
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
Search on wikipedia
|
||||
|
@ -66,6 +71,7 @@ import logger from '@/logging'
|
|||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
||||
|
||||
const FETCH_URL = 'tracks/'
|
||||
|
||||
|
@ -73,6 +79,7 @@ export default {
|
|||
props: ['id'],
|
||||
components: {
|
||||
PlayButton,
|
||||
TrackPlaylistIcon,
|
||||
TrackFavoriteIcon
|
||||
},
|
||||
data () {
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">
|
||||
{{ playlist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<i class="user icon"></i> {{ playlist.user.username }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<i class="clock icon"></i> Updated <human-date :date="playlist.modification_date"></human-date>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<span>
|
||||
<i class="sound icon"></i>
|
||||
{{ playlist.tracks_count }} tracks
|
||||
</span>
|
||||
<play-button class="mini basic orange right floated" :playlist="playlist">Play all</play-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: ['playlist'],
|
||||
components: {
|
||||
PlayButton
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="playlists.length > 0"
|
||||
v-masonry
|
||||
transition-duration="0"
|
||||
item-selector=".column"
|
||||
percent-position="true"
|
||||
stagger="0"
|
||||
class="ui stackable three column doubling grid">
|
||||
<div
|
||||
v-masonry-tile
|
||||
v-for="playlist in playlists"
|
||||
:key="playlist.id"
|
||||
class="column">
|
||||
<playlist-card class="fluid" :playlist="playlist"></playlist-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import PlaylistCard from '@/components/playlists/Card'
|
||||
|
||||
export default {
|
||||
props: ['playlists'],
|
||||
components: {
|
||||
PlaylistCard
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,178 @@
|
|||
<template>
|
||||
<div class="ui text container">
|
||||
<playlist-form @updated="$emit('playlist-updated', $event)" :title="false" :playlist="playlist"></playlist-form>
|
||||
<h3 class="ui top attached header">
|
||||
Playlist editor
|
||||
</h3>
|
||||
<div class="ui attached segment">
|
||||
<template v-if="status === 'loading'">
|
||||
<div class="ui active tiny inline loader"></div>
|
||||
Syncing changes to server...
|
||||
</template>
|
||||
<template v-else-if="status === 'errored'">
|
||||
<i class="red close icon"></i>
|
||||
An error occured while saving your changes
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="status === 'saved'">
|
||||
<i class="green check icon"></i> Changes synced with server
|
||||
</template>
|
||||
</div>
|
||||
<div class="ui bottom attached segment">
|
||||
<div
|
||||
@click="insertMany(queueTracks)"
|
||||
:disabled="queueTracks.length === 0"
|
||||
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
|
||||
title="Copy tracks from current queue to playlist">
|
||||
<i class="plus icon"></i> Insert from queue ({{ queueTracks.length }} tracks)</div>
|
||||
|
||||
<dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
|
||||
<i class="eraser icon"></i> Clear playlist
|
||||
<p slot="modal-header">Do you want to clear the playlist "{{ playlist.name }}"?</p>
|
||||
<p slot="modal-content">This will remove all tracks from this playlist and cannot be undone.</p>
|
||||
<p slot="modal-confirm">Clear playlist</p>
|
||||
</dangerous-button>
|
||||
<div class="ui hidden divider"></div>
|
||||
<template v-if="plts.length > 0">
|
||||
<p>Drag and drop rows to reorder tracks in the playlist</p>
|
||||
<table class="ui compact very basic fixed single line unstackable table">
|
||||
<draggable v-model="plts" element="tbody" @update="reorder">
|
||||
<tr v-for="(plt, index) in plts" :key="plt.id">
|
||||
<td class="left aligned">{{ plt.index + 1}}</td>
|
||||
<td class="center aligned">
|
||||
<img class="ui mini image" v-if="plt.track.album.cover" :src="plt.track.album.cover">
|
||||
<img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
<strong>{{ plt.track.title }}</strong><br />
|
||||
{{ plt.track.artist.name }}
|
||||
</td>
|
||||
<td class="right aligned">
|
||||
<i @click.stop="removePlt(index)" class="circular red trash icon"></i>
|
||||
</td>
|
||||
</tr>
|
||||
</draggable>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
import axios from 'axios'
|
||||
import PlaylistForm from '@/components/playlists/Form'
|
||||
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable,
|
||||
PlaylistForm
|
||||
},
|
||||
props: ['playlist', 'playlistTracks'],
|
||||
data () {
|
||||
return {
|
||||
plts: this.playlistTracks,
|
||||
isLoading: false,
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
success () {
|
||||
this.isLoading = false
|
||||
this.errors = []
|
||||
},
|
||||
errored (errors) {
|
||||
this.isLoading = false
|
||||
this.errors = errors
|
||||
},
|
||||
reorder ({oldIndex, newIndex}) {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
let plt = this.plts[newIndex]
|
||||
let url = 'playlist-tracks/' + plt.id + '/'
|
||||
axios.patch(url, {index: newIndex}).then((response) => {
|
||||
self.success()
|
||||
}, error => {
|
||||
self.errored(error.backendErrors)
|
||||
})
|
||||
},
|
||||
removePlt (index) {
|
||||
let plt = this.plts[index]
|
||||
this.plts.splice(index, 1)
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
let url = 'playlist-tracks/' + plt.id + '/'
|
||||
axios.delete(url).then((response) => {
|
||||
self.success()
|
||||
self.$store.dispatch('playlists/fetchOwn')
|
||||
}, error => {
|
||||
self.errored(error.backendErrors)
|
||||
})
|
||||
},
|
||||
clearPlaylist () {
|
||||
this.plts = []
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
let url = 'playlists/' + this.playlist.id + '/clear'
|
||||
axios.delete(url).then((response) => {
|
||||
self.success()
|
||||
self.$store.dispatch('playlists/fetchOwn')
|
||||
}, error => {
|
||||
self.errored(error.backendErrors)
|
||||
})
|
||||
},
|
||||
insertMany (tracks) {
|
||||
let self = this
|
||||
let ids = tracks.map(t => {
|
||||
return t.id
|
||||
})
|
||||
self.isLoading = true
|
||||
let url = 'playlists/' + this.playlist.id + '/add/'
|
||||
axios.post(url, {tracks: ids}).then((response) => {
|
||||
response.data.results.forEach(r => {
|
||||
self.plts.push(r)
|
||||
})
|
||||
self.success()
|
||||
self.$store.dispatch('playlists/fetchOwn')
|
||||
}, error => {
|
||||
self.errored(error.backendErrors)
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
queueTracks: state => state.queue.tracks
|
||||
}),
|
||||
status () {
|
||||
if (this.isLoading) {
|
||||
return 'loading'
|
||||
}
|
||||
if (this.errors.length > 0) {
|
||||
return 'errored'
|
||||
}
|
||||
return 'saved'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
plts: {
|
||||
handler (newValue) {
|
||||
newValue.forEach((e, i) => {
|
||||
e.index = i
|
||||
})
|
||||
this.$emit('tracks-updated', newValue)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<h4 v-if="title" class="ui header">Create a new playlist</h4>
|
||||
<div v-if="success" class="ui positive message">
|
||||
<div class="header">
|
||||
<template v-if="playlist">
|
||||
Playlist updated
|
||||
</template>
|
||||
<template v-else>
|
||||
Playlist created
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">We cannot create the playlist</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>Playlist name</label>
|
||||
<input v-model="name" required type="text" placeholder="My awesome playlist" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Playlist visibility</label>
|
||||
<select class="ui dropdown" v-model="privacyLevel">
|
||||
<option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label> </label>
|
||||
<button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit">
|
||||
<template v-if="playlist">Update playlist</template>
|
||||
<template v-else>Create playlist</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import $ from 'jquery'
|
||||
import axios from 'axios'
|
||||
|
||||
import logger from '@/logging'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
title: {type: Boolean, default: true},
|
||||
playlist: {type: Object, default: null}
|
||||
},
|
||||
mounted () {
|
||||
$(this.$el).find('.dropdown').dropdown()
|
||||
},
|
||||
data () {
|
||||
let d = {
|
||||
errors: [],
|
||||
success: false,
|
||||
isLoading: false,
|
||||
privacyLevelChoices: [
|
||||
{
|
||||
value: 'me',
|
||||
label: 'Nobody except me'
|
||||
},
|
||||
{
|
||||
value: 'instance',
|
||||
label: 'Everyone on this instance'
|
||||
},
|
||||
{
|
||||
value: 'everyone',
|
||||
label: 'Everyone'
|
||||
}
|
||||
]
|
||||
}
|
||||
if (this.playlist) {
|
||||
d.name = this.playlist.name
|
||||
d.privacyLevel = this.playlist.privacy_level
|
||||
} else {
|
||||
d.privacyLevel = this.$store.state.auth.profile.privacy_level
|
||||
d.name = ''
|
||||
}
|
||||
return d
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
this.isLoading = true
|
||||
this.success = false
|
||||
this.errors = []
|
||||
let self = this
|
||||
let payload = {
|
||||
name: this.name,
|
||||
privacy_level: this.privacyLevel
|
||||
}
|
||||
|
||||
let promise
|
||||
let url
|
||||
if (this.playlist) {
|
||||
url = `playlists/${this.playlist.id}/`
|
||||
promise = axios.patch(url, payload)
|
||||
} else {
|
||||
url = 'playlists/'
|
||||
promise = axios.post(url, payload)
|
||||
}
|
||||
return promise.then(response => {
|
||||
self.success = true
|
||||
self.isLoading = false
|
||||
if (!self.playlist) {
|
||||
self.name = ''
|
||||
}
|
||||
self.$emit('updated', response.data)
|
||||
self.$store.dispatch('playlists/fetchOwn')
|
||||
}, error => {
|
||||
logger.default.error('Error while creating playlist')
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<modal @update:show="update" :show="$store.state.playlists.showModal">
|
||||
<div class="header">
|
||||
Manage playlists
|
||||
</div>
|
||||
<div class="scrolling content">
|
||||
<div class="description">
|
||||
<template v-if="track">
|
||||
<h4 class="ui header">Current track</h4>
|
||||
<div>
|
||||
"{{ track.title }}" by {{ track.artist.name }}
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
</template>
|
||||
|
||||
<playlist-form></playlist-form>
|
||||
<div class="ui divider"></div>
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">We cannot add the track to a playlist</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="ui header">Available playlists</h4>
|
||||
<table class="ui unstackable very basic table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th class="sorted descending">Last modification</th>
|
||||
<th>Tracks</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="playlist in sortedPlaylists">
|
||||
<td>
|
||||
<router-link
|
||||
class="ui icon basic small button"
|
||||
:to="{name: 'library.playlists.detail', params: {id: playlist.id }, query: {mode: 'edit'}}"><i class="ui pencil icon"></i></router-link>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">{{ playlist.name }}</router-link></td>
|
||||
<td><human-date :date="playlist.modification_date"></human-date></td>
|
||||
<td>{{ playlist.tracks_count }}</td>
|
||||
<td>
|
||||
<div
|
||||
v-if="track"
|
||||
class="ui green icon basic small right floated button"
|
||||
title="Add to this playlist"
|
||||
@click="addToPlaylist(playlist.id)">
|
||||
<i class="plus icon"></i> Add track
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button">Cancel</div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
import logger from '@/logging'
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
import PlaylistForm from '@/components/playlists/Form'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Modal,
|
||||
PlaylistForm
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update (v) {
|
||||
this.$store.commit('playlists/showModal', v)
|
||||
},
|
||||
addToPlaylist (playlistId) {
|
||||
let self = this
|
||||
let payload = {
|
||||
track: this.track.id,
|
||||
playlist: playlistId
|
||||
}
|
||||
return axios.post('playlist-tracks/', payload).then(response => {
|
||||
logger.default.info('Successfully added track to playlist')
|
||||
self.update(false)
|
||||
self.$store.dispatch('playlists/fetchOwn')
|
||||
}, error => {
|
||||
logger.default.error('Error while adding track to playlist')
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
playlists: state => state.playlists.playlists,
|
||||
track: state => state.playlists.modalTrack
|
||||
}),
|
||||
sortedPlaylists () {
|
||||
let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }])
|
||||
p.reverse()
|
||||
return p
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$store.state.route.path' () {
|
||||
this.$store.commit('playlists/showModal', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<button
|
||||
@click="$store.commit('playlists/chooseTrack', track)"
|
||||
v-if="button"
|
||||
:class="['ui', 'button']">
|
||||
<i class="list icon"></i>
|
||||
Add to playlist...
|
||||
</button>
|
||||
<i
|
||||
v-else
|
||||
@click="$store.commit('playlists/chooseTrack', track)"
|
||||
:class="['playlist-icon', 'list', 'link', 'icon']"
|
||||
title="Add to playlist...">
|
||||
</i>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: {
|
||||
track: {type: Object},
|
||||
button: {type: Boolean, default: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showModal: false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -21,7 +21,8 @@ import RadioBuilder from '@/components/library/radios/Builder'
|
|||
import BatchList from '@/components/library/import/BatchList'
|
||||
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||
import RequestsList from '@/components/requests/RequestsList'
|
||||
|
||||
import PlaylistDetail from '@/views/playlists/Detail'
|
||||
import PlaylistList from '@/views/playlists/List'
|
||||
import Favorites from '@/components/favorites/List'
|
||||
|
||||
Vue.use(Router)
|
||||
|
@ -110,6 +111,25 @@ export default new Router({
|
|||
},
|
||||
{ path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true },
|
||||
{ path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true },
|
||||
{
|
||||
path: 'playlists/',
|
||||
name: 'library.playlists.browse',
|
||||
component: PlaylistList,
|
||||
props: (route) => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id',
|
||||
name: 'library.playlists.detail',
|
||||
component: PlaylistDetail,
|
||||
props: (route) => ({
|
||||
id: route.params.id,
|
||||
defaultEdit: route.query.mode === 'edit' })
|
||||
},
|
||||
{ path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
|
||||
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
|
||||
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
|
||||
|
|
|
@ -91,6 +91,7 @@ export default {
|
|||
commit('profile', data)
|
||||
commit('username', data.username)
|
||||
dispatch('favorites/fetch', null, {root: true})
|
||||
dispatch('playlists/fetchOwn', null, {root: true})
|
||||
Object.keys(data.permissions).forEach(function (key) {
|
||||
// this makes it easier to check for permissions in templates
|
||||
commit('permission', {key, status: data.permissions[String(key)].status})
|
||||
|
|
|
@ -8,6 +8,7 @@ import instance from './instance'
|
|||
import queue from './queue'
|
||||
import radios from './radios'
|
||||
import player from './player'
|
||||
import playlists from './playlists'
|
||||
import ui from './ui'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
@ -20,6 +21,7 @@ export default new Vuex.Store({
|
|||
instance,
|
||||
queue,
|
||||
radios,
|
||||
playlists,
|
||||
player
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
playlists: [],
|
||||
showModal: false,
|
||||
modalTrack: null
|
||||
},
|
||||
mutations: {
|
||||
playlists (state, value) {
|
||||
state.playlists = value
|
||||
},
|
||||
chooseTrack (state, value) {
|
||||
state.showModal = true
|
||||
state.modalTrack = value
|
||||
},
|
||||
showModal (state, value) {
|
||||
state.showModal = value
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
fetchOwn ({commit, rootState}) {
|
||||
let userId = rootState.auth.profile.id
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
return axios.get('playlists/', {params: {user: userId}}).then((response) => {
|
||||
commit('playlists', response.data.results)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted list yellow icon"></i>
|
||||
<div class="content">
|
||||
{{ playlist.name }}
|
||||
<div class="sub header">
|
||||
Playlist containing {{ playlistTracks.length }} tracks,
|
||||
by <username :username="playlist.user.username"></username>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
</button>
|
||||
<play-button class="orange" :tracks="tracks">Play all</play-button>
|
||||
<button
|
||||
class="ui icon button"
|
||||
v-if="playlist.user.id === $store.state.auth.profile.id"
|
||||
@click="edit = !edit">
|
||||
<i class="pencil icon"></i>
|
||||
<template v-if="edit">End edition</template>
|
||||
<template v-else>Edit...</template>
|
||||
</button>
|
||||
<dangerous-button class="labeled icon" :action="deletePlaylist">
|
||||
<i class="trash icon"></i> Delete
|
||||
<p slot="modal-header">Do you want to delete the playlist "{{ playlist.name }}"?</p>
|
||||
<p slot="modal-content">This will completely delete this playlist and cannot be undone.</p>
|
||||
<p slot="modal-confirm">Delete playlist</p>
|
||||
</dangerous-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<template v-if="edit">
|
||||
<playlist-editor
|
||||
@playlist-updated="playlist = $event"
|
||||
@tracks-updated="updatePlts"
|
||||
:playlist="playlist" :playlist-tracks="playlistTracks"></playlist-editor>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2>Tracks</h2>
|
||||
<track-table :display-position="true" :tracks="tracks"></track-table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import RadioButton from '@/components/radios/Button'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import PlaylistEditor from '@/components/playlists/Editor'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
id: {required: true},
|
||||
defaultEdit: {type: Boolean, default: false}
|
||||
},
|
||||
components: {
|
||||
PlaylistEditor,
|
||||
TrackTable,
|
||||
PlayButton,
|
||||
RadioButton
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
edit: this.defaultEdit,
|
||||
isLoading: false,
|
||||
playlist: null,
|
||||
tracks: [],
|
||||
playlistTracks: []
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.fetch()
|
||||
},
|
||||
methods: {
|
||||
updatePlts (v) {
|
||||
this.playlistTracks = v
|
||||
this.tracks = v.map((e, i) => {
|
||||
let track = e.track
|
||||
track.position = i + 1
|
||||
return track
|
||||
})
|
||||
},
|
||||
fetch: function () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
let url = 'playlists/' + this.id + '/'
|
||||
axios.get(url).then((response) => {
|
||||
self.playlist = response.data
|
||||
axios.get(url + 'tracks').then((response) => {
|
||||
self.updatePlts(response.data.results)
|
||||
}).then(() => {
|
||||
self.isLoading = false
|
||||
})
|
||||
})
|
||||
},
|
||||
deletePlaylist () {
|
||||
let self = this
|
||||
let url = 'playlists/' + this.id + '/'
|
||||
axios.delete(url).then((response) => {
|
||||
self.$store.dispatch('playlists/fetchOwn')
|
||||
self.$router.push({
|
||||
path: '/library'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,158 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">Browsing playlists</h2>
|
||||
<div :class="['ui', {'loading': isLoading}, 'form']">
|
||||
<template v-if="$store.state.auth.authenticated">
|
||||
<button
|
||||
@click="$store.commit('playlists/chooseTrack', null)"
|
||||
class="ui basic green button">Manage your playlists</button>
|
||||
<div class="ui hidden divider"></div>
|
||||
</template>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label>Search</label>
|
||||
<input type="text" v-model="query" placeholder="Enter an playlist name..."/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ordering</label>
|
||||
<select class="ui dropdown" v-model="ordering">
|
||||
<option v-for="option in orderingOptions" :value="option[0]">
|
||||
{{ option[1] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ordering direction</label>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value="">Ascending</option>
|
||||
<option value="-">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Results per page</label>
|
||||
<select class="ui dropdown" v-model="paginateBy">
|
||||
<option :value="parseInt(12)">12</option>
|
||||
<option :value="parseInt(25)">25</option>
|
||||
<option :value="parseInt(50)">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<playlist-card-list v-if="result" :playlists="result.results"></playlist-card-list>
|
||||
<div class="ui center aligned basic segment">
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import $ from 'jquery'
|
||||
|
||||
import OrderingMixin from '@/components/mixins/Ordering'
|
||||
import PaginationMixin from '@/components/mixins/Pagination'
|
||||
import PlaylistCardList from '@/components/playlists/CardList'
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
const FETCH_URL = 'playlists/'
|
||||
|
||||
export default {
|
||||
mixins: [OrderingMixin, PaginationMixin],
|
||||
props: {
|
||||
defaultQuery: {type: String, required: false, default: ''}
|
||||
},
|
||||
components: {
|
||||
PlaylistCardList,
|
||||
Pagination
|
||||
},
|
||||
data () {
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||
return {
|
||||
isLoading: true,
|
||||
result: null,
|
||||
page: parseInt(this.defaultPage),
|
||||
query: this.defaultQuery,
|
||||
paginateBy: parseInt(this.defaultPaginateBy || 12),
|
||||
orderingDirection: defaultOrdering.direction,
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'Creation date'],
|
||||
['modification_date', 'Last modification date'],
|
||||
['name', 'Name']
|
||||
]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
mounted () {
|
||||
$('.ui.dropdown').dropdown()
|
||||
},
|
||||
methods: {
|
||||
updateQueryString: _.debounce(function () {
|
||||
this.$router.replace({
|
||||
query: {
|
||||
query: this.query,
|
||||
page: this.page,
|
||||
paginateBy: this.paginateBy,
|
||||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
})
|
||||
}, 500),
|
||||
fetchData: _.debounce(function () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL
|
||||
let params = {
|
||||
page: this.page,
|
||||
page_size: this.paginateBy,
|
||||
q: this.query,
|
||||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.result = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
}, 500),
|
||||
selectPage: function (page) {
|
||||
this.page = page
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
page () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
paginateBy () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
ordering () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
orderingDirection () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
query () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -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'
|
||||
|
|
|
@ -180,7 +180,8 @@ describe('store/auth', () => {
|
|||
{ type: 'permission', payload: {key: 'admin', status: true} }
|
||||
],
|
||||
expectedActions: [
|
||||
{ type: 'favorites/fetch', payload: null, options: {root: true} }
|
||||
{ type: 'favorites/fetch', payload: null, options: {root: true} },
|
||||
{ type: 'playlists/fetchOwn', payload: null, options: {root: true} },
|
||||
]
|
||||
}, done)
|
||||
})
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
var sinon = require('sinon')
|
||||
import moxios from 'moxios'
|
||||
import store from '@/store/playlists'
|
||||
|
||||
import { testAction } from '../../utils'
|
||||
|
||||
describe('store/playlists', () => {
|
||||
var sandbox
|
||||
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.sandbox.create()
|
||||
moxios.install()
|
||||
})
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
moxios.uninstall()
|
||||
})
|
||||
|
||||
describe('mutations', () => {
|
||||
it('set playlists', () => {
|
||||
const state = { playlists: [] }
|
||||
store.mutations.playlists(state, [{id: 1, name: 'test'}])
|
||||
expect(state.playlists).to.deep.equal([{id: 1, name: 'test'}])
|
||||
})
|
||||
})
|
||||
describe('actions', () => {
|
||||
it('fetchOwn does nothing with no user', (done) => {
|
||||
testAction({
|
||||
action: store.actions.fetchOwn,
|
||||
payload: null,
|
||||
params: {state: { playlists: [] }, rootState: {auth: {profile: {}}}},
|
||||
expectedMutations: []
|
||||
}, done)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue