Merge branch 'release/0.7'
This commit is contained in:
commit
3673f624dd
|
@ -16,13 +16,15 @@ test_api:
|
||||||
stage: test
|
stage: test
|
||||||
image: funkwhale/funkwhale:latest
|
image: funkwhale/funkwhale:latest
|
||||||
cache:
|
cache:
|
||||||
key: "$CI_PROJECT_ID/pip_cache"
|
key: "$CI_PROJECT_ID__pip_cache"
|
||||||
paths:
|
paths:
|
||||||
- "$PIP_CACHE_DIR"
|
- "$PIP_CACHE_DIR"
|
||||||
variables:
|
variables:
|
||||||
DJANGO_ALLOWED_HOSTS: "localhost"
|
DJANGO_ALLOWED_HOSTS: "localhost"
|
||||||
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||||
FUNKWHALE_URL: "https://funkwhale.ci"
|
FUNKWHALE_URL: "https://funkwhale.ci"
|
||||||
|
CACHEOPS_ENABLED: "false"
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- cd api
|
- cd api
|
||||||
- pip install -r requirements/base.txt
|
- pip install -r requirements/base.txt
|
||||||
|
@ -44,7 +46,7 @@ test_front:
|
||||||
- yarn install
|
- yarn install
|
||||||
- yarn run unit
|
- yarn run unit
|
||||||
cache:
|
cache:
|
||||||
key: "$CI_PROJECT_ID/front_dependencies"
|
key: "$CI_PROJECT_ID__front_dependencies"
|
||||||
paths:
|
paths:
|
||||||
- front/node_modules
|
- front/node_modules
|
||||||
- front/yarn.lock
|
- front/yarn.lock
|
||||||
|
@ -66,7 +68,7 @@ build_front:
|
||||||
- yarn install
|
- yarn install
|
||||||
- yarn run build
|
- yarn run build
|
||||||
cache:
|
cache:
|
||||||
key: "$CI_PROJECT_ID/front_dependencies"
|
key: "$CI_PROJECT_ID__front_dependencies"
|
||||||
paths:
|
paths:
|
||||||
- front/node_modules
|
- front/node_modules
|
||||||
- front/yarn.lock
|
- front/yarn.lock
|
||||||
|
@ -84,15 +86,12 @@ build_front:
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
stage: test
|
stage: test
|
||||||
image: alpine
|
image: python:3.6-alpine
|
||||||
before_script:
|
before_script:
|
||||||
- cd docs
|
- cd docs
|
||||||
script:
|
script:
|
||||||
- apk --no-cache add py2-pip python-dev
|
|
||||||
- pip install sphinx
|
- pip install sphinx
|
||||||
- apk --no-cache add make
|
- python -m sphinx . ../public
|
||||||
- make html
|
|
||||||
- mv _build/html/ ../public
|
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
|
|
32
CHANGELOG
32
CHANGELOG
|
@ -3,7 +3,37 @@ Changelog
|
||||||
|
|
||||||
.. towncrier
|
.. 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:
|
Features:
|
||||||
|
|
183
README.rst
183
README.rst
|
@ -5,27 +5,107 @@ A self-hosted tribute to Grooveshark.com.
|
||||||
|
|
||||||
LICENSE: BSD
|
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 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
|
Launch all services
|
||||||
|
@ -33,18 +113,83 @@ Launch all services
|
||||||
|
|
||||||
Then you can run everything with::
|
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
|
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
|
This is regular pytest, so you can use any arguments/options that pytest usually accept::
|
||||||
traditional django test arguments and options, such as::
|
|
||||||
|
|
||||||
./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',
|
'taggit',
|
||||||
'rest_auth',
|
'rest_auth',
|
||||||
'rest_auth.registration',
|
'rest_auth.registration',
|
||||||
'mptt',
|
|
||||||
'dynamic_preferences',
|
'dynamic_preferences',
|
||||||
'django_filters',
|
'django_filters',
|
||||||
|
'cacheops',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -369,7 +369,19 @@ MUSICBRAINZ_CACHE_DURATION = env.int(
|
||||||
'MUSICBRAINZ_CACHE_DURATION',
|
'MUSICBRAINZ_CACHE_DURATION',
|
||||||
default=300
|
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' %}
|
# Custom Admin URL, use {% url 'admin:index' %}
|
||||||
ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
|
ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
|
||||||
CSRF_USE_SESSIONS = True
|
CSRF_USE_SESSIONS = True
|
||||||
|
|
||||||
|
# Playlist settings
|
||||||
|
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
|
||||||
|
|
|
@ -19,10 +19,6 @@ CACHES = {
|
||||||
|
|
||||||
CELERY_BROKER_URL = 'memory://'
|
CELERY_BROKER_URL = 'memory://'
|
||||||
|
|
||||||
# TESTING
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
|
|
||||||
|
|
||||||
########## CELERY
|
########## CELERY
|
||||||
# In development, all tasks will be executed locally by blocking until the task returns
|
# In development, all tasks will be executed locally by blocking until the task returns
|
||||||
CELERY_TASK_ALWAYS_EAGER = True
|
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
|
# Your local stuff: Below this line define 3rd party library settings
|
||||||
API_AUTHENTICATION_REQUIRED = False
|
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
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENTRYPOINT ["compose/django/dev-entrypoint.sh"]
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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('.')])
|
__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.conf import settings
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
from rest_framework.permissions import BasePermission, DjangoModelPermissions
|
from rest_framework.permissions import BasePermission, DjangoModelPermissions
|
||||||
|
|
||||||
|
@ -20,3 +23,39 @@ class HasModelPermission(DjangoModelPermissions):
|
||||||
"""
|
"""
|
||||||
def get_required_permissions(self, method, model_cls):
|
def get_required_permissions(self, method, model_cls):
|
||||||
return super().get_required_permissions(method, self.model)
|
return super().get_required_permissions(method, self.model)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerPermission(BasePermission):
|
||||||
|
"""
|
||||||
|
Ensure the request user is the owner of the object.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
class MyView(APIView):
|
||||||
|
model = MyModel
|
||||||
|
permission_classes = [OwnerPermission]
|
||||||
|
owner_field = 'owner'
|
||||||
|
owner_checks = ['read', 'write']
|
||||||
|
"""
|
||||||
|
perms_map = {
|
||||||
|
'GET': 'read',
|
||||||
|
'OPTIONS': 'read',
|
||||||
|
'HEAD': 'read',
|
||||||
|
'POST': 'write',
|
||||||
|
'PUT': 'write',
|
||||||
|
'PATCH': 'write',
|
||||||
|
'DELETE': 'write',
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
method_check = self.perms_map[request.method]
|
||||||
|
owner_checks = getattr(view, 'owner_checks', ['read', 'write'])
|
||||||
|
if method_check not in owner_checks:
|
||||||
|
# check not enabled
|
||||||
|
return True
|
||||||
|
|
||||||
|
owner_field = getattr(view, 'owner_field', 'user')
|
||||||
|
owner = operator.attrgetter(owner_field)(obj)
|
||||||
|
if owner != request.user:
|
||||||
|
raise Http404
|
||||||
|
return True
|
||||||
|
|
|
@ -1,12 +1,36 @@
|
||||||
import django_filters
|
from django.db.models import Count
|
||||||
|
|
||||||
|
from django_filters import rest_framework as filters
|
||||||
|
|
||||||
from . import models
|
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:
|
class Meta:
|
||||||
model = models.Artist
|
model = models.Artist
|
||||||
fields = {
|
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)
|
queryset = queryset.filter(tags__pk=tag)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = (
|
queryset = (
|
||||||
models.Artist.objects.all()
|
models.Artist.objects.all()
|
||||||
|
@ -67,6 +68,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
filter_class = filters.ArtistFilter
|
filter_class = filters.ArtistFilter
|
||||||
ordering_fields = ('id', 'name', 'creation_date')
|
ordering_fields = ('id', 'name', 'creation_date')
|
||||||
|
|
||||||
|
|
||||||
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = (
|
queryset = (
|
||||||
models.Album.objects.all()
|
models.Album.objects.all()
|
||||||
|
@ -78,6 +80,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
permission_classes = [ConditionalAuthentication]
|
permission_classes = [ConditionalAuthentication]
|
||||||
search_fields = ['title__unaccent']
|
search_fields = ['title__unaccent']
|
||||||
ordering_fields = ('creation_date',)
|
ordering_fields = ('creation_date',)
|
||||||
|
filter_class = filters.AlbumFilter
|
||||||
|
|
||||||
|
|
||||||
class ImportBatchViewSet(
|
class ImportBatchViewSet(
|
||||||
|
@ -237,6 +240,7 @@ class TagViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
|
||||||
class Search(views.APIView):
|
class Search(views.APIView):
|
||||||
max_results = 3
|
max_results = 3
|
||||||
|
permission_classes = [ConditionalAuthentication]
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
query = request.GET['query']
|
query = request.GET['query']
|
||||||
|
|
|
@ -5,13 +5,13 @@ from . import models
|
||||||
|
|
||||||
@admin.register(models.Playlist)
|
@admin.register(models.Playlist)
|
||||||
class PlaylistAdmin(admin.ModelAdmin):
|
class PlaylistAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'user', 'is_public', 'creation_date']
|
list_display = ['name', 'user', 'privacy_level', 'creation_date']
|
||||||
search_fields = ['name', ]
|
search_fields = ['name', ]
|
||||||
list_select_related = True
|
list_select_related = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.PlaylistTrack)
|
@admin.register(models.PlaylistTrack)
|
||||||
class PlaylistTrackAdmin(admin.ModelAdmin):
|
class PlaylistTrackAdmin(admin.ModelAdmin):
|
||||||
list_display = ['playlist', 'track', 'position', ]
|
list_display = ['playlist', 'track', 'index']
|
||||||
search_fields = ['track__name', 'playlist__name']
|
search_fields = ['track__name', 'playlist__name']
|
||||||
list_select_related = True
|
list_select_related = True
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
from funkwhale_api.factories import registry
|
from funkwhale_api.factories import registry
|
||||||
|
from funkwhale_api.music.factories import TrackFactory
|
||||||
from funkwhale_api.users.factories import UserFactory
|
from funkwhale_api.users.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
@ -11,3 +12,12 @@ class PlaylistFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'playlists.Playlist'
|
model = 'playlists.Playlist'
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
class PlaylistTrackFactory(factory.django.DjangoModelFactory):
|
||||||
|
playlist = factory.SubFactory(PlaylistFactory)
|
||||||
|
track = factory.SubFactory(TrackFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'playlists.PlaylistTrack'
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
from django_filters import rest_framework as filters
|
||||||
|
|
||||||
|
from funkwhale_api.music import utils
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistFilter(filters.FilterSet):
|
||||||
|
q = filters.CharFilter(name='_', method='filter_q')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Playlist
|
||||||
|
fields = {
|
||||||
|
'user': ['exact'],
|
||||||
|
'name': ['exact', 'icontains'],
|
||||||
|
'q': 'exact',
|
||||||
|
}
|
||||||
|
|
||||||
|
def filter_q(self, queryset, name, value):
|
||||||
|
query = utils.get_query(value, ['name', 'user__username'])
|
||||||
|
return queryset.filter(query)
|
|
@ -4,7 +4,6 @@ from __future__ import unicode_literals
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
import mptt.fields
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
@ -34,7 +33,7 @@ class Migration(migrations.Migration):
|
||||||
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
|
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
|
||||||
('position', models.PositiveIntegerField(db_index=True, editable=False)),
|
('position', models.PositiveIntegerField(db_index=True, editable=False)),
|
||||||
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)),
|
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)),
|
||||||
('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
|
('previous', models.OneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)),
|
||||||
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
|
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 2.0.3 on 2018-03-16 22:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('playlists', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='playlist',
|
||||||
|
name='is_public',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playlist',
|
||||||
|
name='privacy_level',
|
||||||
|
field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 2.0.3 on 2018-03-19 12:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('playlists', '0002_auto_20180316_2217'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='playlisttrack',
|
||||||
|
options={'ordering': ('-playlist', 'index')},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='creation_date',
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='index',
|
||||||
|
field=models.PositiveIntegerField(null=True),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='lft',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='position',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='previous',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='rght',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='tree_id',
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='playlisttrack',
|
||||||
|
unique_together={('playlist', 'index')},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 2.0.3 on 2018-03-20 17:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('playlists', '0003_auto_20180319_1214'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playlist',
|
||||||
|
name='modification_date',
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='playlisttrack',
|
||||||
|
name='index',
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='playlisttrack',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,43 +1,130 @@
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from mptt.models import MPTTModel, TreeOneToOneField
|
from rest_framework import exceptions
|
||||||
|
|
||||||
|
from funkwhale_api.common import fields
|
||||||
|
|
||||||
|
|
||||||
class Playlist(models.Model):
|
class Playlist(models.Model):
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
is_public = models.BooleanField(default=False)
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
'users.User', related_name="playlists", on_delete=models.CASCADE)
|
'users.User', related_name="playlists", on_delete=models.CASCADE)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
modification_date = models.DateTimeField(
|
||||||
|
auto_now=True)
|
||||||
|
privacy_level = fields.get_privacy_field()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def add_track(self, track, previous=None):
|
@transaction.atomic
|
||||||
plt = PlaylistTrack(previous=previous, track=track, playlist=self)
|
def insert(self, plt, index=None):
|
||||||
plt.save()
|
"""
|
||||||
|
Given a PlaylistTrack, insert it at the correct index in the playlist,
|
||||||
|
and update other tracks index if necessary.
|
||||||
|
"""
|
||||||
|
old_index = plt.index
|
||||||
|
move = old_index is not None
|
||||||
|
if index is not None and index == old_index:
|
||||||
|
# moving at same position, just skip
|
||||||
|
return index
|
||||||
|
|
||||||
return plt
|
existing = self.playlist_tracks.select_for_update()
|
||||||
|
if move:
|
||||||
|
existing = existing.exclude(pk=plt.pk)
|
||||||
|
total = existing.filter(index__isnull=False).count()
|
||||||
|
|
||||||
|
if index is None:
|
||||||
|
# we simply increment the last track index by 1
|
||||||
|
index = total
|
||||||
|
|
||||||
|
if index > total:
|
||||||
|
raise exceptions.ValidationError('Index is not continuous')
|
||||||
|
|
||||||
|
if index < 0:
|
||||||
|
raise exceptions.ValidationError('Index must be zero or positive')
|
||||||
|
|
||||||
|
if move:
|
||||||
|
# we remove the index temporarily, to avoid integrity errors
|
||||||
|
plt.index = None
|
||||||
|
plt.save(update_fields=['index'])
|
||||||
|
if index > old_index:
|
||||||
|
# new index is higher than current, we decrement previous tracks
|
||||||
|
to_update = existing.filter(
|
||||||
|
index__gt=old_index, index__lte=index)
|
||||||
|
to_update.update(index=models.F('index') - 1)
|
||||||
|
if index < old_index:
|
||||||
|
# new index is lower than current, we increment next tracks
|
||||||
|
to_update = existing.filter(index__lt=old_index, index__gte=index)
|
||||||
|
to_update.update(index=models.F('index') + 1)
|
||||||
|
else:
|
||||||
|
to_update = existing.filter(index__gte=index)
|
||||||
|
to_update.update(index=models.F('index') + 1)
|
||||||
|
|
||||||
|
plt.index = index
|
||||||
|
plt.save(update_fields=['index'])
|
||||||
|
self.save(update_fields=['modification_date'])
|
||||||
|
return index
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def remove(self, index):
|
||||||
|
existing = self.playlist_tracks.select_for_update()
|
||||||
|
self.save(update_fields=['modification_date'])
|
||||||
|
to_update = existing.filter(index__gt=index)
|
||||||
|
return to_update.update(index=models.F('index') - 1)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def insert_many(self, tracks):
|
||||||
|
existing = self.playlist_tracks.select_for_update()
|
||||||
|
now = timezone.now()
|
||||||
|
total = existing.filter(index__isnull=False).count()
|
||||||
|
if existing.count() + len(tracks) > settings.PLAYLISTS_MAX_TRACKS:
|
||||||
|
raise exceptions.ValidationError(
|
||||||
|
'Playlist would reach the maximum of {} tracks'.format(
|
||||||
|
settings.PLAYLISTS_MAX_TRACKS))
|
||||||
|
self.save(update_fields=['modification_date'])
|
||||||
|
start = total
|
||||||
|
plts = [
|
||||||
|
PlaylistTrack(
|
||||||
|
creation_date=now, playlist=self, track=track, index=start+i)
|
||||||
|
for i, track in enumerate(tracks)
|
||||||
|
]
|
||||||
|
return PlaylistTrack.objects.bulk_create(plts)
|
||||||
|
|
||||||
|
class PlaylistTrackQuerySet(models.QuerySet):
|
||||||
|
def for_nested_serialization(self):
|
||||||
|
return (self.select_related()
|
||||||
|
.select_related('track__album__artist')
|
||||||
|
.prefetch_related(
|
||||||
|
'track__tags',
|
||||||
|
'track__files',
|
||||||
|
'track__artist__albums__tracks__tags'))
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTrack(MPTTModel):
|
class PlaylistTrack(models.Model):
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
'music.Track',
|
'music.Track',
|
||||||
related_name='playlist_tracks',
|
related_name='playlist_tracks',
|
||||||
on_delete=models.CASCADE)
|
on_delete=models.CASCADE)
|
||||||
previous = TreeOneToOneField(
|
index = models.PositiveIntegerField(null=True, blank=True)
|
||||||
'self',
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
related_name='next',
|
|
||||||
on_delete=models.CASCADE)
|
|
||||||
playlist = models.ForeignKey(
|
playlist = models.ForeignKey(
|
||||||
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
|
Playlist, related_name='playlist_tracks', on_delete=models.CASCADE)
|
||||||
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
class MPTTMeta:
|
objects = PlaylistTrackQuerySet.as_manager()
|
||||||
level_attr = 'position'
|
|
||||||
parent_attr = 'previous'
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('-playlist', 'position')
|
ordering = ('-playlist', 'index')
|
||||||
|
unique_together = ('playlist', 'index')
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
playlist = self.playlist
|
||||||
|
index = self.index
|
||||||
|
update_indexes = kwargs.pop('update_indexes', False)
|
||||||
|
r = super().delete(*args, **kwargs)
|
||||||
|
if index is not None and update_indexes:
|
||||||
|
playlist.remove(index)
|
||||||
|
return r
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
|
from funkwhale_api.music.models import Track
|
||||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||||
|
from funkwhale_api.users.serializers import UserBasicSerializer
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@ -11,20 +14,81 @@ class PlaylistTrackSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PlaylistTrack
|
model = models.PlaylistTrack
|
||||||
fields = ('id', 'track', 'playlist', 'position')
|
fields = ('id', 'track', 'playlist', 'index', 'creation_date')
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
|
class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
|
||||||
|
index = serializers.IntegerField(
|
||||||
|
required=False, min_value=0, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PlaylistTrack
|
model = models.PlaylistTrack
|
||||||
fields = ('id', 'track', 'playlist', 'position')
|
fields = ('id', 'track', 'playlist', 'index')
|
||||||
|
|
||||||
|
def validate_playlist(self, value):
|
||||||
|
if self.context.get('request'):
|
||||||
|
# validate proper ownership on the playlist
|
||||||
|
if self.context['request'].user != value.user:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
'You do not have the permission to edit this playlist')
|
||||||
|
existing = value.playlist_tracks.count()
|
||||||
|
if existing >= settings.PLAYLISTS_MAX_TRACKS:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
'Playlist has reached the maximum of {} tracks'.format(
|
||||||
|
settings.PLAYLISTS_MAX_TRACKS))
|
||||||
|
return value
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create(self, validated_data):
|
||||||
|
index = validated_data.pop('index', None)
|
||||||
|
instance = super().create(validated_data)
|
||||||
|
instance.playlist.insert(instance, index)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
update_index = 'index' in validated_data
|
||||||
|
index = validated_data.pop('index', None)
|
||||||
|
super().update(instance, validated_data)
|
||||||
|
if update_index:
|
||||||
|
instance.playlist.insert(instance, index)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def get_unique_together_validators(self):
|
||||||
|
"""
|
||||||
|
We explicitely disable unique together validation here
|
||||||
|
because it collides with our internal logic
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class PlaylistSerializer(serializers.ModelSerializer):
|
class PlaylistSerializer(serializers.ModelSerializer):
|
||||||
playlist_tracks = PlaylistTrackSerializer(many=True, read_only=True)
|
tracks_count = serializers.SerializerMethodField(read_only=True)
|
||||||
|
user = UserBasicSerializer(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Playlist
|
model = models.Playlist
|
||||||
fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks')
|
fields = (
|
||||||
read_only_fields = ['id', 'playlist_tracks', 'creation_date']
|
'id',
|
||||||
|
'name',
|
||||||
|
'tracks_count',
|
||||||
|
'user',
|
||||||
|
'modification_date',
|
||||||
|
'creation_date',
|
||||||
|
'privacy_level',)
|
||||||
|
read_only_fields = [
|
||||||
|
'id',
|
||||||
|
'modification_date',
|
||||||
|
'creation_date',]
|
||||||
|
|
||||||
|
def get_tracks_count(self, obj):
|
||||||
|
try:
|
||||||
|
return obj.tracks_count
|
||||||
|
except AttributeError:
|
||||||
|
# no annotation?
|
||||||
|
return obj.playlist_tracks.count()
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistAddManySerializer(serializers.Serializer):
|
||||||
|
tracks = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True, queryset=Track.objects.for_nested_serialization())
|
||||||
|
|
|
@ -1,58 +1,123 @@
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from rest_framework import exceptions
|
||||||
from rest_framework import generics, mixins, viewsets
|
from rest_framework import generics, mixins, viewsets
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||||
|
|
||||||
|
from funkwhale_api.common import permissions
|
||||||
|
from funkwhale_api.common import fields
|
||||||
from funkwhale_api.music.models import Track
|
from funkwhale_api.music.models import Track
|
||||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
|
||||||
|
|
||||||
|
from . import filters
|
||||||
from . import models
|
from . import models
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
class PlaylistViewSet(
|
class PlaylistViewSet(
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
viewsets.GenericViewSet):
|
viewsets.GenericViewSet):
|
||||||
|
|
||||||
serializer_class = serializers.PlaylistSerializer
|
serializer_class = serializers.PlaylistSerializer
|
||||||
queryset = (models.Playlist.objects.all())
|
queryset = (
|
||||||
permission_classes = [ConditionalAuthentication]
|
models.Playlist.objects.all().select_related('user')
|
||||||
|
.annotate(tracks_count=Count('playlist_tracks'))
|
||||||
|
)
|
||||||
|
permission_classes = [
|
||||||
|
permissions.ConditionalAuthentication,
|
||||||
|
permissions.OwnerPermission,
|
||||||
|
IsAuthenticatedOrReadOnly,
|
||||||
|
]
|
||||||
|
owner_checks = ['write']
|
||||||
|
filter_class = filters.PlaylistFilter
|
||||||
|
ordering_fields = ('id', 'name', 'creation_date', 'modification_date')
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
@detail_route(methods=['get'])
|
||||||
serializer = self.get_serializer(data=request.data)
|
def tracks(self, request, *args, **kwargs):
|
||||||
|
playlist = self.get_object()
|
||||||
|
plts = playlist.playlist_tracks.all().for_nested_serialization()
|
||||||
|
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
|
||||||
|
data = {
|
||||||
|
'count': len(plts),
|
||||||
|
'results': serializer.data
|
||||||
|
}
|
||||||
|
return Response(data, status=200)
|
||||||
|
|
||||||
|
@detail_route(methods=['post'])
|
||||||
|
@transaction.atomic
|
||||||
|
def add(self, request, *args, **kwargs):
|
||||||
|
playlist = self.get_object()
|
||||||
|
serializer = serializers.PlaylistAddManySerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
instance = self.perform_create(serializer)
|
try:
|
||||||
serializer = self.get_serializer(instance=instance)
|
plts = playlist.insert_many(serializer.validated_data['tracks'])
|
||||||
headers = self.get_success_headers(serializer.data)
|
except exceptions.ValidationError as e:
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
payload = {'playlist': e.detail}
|
||||||
|
return Response(payload, status=400)
|
||||||
|
ids = [p.id for p in plts]
|
||||||
|
plts = models.PlaylistTrack.objects.filter(
|
||||||
|
pk__in=ids).order_by('index').for_nested_serialization()
|
||||||
|
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
|
||||||
|
data = {
|
||||||
|
'count': len(plts),
|
||||||
|
'results': serializer.data
|
||||||
|
}
|
||||||
|
return Response(data, status=201)
|
||||||
|
|
||||||
|
@detail_route(methods=['delete'])
|
||||||
|
@transaction.atomic
|
||||||
|
def clear(self, request, *args, **kwargs):
|
||||||
|
playlist = self.get_object()
|
||||||
|
playlist.playlist_tracks.all().delete()
|
||||||
|
playlist.save(update_fields=['modification_date'])
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(user=self.request.user)
|
return self.queryset.filter(
|
||||||
|
fields.privacy_level_query(self.request.user))
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
return serializer.save(user=self.request.user)
|
return serializer.save(
|
||||||
|
user=self.request.user,
|
||||||
|
privacy_level=serializer.validated_data.get(
|
||||||
|
'privacy_level', self.request.user.privacy_level)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTrackViewSet(
|
class PlaylistTrackViewSet(
|
||||||
|
mixins.RetrieveModelMixin,
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
viewsets.GenericViewSet):
|
viewsets.GenericViewSet):
|
||||||
|
|
||||||
serializer_class = serializers.PlaylistTrackSerializer
|
serializer_class = serializers.PlaylistTrackSerializer
|
||||||
queryset = (models.PlaylistTrack.objects.all())
|
queryset = (models.PlaylistTrack.objects.all().for_nested_serialization())
|
||||||
permission_classes = [ConditionalAuthentication]
|
permission_classes = [
|
||||||
|
permissions.ConditionalAuthentication,
|
||||||
|
permissions.OwnerPermission,
|
||||||
|
IsAuthenticatedOrReadOnly,
|
||||||
|
]
|
||||||
|
owner_field = 'playlist.user'
|
||||||
|
owner_checks = ['write']
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def get_serializer_class(self):
|
||||||
serializer = serializers.PlaylistTrackCreateSerializer(
|
if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']:
|
||||||
data=request.data)
|
return serializers.PlaylistTrackWriteSerializer
|
||||||
serializer.is_valid(raise_exception=True)
|
return self.serializer_class
|
||||||
instance = self.perform_create(serializer)
|
|
||||||
serializer = self.get_serializer(instance=instance)
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(playlist__user=self.request.user)
|
return self.queryset.filter(
|
||||||
|
fields.privacy_level_query(
|
||||||
|
self.request.user,
|
||||||
|
lookup_field='playlist__privacy_level'))
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
instance.delete(update_indexes=True)
|
||||||
|
|
|
@ -7,8 +7,7 @@ from . import models
|
||||||
class ImportRequestAdmin(admin.ModelAdmin):
|
class ImportRequestAdmin(admin.ModelAdmin):
|
||||||
list_display = ['artist_name', 'user', 'status', 'creation_date']
|
list_display = ['artist_name', 'user', 'status', 'creation_date']
|
||||||
list_select_related = [
|
list_select_related = [
|
||||||
'user',
|
'user'
|
||||||
'track'
|
|
||||||
]
|
]
|
||||||
list_filter = [
|
list_filter = [
|
||||||
'status',
|
'status',
|
||||||
|
|
|
@ -10,15 +10,9 @@ from django.db import models
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from funkwhale_api.common import fields
|
||||||
|
|
||||||
|
|
||||||
PRIVACY_LEVEL_CHOICES = [
|
|
||||||
('me', 'Only me'),
|
|
||||||
('followers', 'Me and my followers'),
|
|
||||||
('instance', 'Everyone on my instance, and my followers'),
|
|
||||||
('everyone', 'Everyone, including people on other instances'),
|
|
||||||
]
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
|
|
||||||
|
@ -39,8 +33,8 @@ class User(AbstractUser):
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
privacy_level = models.CharField(
|
privacy_level = fields.get_privacy_field()
|
||||||
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
|
@ -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
|
youtube_dl>=2017.12.14
|
||||||
djangorestframework>=3.7,<3.8
|
djangorestframework>=3.7,<3.8
|
||||||
djangorestframework-jwt>=1.11,<1.12
|
djangorestframework-jwt>=1.11,<1.12
|
||||||
django-mptt>=0.9,<0.10
|
|
||||||
google-api-python-client>=1.6,<1.7
|
google-api-python-client>=1.6,<1.7
|
||||||
arrow>=0.12,<0.13
|
arrow>=0.12,<0.13
|
||||||
persisting-theory>=0.2,<0.3
|
persisting-theory>=0.2,<0.3
|
||||||
|
@ -58,3 +57,6 @@ python-magic==0.4.15
|
||||||
ffmpeg-python==0.1.10
|
ffmpeg-python==0.1.10
|
||||||
channels>=2,<2.1
|
channels>=2,<2.1
|
||||||
channels_redis>=2.1,<2.2
|
channels_redis>=2.1,<2.2
|
||||||
|
django-cacheops>=4,<4.1
|
||||||
|
|
||||||
|
daphne==2.0.4
|
||||||
|
|
|
@ -2,9 +2,6 @@
|
||||||
|
|
||||||
coverage>=4.4,<4.5
|
coverage>=4.4,<4.5
|
||||||
django_coverage_plugin>=1.5,<1.6
|
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
|
factory_boy>=2.8.1
|
||||||
|
|
||||||
# django-debug-toolbar that works with Django 1.5+
|
# django-debug-toolbar that works with Django 1.5+
|
||||||
|
|
|
@ -3,5 +3,3 @@
|
||||||
|
|
||||||
# WSGI Handler
|
# 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]
|
[pep8]
|
||||||
max-line-length = 120
|
max-line-length = 120
|
||||||
exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
|
exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
|
||||||
|
|
||||||
|
[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 tempfile
|
||||||
import shutil
|
import shutil
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from django.core.cache import cache as django_cache
|
from django.core.cache import cache as django_cache
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework.test import APIRequestFactory
|
||||||
|
|
||||||
from funkwhale_api.activity import record
|
from funkwhale_api.activity import record
|
||||||
from funkwhale_api.taskapp import celery
|
from funkwhale_api.taskapp import celery
|
||||||
|
@ -26,6 +30,16 @@ def cache():
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def factories(db):
|
def factories(db):
|
||||||
from funkwhale_api import factories
|
from funkwhale_api import factories
|
||||||
|
for v in factories.registry.values():
|
||||||
|
v._meta.strategy = factory.CREATE_STRATEGY
|
||||||
|
yield factories.registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def nodb_factories():
|
||||||
|
from funkwhale_api import factories
|
||||||
|
for v in factories.registry.values():
|
||||||
|
v._meta.strategy = factory.BUILD_STRATEGY
|
||||||
yield factories.registry
|
yield factories.registry
|
||||||
|
|
||||||
|
|
||||||
|
@ -84,6 +98,11 @@ def superuser_client(db, factories, client):
|
||||||
delattr(client, 'user')
|
delattr(client, 'user')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_request():
|
||||||
|
return APIRequestFactory()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def activity_registry():
|
def activity_registry():
|
||||||
r = record.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
|
- ./api/funkwhale_api/media:/protected/media
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:6001:6001"
|
- "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"
|
v-if="$store.state.instance.settings.raven.front_enabled.value"
|
||||||
:dsn="$store.state.instance.settings.raven.front_dsn.value">
|
:dsn="$store.state.instance.settings.raven.front_dsn.value">
|
||||||
</raven>
|
</raven>
|
||||||
|
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -39,11 +41,14 @@ import logger from '@/logging'
|
||||||
import Sidebar from '@/components/Sidebar'
|
import Sidebar from '@/components/Sidebar'
|
||||||
import Raven from '@/components/Raven'
|
import Raven from '@/components/Raven'
|
||||||
|
|
||||||
|
import PlaylistModal from '@/components/playlists/PlaylistModal'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'app',
|
name: 'app',
|
||||||
components: {
|
components: {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
Raven
|
Raven,
|
||||||
|
PlaylistModal
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.$store.dispatch('instance/fetchSettings')
|
this.$store.dispatch('instance/fetchSettings')
|
||||||
|
@ -56,6 +61,9 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openWebsocket () {
|
openWebsocket () {
|
||||||
|
if (!this.$store.state.auth.authenticated) {
|
||||||
|
return
|
||||||
|
}
|
||||||
let self = this
|
let self = this
|
||||||
let token = this.$store.state.auth.token
|
let token = this.$store.state.auth.token
|
||||||
// let token = 'test'
|
// let token = 'test'
|
||||||
|
|
|
@ -94,9 +94,9 @@
|
||||||
<p>Funkwhale is dead simple to use.</p>
|
<p>Funkwhale is dead simple to use.</p>
|
||||||
<div class="ui list">
|
<div class="ui list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="libraryr icon"></i>
|
<i class="book icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="ui pagination borderless menu">
|
<div class="ui pagination borderless menu">
|
||||||
<a
|
<a
|
||||||
|
v-if="current - 1 >= 1"
|
||||||
@click="selectPage(current - 1)"
|
@click="selectPage(current - 1)"
|
||||||
:class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></a>
|
:class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></a>
|
||||||
<template>
|
<template>
|
||||||
|
@ -16,6 +17,7 @@
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<a
|
<a
|
||||||
|
v-if="current + 1 <= maxPage"
|
||||||
@click="selectPage(current + 1)"
|
@click="selectPage(current + 1)"
|
||||||
:class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></a>
|
:class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,7 +64,6 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log(final)
|
|
||||||
return final
|
return final
|
||||||
},
|
},
|
||||||
maxPage: function () {
|
maxPage: function () {
|
||||||
|
|
|
@ -36,6 +36,12 @@
|
||||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
||||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
|
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
||||||
|
<a
|
||||||
|
@click="$store.commit('playlists/chooseTrack', null)"
|
||||||
|
v-if="$store.state.auth.authenticated"
|
||||||
|
class="item">
|
||||||
|
<i class="list icon"></i> Playlists
|
||||||
|
</a>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="$store.state.auth.authenticated"
|
||||||
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
|
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
|
||||||
|
@ -70,7 +76,7 @@
|
||||||
<td>
|
<td>
|
||||||
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
|
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
|
||||||
<i class="pink heart icon"></i>
|
<i class="pink heart icon"></i>
|
||||||
</template
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<i @click.stop="cleanTrack(index)" class="circular trash icon"></i>
|
<i @click.stop="cleanTrack(index)" class="circular trash icon"></i>
|
||||||
|
@ -148,7 +154,7 @@ export default {
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import '../style/vendor/media';
|
@import '../style/vendor/media';
|
||||||
|
|
||||||
$sidebar-color: #1B1C1D;
|
$sidebar-color: #3D3E3F;
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: $sidebar-color;
|
background: $sidebar-color;
|
||||||
|
@ -159,7 +165,7 @@ $sidebar-color: #1B1C1D;
|
||||||
}
|
}
|
||||||
@include media(">desktop") {
|
@include media(">desktop") {
|
||||||
.collapse.button {
|
.collapse.button {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@include media("<desktop") {
|
@include media("<desktop") {
|
||||||
|
@ -176,16 +182,26 @@ $sidebar-color: #1B1C1D;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: $sidebar-color;
|
background-color: $sidebar-color;
|
||||||
}
|
}
|
||||||
.menu {
|
.menu.vertical {
|
||||||
|
background: $sidebar-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-area {
|
.menu-area {
|
||||||
padding: 0.5rem;
|
|
||||||
.menu .item:not(.active):not(:hover) {
|
.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 {
|
.tabs {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
@ -216,14 +232,33 @@ $sidebar-color: #1B1C1D;
|
||||||
.logo {
|
.logo {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
margin: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.search {
|
.ui.search {
|
||||||
display: block;
|
display: flex;
|
||||||
.collapse.button {
|
|
||||||
margin-right: 0.5rem;
|
.collapse.button, .collapse.button:hover, .collapse.button:active {
|
||||||
margin-top: 0.5rem;
|
box-shadow: none !important;
|
||||||
float: right;
|
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>
|
</style>
|
||||||
|
|
|
@ -3,11 +3,11 @@
|
||||||
<button
|
<button
|
||||||
title="Add to current queue"
|
title="Add to current queue"
|
||||||
@click="add"
|
@click="add"
|
||||||
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: playableTracks.length === 0}, 'button']">
|
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
|
||||||
<i class="ui play icon"></i>
|
<i class="ui play icon"></i>
|
||||||
<template v-if="!discrete"><slot>Play</slot></template>
|
<template v-if="!discrete"><slot>Play</slot></template>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="!discrete" class="ui floating dropdown icon button">
|
<div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
|
||||||
<i class="dropdown icon"></i>
|
<i class="dropdown icon"></i>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<div class="item"@click="add"><i class="plus icon"></i> Add to queue</div>
|
<div class="item"@click="add"><i class="plus icon"></i> Add to queue</div>
|
||||||
|
@ -19,6 +19,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
import jQuery from 'jquery'
|
import jQuery from 'jquery'
|
||||||
|
|
||||||
|
@ -27,6 +28,7 @@ export default {
|
||||||
// we can either have a single or multiple tracks to play when clicked
|
// we can either have a single or multiple tracks to play when clicked
|
||||||
tracks: {type: Array, required: false},
|
tracks: {type: Array, required: false},
|
||||||
track: {type: Object, required: false},
|
track: {type: Object, required: false},
|
||||||
|
playlist: {type: Object, required: false},
|
||||||
discrete: {type: Boolean, default: false}
|
discrete: {type: Boolean, default: false}
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
|
@ -35,8 +37,8 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
if (!this.track & !this.tracks) {
|
if (!this.playlist && !this.track && !this.tracks) {
|
||||||
logger.default.error('You have to provide either a track or tracks property')
|
logger.default.error('You have to provide either a track playlist or tracks property')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
@ -45,19 +47,40 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
playableTracks () {
|
playable () {
|
||||||
let tracks
|
|
||||||
if (this.track) {
|
if (this.track) {
|
||||||
tracks = [this.track]
|
return true
|
||||||
} else {
|
} else if (this.tracks) {
|
||||||
tracks = this.tracks
|
return this.tracks.length > 0
|
||||||
|
} else if (this.playlist) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return tracks.filter(e => {
|
return false
|
||||||
return e.files.length > 0
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getPlayableTracks () {
|
||||||
|
let self = this
|
||||||
|
let getTracks = new Promise((resolve, reject) => {
|
||||||
|
if (self.track) {
|
||||||
|
resolve([self.track])
|
||||||
|
} else if (self.tracks) {
|
||||||
|
resolve(self.tracks)
|
||||||
|
} else if (self.playlist) {
|
||||||
|
let url = 'playlists/' + self.playlist.id + '/'
|
||||||
|
axios.get(url + 'tracks').then((response) => {
|
||||||
|
resolve(response.data.results.map(plt => {
|
||||||
|
return plt.track
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return getTracks.then((tracks) => {
|
||||||
|
return tracks.filter(e => {
|
||||||
|
return e.files.length > 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
triggerLoad () {
|
triggerLoad () {
|
||||||
let self = this
|
let self = this
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
@ -66,15 +89,21 @@ export default {
|
||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
add () {
|
add () {
|
||||||
|
let self = this
|
||||||
this.triggerLoad()
|
this.triggerLoad()
|
||||||
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks})
|
this.getPlayableTracks().then((tracks) => {
|
||||||
|
self.$store.dispatch('queue/appendMany', {tracks: tracks})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
addNext (next) {
|
addNext (next) {
|
||||||
|
let self = this
|
||||||
this.triggerLoad()
|
this.triggerLoad()
|
||||||
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1})
|
this.getPlayableTracks().then((tracks) => {
|
||||||
|
self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1})
|
||||||
if (next) {
|
if (next) {
|
||||||
this.$store.dispatch('queue/next')
|
self.$store.dispatch('queue/next')
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<div class="player">
|
<div class="player">
|
||||||
<audio-track
|
<audio-track
|
||||||
ref="currentAudio"
|
ref="currentAudio"
|
||||||
v-if="currentTrack"
|
v-if="renderAudio && currentTrack"
|
||||||
:key="(currentIndex, currentTrack.id)"
|
:key="(currentIndex, currentTrack.id)"
|
||||||
:is-current="true"
|
:is-current="true"
|
||||||
:start-time="$store.state.player.currentTime"
|
:start-time="$store.state.player.currentTime"
|
||||||
|
@ -30,7 +30,12 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<track-favorite-icon :track="currentTrack"></track-favorite-icon>
|
<track-favorite-icon
|
||||||
|
v-if="$store.state.auth.authenticated"
|
||||||
|
:track="currentTrack"></track-favorite-icon>
|
||||||
|
<track-playlist-icon
|
||||||
|
v-if="$store.state.auth.authenticated"
|
||||||
|
:track="currentTrack"></track-playlist-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -140,17 +145,20 @@ import ColorThief from '@/vendor/color-thief'
|
||||||
import Track from '@/audio/track'
|
import Track from '@/audio/track'
|
||||||
import AudioTrack from '@/components/audio/Track'
|
import AudioTrack from '@/components/audio/Track'
|
||||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||||
|
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'player',
|
name: 'player',
|
||||||
components: {
|
components: {
|
||||||
TrackFavoriteIcon,
|
TrackFavoriteIcon,
|
||||||
|
TrackPlaylistIcon,
|
||||||
GlobalEvents,
|
GlobalEvents,
|
||||||
AudioTrack
|
AudioTrack
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]]
|
let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]]
|
||||||
return {
|
return {
|
||||||
|
renderAudio: true,
|
||||||
sliderVolume: this.volume,
|
sliderVolume: this.volume,
|
||||||
Track: Track,
|
Track: Track,
|
||||||
defaultAmbiantColors: defaultAmbiantColors,
|
defaultAmbiantColors: defaultAmbiantColors,
|
||||||
|
@ -163,7 +171,6 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions({
|
...mapActions({
|
||||||
pause: 'player/pause',
|
|
||||||
togglePlay: 'player/togglePlay',
|
togglePlay: 'player/togglePlay',
|
||||||
clean: 'queue/clean',
|
clean: 'queue/clean',
|
||||||
next: 'queue/next',
|
next: 'queue/next',
|
||||||
|
@ -230,6 +237,17 @@ export default {
|
||||||
this.ambiantColors = this.defaultAmbiantColors
|
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) {
|
volume (newValue) {
|
||||||
this.sliderVolume = newValue
|
this.sliderVolume = newValue
|
||||||
},
|
},
|
||||||
|
@ -270,6 +288,7 @@ export default {
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
}
|
}
|
||||||
.track-area {
|
.track-area {
|
||||||
|
margin-top: 0;
|
||||||
.header, .meta, .artist, .album {
|
.header, .meta, .artist, .album {
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
@ -373,4 +392,5 @@ export default {
|
||||||
.ui.feed.icon {
|
.ui.feed.icon {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -30,6 +30,9 @@ export default {
|
||||||
},
|
},
|
||||||
apiSettings: {
|
apiSettings: {
|
||||||
beforeXHR: function (xhrObject) {
|
beforeXHR: function (xhrObject) {
|
||||||
|
if (!self.$store.state.auth.authenticated) {
|
||||||
|
return xhrObject
|
||||||
|
}
|
||||||
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
|
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
|
||||||
return xhrObject
|
return xhrObject
|
||||||
},
|
},
|
||||||
|
|
|
@ -9,9 +9,10 @@
|
||||||
<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>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
By <router-link :to="{name: 'library.artists.detail', params: {id: album.artist.id }}">
|
<span>
|
||||||
{{ album.artist.name }}
|
By <router-link tag="span" :to="{name: 'library.artists.detail', params: {id: album.artist.id }}">
|
||||||
</router-link>
|
{{ album.artist.name }}</router-link>
|
||||||
|
</span><span class="time" v-if="album.release_date">– {{ album.release_date | year }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="description" v-if="mode === 'rich'">
|
<div class="description" v-if="mode === 'rich'">
|
||||||
<table class="ui very basic fixed single line compact unstackable table">
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="track in tracks">
|
<track-row
|
||||||
<td>
|
:display-position="displayPosition"
|
||||||
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
|
:track="track"
|
||||||
</td>
|
:key="index + '-' + track.id"
|
||||||
<td>
|
v-for="(track, index) in tracks"></track-row>
|
||||||
<img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
|
|
||||||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
|
||||||
</td>
|
|
||||||
<td colspan="6">
|
|
||||||
<router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
|
|
||||||
<template v-if="displayPosition && track.position">
|
|
||||||
{{ track.position }}.
|
|
||||||
</template>
|
|
||||||
{{ track.title }}
|
|
||||||
</router-link>
|
|
||||||
</td>
|
|
||||||
<td colspan="6">
|
|
||||||
<router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}">
|
|
||||||
{{ track.artist.name }}
|
|
||||||
</router-link>
|
|
||||||
</td>
|
|
||||||
<td colspan="6">
|
|
||||||
<router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
|
|
||||||
{{ track.album.title }}
|
|
||||||
</router-link>
|
|
||||||
</td>
|
|
||||||
<td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot class="full-width">
|
<tfoot class="full-width">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -83,9 +60,8 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.aut
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import backend from '@/audio/backend'
|
import backend from '@/audio/backend'
|
||||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
|
||||||
|
|
||||||
|
import TrackRow from '@/components/audio/track/Row'
|
||||||
import Modal from '@/components/semantic/Modal'
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -95,8 +71,7 @@ export default {
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
Modal,
|
Modal,
|
||||||
TrackFavoriteIcon,
|
TrackRow
|
||||||
PlayButton
|
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<div @click="showModal = true" :class="['ui', color, {disabled: disabled}, 'button']" :disabled="disabled">
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
|
<modal class="small" :show.sync="showModal">
|
||||||
|
<div class="header">
|
||||||
|
<slot name="modal-header">Do you want to confirm this action?</slot>
|
||||||
|
</div>
|
||||||
|
<div class="scrolling content">
|
||||||
|
<div class="description">
|
||||||
|
<slot name="modal-content"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="ui cancel button">Cancel</div>
|
||||||
|
<div :class="['ui', 'confirm', color, 'button']" @click="confirm">
|
||||||
|
<slot name="modal-confirm">Confirm</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
action: {type: Function, required: true},
|
||||||
|
disabled: {type: Boolean, default: false},
|
||||||
|
color: {type: String, default: 'red'}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Modal
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showModal: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
confirm () {
|
||||||
|
this.showModal = false
|
||||||
|
this.action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -8,4 +8,8 @@ import Username from '@/components/common/Username'
|
||||||
|
|
||||||
Vue.component('username', Username)
|
Vue.component('username', Username)
|
||||||
|
|
||||||
|
import DangerousButton from '@/components/common/DangerousButton'
|
||||||
|
|
||||||
|
Vue.component('dangerous-button', DangerousButton)
|
||||||
|
|
||||||
export default {}
|
export default {}
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<h2>Albums by this artist</h2>
|
<h2>Albums by this artist</h2>
|
||||||
<div class="ui stackable doubling three column grid">
|
<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>
|
<album-card :mode="'rich'" class="fluid" :album="album"></album-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -41,6 +41,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import _ from 'lodash'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
import backend from '@/audio/backend'
|
import backend from '@/audio/backend'
|
||||||
|
@ -83,6 +84,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
sortedAlbums () {
|
||||||
|
let a = this.albums || []
|
||||||
|
return _.orderBy(a, ['release_date'], ['asc'])
|
||||||
|
},
|
||||||
totalTracks () {
|
totalTracks () {
|
||||||
return this.albums.map((album) => {
|
return this.albums.map((album) => {
|
||||||
return album.tracks.length
|
return album.tracks.length
|
||||||
|
|
|
@ -129,7 +129,8 @@ export default {
|
||||||
page: this.page,
|
page: this.page,
|
||||||
page_size: this.paginateBy,
|
page_size: this.paginateBy,
|
||||||
name__icontains: this.query,
|
name__icontains: this.query,
|
||||||
ordering: this.getOrderingAsString()
|
ordering: this.getOrderingAsString(),
|
||||||
|
listenable: 'true'
|
||||||
}
|
}
|
||||||
logger.default.debug('Fetching artists')
|
logger.default.debug('Fetching artists')
|
||||||
axios.get(url, {params: params}).then((response) => {
|
axios.get(url, {params: params}).then((response) => {
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h2 class="ui header">Music requests</h2>
|
<h2 class="ui header">Music requests</h2>
|
||||||
<request-form></request-form>
|
<request-form v-if="$store.state.auth.authenticated"></request-form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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" exact>Browse</router-link>
|
||||||
<router-link class="ui item" to="/library/artists" exact>Artists</router-link>
|
<router-link class="ui item" to="/library/artists" exact>Artists</router-link>
|
||||||
<router-link class="ui item" to="/library/radios" exact>Radios</router-link>
|
<router-link class="ui item" to="/library/radios" exact>Radios</router-link>
|
||||||
|
<router-link class="ui item" to="/library/playlists" exact>Playlists</router-link>
|
||||||
<div class="ui secondary right menu">
|
<div class="ui secondary right menu">
|
||||||
<router-link class="ui item" to="/library/requests/" exact>
|
<router-link v-if="$store.state.auth.authenticated" class="ui item" to="/library/requests/" exact>
|
||||||
Requests
|
Requests
|
||||||
<div class="ui teal label">{{ requestsCount }}</div>
|
<div class="ui teal label">{{ requestsCount }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
@ -32,8 +33,11 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
fetchRequestsCount () {
|
fetchRequestsCount () {
|
||||||
|
if (!this.$store.state.authenticated) {
|
||||||
|
return
|
||||||
|
}
|
||||||
let self = this
|
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
|
self.requestsCount = response.data.count
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,11 @@
|
||||||
|
|
||||||
<play-button class="orange" :track="track">Play</play-button>
|
<play-button class="orange" :track="track">Play</play-button>
|
||||||
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
|
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
|
||||||
|
<track-playlist-icon
|
||||||
|
:button="true"
|
||||||
|
v-if="$store.state.auth.authenticated"
|
||||||
|
:track="track"></track-playlist-icon>
|
||||||
|
|
||||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||||
<i class="wikipedia icon"></i>
|
<i class="wikipedia icon"></i>
|
||||||
Search on wikipedia
|
Search on wikipedia
|
||||||
|
@ -66,6 +71,7 @@ import logger from '@/logging'
|
||||||
import backend from '@/audio/backend'
|
import backend from '@/audio/backend'
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||||
|
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
||||||
|
|
||||||
const FETCH_URL = 'tracks/'
|
const FETCH_URL = 'tracks/'
|
||||||
|
|
||||||
|
@ -73,6 +79,7 @@ export default {
|
||||||
props: ['id'],
|
props: ['id'],
|
||||||
components: {
|
components: {
|
||||||
PlayButton,
|
PlayButton,
|
||||||
|
TrackPlaylistIcon,
|
||||||
TrackFavoriteIcon
|
TrackFavoriteIcon
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<div class="ui card">
|
||||||
|
<div class="content">
|
||||||
|
<div class="header">
|
||||||
|
<router-link class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">
|
||||||
|
{{ playlist.name }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<i class="user icon"></i> {{ playlist.user.username }}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<i class="clock icon"></i> Updated <human-date :date="playlist.modification_date"></human-date>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="extra content">
|
||||||
|
<span>
|
||||||
|
<i class="sound icon"></i>
|
||||||
|
{{ playlist.tracks_count }} tracks
|
||||||
|
</span>
|
||||||
|
<play-button class="mini basic orange right floated" :playlist="playlist">Play all</play-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['playlist'],
|
||||||
|
components: {
|
||||||
|
PlayButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,34 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="playlists.length > 0"
|
||||||
|
v-masonry
|
||||||
|
transition-duration="0"
|
||||||
|
item-selector=".column"
|
||||||
|
percent-position="true"
|
||||||
|
stagger="0"
|
||||||
|
class="ui stackable three column doubling grid">
|
||||||
|
<div
|
||||||
|
v-masonry-tile
|
||||||
|
v-for="playlist in playlists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="column">
|
||||||
|
<playlist-card class="fluid" :playlist="playlist"></playlist-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import PlaylistCard from '@/components/playlists/Card'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['playlists'],
|
||||||
|
components: {
|
||||||
|
PlaylistCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,178 @@
|
||||||
|
<template>
|
||||||
|
<div class="ui text container">
|
||||||
|
<playlist-form @updated="$emit('playlist-updated', $event)" :title="false" :playlist="playlist"></playlist-form>
|
||||||
|
<h3 class="ui top attached header">
|
||||||
|
Playlist editor
|
||||||
|
</h3>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<template v-if="status === 'loading'">
|
||||||
|
<div class="ui active tiny inline loader"></div>
|
||||||
|
Syncing changes to server...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="status === 'errored'">
|
||||||
|
<i class="red close icon"></i>
|
||||||
|
An error occured while saving your changes
|
||||||
|
<div v-if="errors.length > 0" class="ui negative message">
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="status === 'saved'">
|
||||||
|
<i class="green check icon"></i> Changes synced with server
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="ui bottom attached segment">
|
||||||
|
<div
|
||||||
|
@click="insertMany(queueTracks)"
|
||||||
|
:disabled="queueTracks.length === 0"
|
||||||
|
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
|
||||||
|
title="Copy tracks from current queue to playlist">
|
||||||
|
<i class="plus icon"></i> Insert from queue ({{ queueTracks.length }} tracks)</div>
|
||||||
|
|
||||||
|
<dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
|
||||||
|
<i class="eraser icon"></i> Clear playlist
|
||||||
|
<p slot="modal-header">Do you want to clear the playlist "{{ playlist.name }}"?</p>
|
||||||
|
<p slot="modal-content">This will remove all tracks from this playlist and cannot be undone.</p>
|
||||||
|
<p slot="modal-confirm">Clear playlist</p>
|
||||||
|
</dangerous-button>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<template v-if="plts.length > 0">
|
||||||
|
<p>Drag and drop rows to reorder tracks in the playlist</p>
|
||||||
|
<table class="ui compact very basic fixed single line unstackable table">
|
||||||
|
<draggable v-model="plts" element="tbody" @update="reorder">
|
||||||
|
<tr v-for="(plt, index) in plts" :key="plt.id">
|
||||||
|
<td class="left aligned">{{ plt.index + 1}}</td>
|
||||||
|
<td class="center aligned">
|
||||||
|
<img class="ui mini image" v-if="plt.track.album.cover" :src="plt.track.album.cover">
|
||||||
|
<img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
|
||||||
|
</td>
|
||||||
|
<td colspan="4">
|
||||||
|
<strong>{{ plt.track.title }}</strong><br />
|
||||||
|
{{ plt.track.artist.name }}
|
||||||
|
</td>
|
||||||
|
<td class="right aligned">
|
||||||
|
<i @click.stop="removePlt(index)" class="circular red trash icon"></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</draggable>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {mapState} from 'vuex'
|
||||||
|
import axios from 'axios'
|
||||||
|
import PlaylistForm from '@/components/playlists/Form'
|
||||||
|
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
draggable,
|
||||||
|
PlaylistForm
|
||||||
|
},
|
||||||
|
props: ['playlist', 'playlistTracks'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
plts: this.playlistTracks,
|
||||||
|
isLoading: false,
|
||||||
|
errors: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
success () {
|
||||||
|
this.isLoading = false
|
||||||
|
this.errors = []
|
||||||
|
},
|
||||||
|
errored (errors) {
|
||||||
|
this.isLoading = false
|
||||||
|
this.errors = errors
|
||||||
|
},
|
||||||
|
reorder ({oldIndex, newIndex}) {
|
||||||
|
let self = this
|
||||||
|
self.isLoading = true
|
||||||
|
let plt = this.plts[newIndex]
|
||||||
|
let url = 'playlist-tracks/' + plt.id + '/'
|
||||||
|
axios.patch(url, {index: newIndex}).then((response) => {
|
||||||
|
self.success()
|
||||||
|
}, error => {
|
||||||
|
self.errored(error.backendErrors)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removePlt (index) {
|
||||||
|
let plt = this.plts[index]
|
||||||
|
this.plts.splice(index, 1)
|
||||||
|
let self = this
|
||||||
|
self.isLoading = true
|
||||||
|
let url = 'playlist-tracks/' + plt.id + '/'
|
||||||
|
axios.delete(url).then((response) => {
|
||||||
|
self.success()
|
||||||
|
self.$store.dispatch('playlists/fetchOwn')
|
||||||
|
}, error => {
|
||||||
|
self.errored(error.backendErrors)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clearPlaylist () {
|
||||||
|
this.plts = []
|
||||||
|
let self = this
|
||||||
|
self.isLoading = true
|
||||||
|
let url = 'playlists/' + this.playlist.id + '/clear'
|
||||||
|
axios.delete(url).then((response) => {
|
||||||
|
self.success()
|
||||||
|
self.$store.dispatch('playlists/fetchOwn')
|
||||||
|
}, error => {
|
||||||
|
self.errored(error.backendErrors)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
insertMany (tracks) {
|
||||||
|
let self = this
|
||||||
|
let ids = tracks.map(t => {
|
||||||
|
return t.id
|
||||||
|
})
|
||||||
|
self.isLoading = true
|
||||||
|
let url = 'playlists/' + this.playlist.id + '/add/'
|
||||||
|
axios.post(url, {tracks: ids}).then((response) => {
|
||||||
|
response.data.results.forEach(r => {
|
||||||
|
self.plts.push(r)
|
||||||
|
})
|
||||||
|
self.success()
|
||||||
|
self.$store.dispatch('playlists/fetchOwn')
|
||||||
|
}, error => {
|
||||||
|
self.errored(error.backendErrors)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
queueTracks: state => state.queue.tracks
|
||||||
|
}),
|
||||||
|
status () {
|
||||||
|
if (this.isLoading) {
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
if (this.errors.length > 0) {
|
||||||
|
return 'errored'
|
||||||
|
}
|
||||||
|
return 'saved'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
plts: {
|
||||||
|
handler (newValue) {
|
||||||
|
newValue.forEach((e, i) => {
|
||||||
|
e.index = i
|
||||||
|
})
|
||||||
|
this.$emit('tracks-updated', newValue)
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,125 @@
|
||||||
|
<template>
|
||||||
|
<form class="ui form" @submit.prevent="submit()">
|
||||||
|
<h4 v-if="title" class="ui header">Create a new playlist</h4>
|
||||||
|
<div v-if="success" class="ui positive message">
|
||||||
|
<div class="header">
|
||||||
|
<template v-if="playlist">
|
||||||
|
Playlist updated
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Playlist created
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="errors.length > 0" class="ui negative message">
|
||||||
|
<div class="header">We cannot create the playlist</div>
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="three fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Playlist name</label>
|
||||||
|
<input v-model="name" required type="text" placeholder="My awesome playlist" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Playlist visibility</label>
|
||||||
|
<select class="ui dropdown" v-model="privacyLevel">
|
||||||
|
<option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label> </label>
|
||||||
|
<button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit">
|
||||||
|
<template v-if="playlist">Update playlist</template>
|
||||||
|
<template v-else>Create playlist</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import $ from 'jquery'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import logger from '@/logging'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
title: {type: Boolean, default: true},
|
||||||
|
playlist: {type: Object, default: null}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
$(this.$el).find('.dropdown').dropdown()
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
let d = {
|
||||||
|
errors: [],
|
||||||
|
success: false,
|
||||||
|
isLoading: false,
|
||||||
|
privacyLevelChoices: [
|
||||||
|
{
|
||||||
|
value: 'me',
|
||||||
|
label: 'Nobody except me'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'instance',
|
||||||
|
label: 'Everyone on this instance'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'everyone',
|
||||||
|
label: 'Everyone'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (this.playlist) {
|
||||||
|
d.name = this.playlist.name
|
||||||
|
d.privacyLevel = this.playlist.privacy_level
|
||||||
|
} else {
|
||||||
|
d.privacyLevel = this.$store.state.auth.profile.privacy_level
|
||||||
|
d.name = ''
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submit () {
|
||||||
|
this.isLoading = true
|
||||||
|
this.success = false
|
||||||
|
this.errors = []
|
||||||
|
let self = this
|
||||||
|
let payload = {
|
||||||
|
name: this.name,
|
||||||
|
privacy_level: this.privacyLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
let promise
|
||||||
|
let url
|
||||||
|
if (this.playlist) {
|
||||||
|
url = `playlists/${this.playlist.id}/`
|
||||||
|
promise = axios.patch(url, payload)
|
||||||
|
} else {
|
||||||
|
url = 'playlists/'
|
||||||
|
promise = axios.post(url, payload)
|
||||||
|
}
|
||||||
|
return promise.then(response => {
|
||||||
|
self.success = true
|
||||||
|
self.isLoading = false
|
||||||
|
if (!self.playlist) {
|
||||||
|
self.name = ''
|
||||||
|
}
|
||||||
|
self.$emit('updated', response.data)
|
||||||
|
self.$store.dispatch('playlists/fetchOwn')
|
||||||
|
}, error => {
|
||||||
|
logger.default.error('Error while creating playlist')
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,127 @@
|
||||||
|
<template>
|
||||||
|
<modal @update:show="update" :show="$store.state.playlists.showModal">
|
||||||
|
<div class="header">
|
||||||
|
Manage playlists
|
||||||
|
</div>
|
||||||
|
<div class="scrolling content">
|
||||||
|
<div class="description">
|
||||||
|
<template v-if="track">
|
||||||
|
<h4 class="ui header">Current track</h4>
|
||||||
|
<div>
|
||||||
|
"{{ track.title }}" by {{ track.artist.name }}
|
||||||
|
</div>
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<playlist-form></playlist-form>
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
<div v-if="errors.length > 0" class="ui negative message">
|
||||||
|
<div class="header">We cannot add the track to a playlist</div>
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 class="ui header">Available playlists</h4>
|
||||||
|
<table class="ui unstackable very basic table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="sorted descending">Last modification</th>
|
||||||
|
<th>Tracks</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="playlist in sortedPlaylists">
|
||||||
|
<td>
|
||||||
|
<router-link
|
||||||
|
class="ui icon basic small button"
|
||||||
|
:to="{name: 'library.playlists.detail', params: {id: playlist.id }, query: {mode: 'edit'}}"><i class="ui pencil icon"></i></router-link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<router-link :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">{{ playlist.name }}</router-link></td>
|
||||||
|
<td><human-date :date="playlist.modification_date"></human-date></td>
|
||||||
|
<td>{{ playlist.tracks_count }}</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
v-if="track"
|
||||||
|
class="ui green icon basic small right floated button"
|
||||||
|
title="Add to this playlist"
|
||||||
|
@click="addToPlaylist(playlist.id)">
|
||||||
|
<i class="plus icon"></i> Add track
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="ui cancel button">Cancel</div>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import _ from 'lodash'
|
||||||
|
import axios from 'axios'
|
||||||
|
import {mapState} from 'vuex'
|
||||||
|
|
||||||
|
import logger from '@/logging'
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
import PlaylistForm from '@/components/playlists/Form'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Modal,
|
||||||
|
PlaylistForm
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
errors: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
update (v) {
|
||||||
|
this.$store.commit('playlists/showModal', v)
|
||||||
|
},
|
||||||
|
addToPlaylist (playlistId) {
|
||||||
|
let self = this
|
||||||
|
let payload = {
|
||||||
|
track: this.track.id,
|
||||||
|
playlist: playlistId
|
||||||
|
}
|
||||||
|
return axios.post('playlist-tracks/', payload).then(response => {
|
||||||
|
logger.default.info('Successfully added track to playlist')
|
||||||
|
self.update(false)
|
||||||
|
self.$store.dispatch('playlists/fetchOwn')
|
||||||
|
}, error => {
|
||||||
|
logger.default.error('Error while adding track to playlist')
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
playlists: state => state.playlists.playlists,
|
||||||
|
track: state => state.playlists.modalTrack
|
||||||
|
}),
|
||||||
|
sortedPlaylists () {
|
||||||
|
let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }])
|
||||||
|
p.reverse()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$store.state.route.path' () {
|
||||||
|
this.$store.commit('playlists/showModal', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,34 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
@click="$store.commit('playlists/chooseTrack', track)"
|
||||||
|
v-if="button"
|
||||||
|
:class="['ui', 'button']">
|
||||||
|
<i class="list icon"></i>
|
||||||
|
Add to playlist...
|
||||||
|
</button>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
@click="$store.commit('playlists/chooseTrack', track)"
|
||||||
|
:class="['playlist-icon', 'list', 'link', 'icon']"
|
||||||
|
title="Add to playlist...">
|
||||||
|
</i>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
track: {type: Object},
|
||||||
|
button: {type: Boolean, default: false}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showModal: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -31,7 +31,7 @@ export default {
|
||||||
if (!state.running) {
|
if (!state.running) {
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
return current.type === this.type & current.objectId === this.objectId
|
return current.type === this.type && current.objectId === this.objectId && current.customRadioId === this.customRadioId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,8 +19,15 @@ export default {
|
||||||
control: null
|
control: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
beforeDestroy () {
|
||||||
|
if (this.control) {
|
||||||
|
this.control.remove()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initModal () {
|
||||||
this.control = $(this.$el).modal({
|
this.control = $(this.$el).modal({
|
||||||
|
duration: 100,
|
||||||
onApprove: function () {
|
onApprove: function () {
|
||||||
this.$emit('approved')
|
this.$emit('approved')
|
||||||
}.bind(this),
|
}.bind(this),
|
||||||
|
@ -31,14 +38,19 @@ export default {
|
||||||
this.$emit('update:show', false)
|
this.$emit('update:show', false)
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show: {
|
show: {
|
||||||
handler (newValue) {
|
handler (newValue) {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
|
this.initModal()
|
||||||
this.control.modal('show')
|
this.control.modal('show')
|
||||||
} else {
|
} else {
|
||||||
|
if (this.control) {
|
||||||
this.control.modal('hide')
|
this.control.modal('hide')
|
||||||
|
this.control.remove()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,12 @@ export function momentFormat (date, format) {
|
||||||
|
|
||||||
Vue.filter('moment', momentFormat)
|
Vue.filter('moment', momentFormat)
|
||||||
|
|
||||||
|
export function year (date) {
|
||||||
|
return moment(date).year()
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.filter('year', year)
|
||||||
|
|
||||||
export function capitalize (str) {
|
export function capitalize (str) {
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
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 BatchList from '@/components/library/import/BatchList'
|
||||||
import BatchDetail from '@/components/library/import/BatchDetail'
|
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||||
import RequestsList from '@/components/requests/RequestsList'
|
import RequestsList from '@/components/requests/RequestsList'
|
||||||
|
import PlaylistDetail from '@/views/playlists/Detail'
|
||||||
|
import PlaylistList from '@/views/playlists/List'
|
||||||
import Favorites from '@/components/favorites/List'
|
import Favorites from '@/components/favorites/List'
|
||||||
|
|
||||||
Vue.use(Router)
|
Vue.use(Router)
|
||||||
|
@ -110,6 +111,25 @@ export default new Router({
|
||||||
},
|
},
|
||||||
{ path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true },
|
{ path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true },
|
||||||
{ path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true },
|
{ path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true },
|
||||||
|
{
|
||||||
|
path: 'playlists/',
|
||||||
|
name: 'library.playlists.browse',
|
||||||
|
component: PlaylistList,
|
||||||
|
props: (route) => ({
|
||||||
|
defaultOrdering: route.query.ordering,
|
||||||
|
defaultQuery: route.query.query,
|
||||||
|
defaultPaginateBy: route.query.paginateBy,
|
||||||
|
defaultPage: route.query.page
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'playlists/:id',
|
||||||
|
name: 'library.playlists.detail',
|
||||||
|
component: PlaylistDetail,
|
||||||
|
props: (route) => ({
|
||||||
|
id: route.params.id,
|
||||||
|
defaultEdit: route.query.mode === 'edit' })
|
||||||
|
},
|
||||||
{ path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
|
{ path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
|
||||||
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
|
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
|
||||||
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
|
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
|
||||||
|
|
|
@ -91,6 +91,7 @@ export default {
|
||||||
commit('profile', data)
|
commit('profile', data)
|
||||||
commit('username', data.username)
|
commit('username', data.username)
|
||||||
dispatch('favorites/fetch', null, {root: true})
|
dispatch('favorites/fetch', null, {root: true})
|
||||||
|
dispatch('playlists/fetchOwn', null, {root: true})
|
||||||
Object.keys(data.permissions).forEach(function (key) {
|
Object.keys(data.permissions).forEach(function (key) {
|
||||||
// this makes it easier to check for permissions in templates
|
// this makes it easier to check for permissions in templates
|
||||||
commit('permission', {key, status: data.permissions[String(key)].status})
|
commit('permission', {key, status: data.permissions[String(key)].status})
|
||||||
|
|
|
@ -8,6 +8,7 @@ import instance from './instance'
|
||||||
import queue from './queue'
|
import queue from './queue'
|
||||||
import radios from './radios'
|
import radios from './radios'
|
||||||
import player from './player'
|
import player from './player'
|
||||||
|
import playlists from './playlists'
|
||||||
import ui from './ui'
|
import ui from './ui'
|
||||||
|
|
||||||
Vue.use(Vuex)
|
Vue.use(Vuex)
|
||||||
|
@ -20,6 +21,7 @@ export default new Vuex.Store({
|
||||||
instance,
|
instance,
|
||||||
queue,
|
queue,
|
||||||
radios,
|
radios,
|
||||||
|
playlists,
|
||||||
player
|
player
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
playlists: [],
|
||||||
|
showModal: false,
|
||||||
|
modalTrack: null
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
playlists (state, value) {
|
||||||
|
state.playlists = value
|
||||||
|
},
|
||||||
|
chooseTrack (state, value) {
|
||||||
|
state.showModal = true
|
||||||
|
state.modalTrack = value
|
||||||
|
},
|
||||||
|
showModal (state, value) {
|
||||||
|
state.showModal = value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
fetchOwn ({commit, rootState}) {
|
||||||
|
let userId = rootState.auth.profile.id
|
||||||
|
if (!userId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return axios.get('playlists/', {params: {user: userId}}).then((response) => {
|
||||||
|
commit('playlists', response.data.results)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment">
|
||||||
|
<div class="segment-content">
|
||||||
|
<h2 class="ui center aligned icon header">
|
||||||
|
<i class="circular inverted list yellow icon"></i>
|
||||||
|
<div class="content">
|
||||||
|
{{ playlist.name }}
|
||||||
|
<div class="sub header">
|
||||||
|
Playlist containing {{ playlistTracks.length }} tracks,
|
||||||
|
by <username :username="playlist.user.username"></username>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
</button>
|
||||||
|
<play-button class="orange" :tracks="tracks">Play all</play-button>
|
||||||
|
<button
|
||||||
|
class="ui icon button"
|
||||||
|
v-if="playlist.user.id === $store.state.auth.profile.id"
|
||||||
|
@click="edit = !edit">
|
||||||
|
<i class="pencil icon"></i>
|
||||||
|
<template v-if="edit">End edition</template>
|
||||||
|
<template v-else>Edit...</template>
|
||||||
|
</button>
|
||||||
|
<dangerous-button class="labeled icon" :action="deletePlaylist">
|
||||||
|
<i class="trash icon"></i> Delete
|
||||||
|
<p slot="modal-header">Do you want to delete the playlist "{{ playlist.name }}"?</p>
|
||||||
|
<p slot="modal-content">This will completely delete this playlist and cannot be undone.</p>
|
||||||
|
<p slot="modal-confirm">Delete playlist</p>
|
||||||
|
</dangerous-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<template v-if="edit">
|
||||||
|
<playlist-editor
|
||||||
|
@playlist-updated="playlist = $event"
|
||||||
|
@tracks-updated="updatePlts"
|
||||||
|
:playlist="playlist" :playlist-tracks="playlistTracks"></playlist-editor>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2>Tracks</h2>
|
||||||
|
<track-table :display-position="true" :tracks="tracks"></track-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import TrackTable from '@/components/audio/track/Table'
|
||||||
|
import RadioButton from '@/components/radios/Button'
|
||||||
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
|
import PlaylistEditor from '@/components/playlists/Editor'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
id: {required: true},
|
||||||
|
defaultEdit: {type: Boolean, default: false}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
PlaylistEditor,
|
||||||
|
TrackTable,
|
||||||
|
PlayButton,
|
||||||
|
RadioButton
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
edit: this.defaultEdit,
|
||||||
|
isLoading: false,
|
||||||
|
playlist: null,
|
||||||
|
tracks: [],
|
||||||
|
playlistTracks: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.fetch()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updatePlts (v) {
|
||||||
|
this.playlistTracks = v
|
||||||
|
this.tracks = v.map((e, i) => {
|
||||||
|
let track = e.track
|
||||||
|
track.position = i + 1
|
||||||
|
return track
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fetch: function () {
|
||||||
|
let self = this
|
||||||
|
self.isLoading = true
|
||||||
|
let url = 'playlists/' + this.id + '/'
|
||||||
|
axios.get(url).then((response) => {
|
||||||
|
self.playlist = response.data
|
||||||
|
axios.get(url + 'tracks').then((response) => {
|
||||||
|
self.updatePlts(response.data.results)
|
||||||
|
}).then(() => {
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deletePlaylist () {
|
||||||
|
let self = this
|
||||||
|
let url = 'playlists/' + this.id + '/'
|
||||||
|
axios.delete(url).then((response) => {
|
||||||
|
self.$store.dispatch('playlists/fetchOwn')
|
||||||
|
self.$router.push({
|
||||||
|
path: '/library'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,158 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ui vertical stripe segment">
|
||||||
|
<h2 class="ui header">Browsing playlists</h2>
|
||||||
|
<div :class="['ui', {'loading': isLoading}, 'form']">
|
||||||
|
<template v-if="$store.state.auth.authenticated">
|
||||||
|
<button
|
||||||
|
@click="$store.commit('playlists/chooseTrack', null)"
|
||||||
|
class="ui basic green button">Manage your playlists</button>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
</template>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>Search</label>
|
||||||
|
<input type="text" v-model="query" placeholder="Enter an playlist name..."/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Ordering</label>
|
||||||
|
<select class="ui dropdown" v-model="ordering">
|
||||||
|
<option v-for="option in orderingOptions" :value="option[0]">
|
||||||
|
{{ option[1] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Ordering direction</label>
|
||||||
|
<select class="ui dropdown" v-model="orderingDirection">
|
||||||
|
<option value="">Ascending</option>
|
||||||
|
<option value="-">Descending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Results per page</label>
|
||||||
|
<select class="ui dropdown" v-model="paginateBy">
|
||||||
|
<option :value="parseInt(12)">12</option>
|
||||||
|
<option :value="parseInt(25)">25</option>
|
||||||
|
<option :value="parseInt(50)">50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<playlist-card-list v-if="result" :playlists="result.results"></playlist-card-list>
|
||||||
|
<div class="ui center aligned basic segment">
|
||||||
|
<pagination
|
||||||
|
v-if="result && result.results.length > 0"
|
||||||
|
@page-changed="selectPage"
|
||||||
|
:current="page"
|
||||||
|
:paginate-by="paginateBy"
|
||||||
|
:total="result.count"
|
||||||
|
></pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import $ from 'jquery'
|
||||||
|
|
||||||
|
import OrderingMixin from '@/components/mixins/Ordering'
|
||||||
|
import PaginationMixin from '@/components/mixins/Pagination'
|
||||||
|
import PlaylistCardList from '@/components/playlists/CardList'
|
||||||
|
import Pagination from '@/components/Pagination'
|
||||||
|
|
||||||
|
const FETCH_URL = 'playlists/'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [OrderingMixin, PaginationMixin],
|
||||||
|
props: {
|
||||||
|
defaultQuery: {type: String, required: false, default: ''}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
PlaylistCardList,
|
||||||
|
Pagination
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||||
|
return {
|
||||||
|
isLoading: true,
|
||||||
|
result: null,
|
||||||
|
page: parseInt(this.defaultPage),
|
||||||
|
query: this.defaultQuery,
|
||||||
|
paginateBy: parseInt(this.defaultPaginateBy || 12),
|
||||||
|
orderingDirection: defaultOrdering.direction,
|
||||||
|
ordering: defaultOrdering.field,
|
||||||
|
orderingOptions: [
|
||||||
|
['creation_date', 'Creation date'],
|
||||||
|
['modification_date', 'Last modification date'],
|
||||||
|
['name', 'Name']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
$('.ui.dropdown').dropdown()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateQueryString: _.debounce(function () {
|
||||||
|
this.$router.replace({
|
||||||
|
query: {
|
||||||
|
query: this.query,
|
||||||
|
page: this.page,
|
||||||
|
paginateBy: this.paginateBy,
|
||||||
|
ordering: this.getOrderingAsString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 500),
|
||||||
|
fetchData: _.debounce(function () {
|
||||||
|
var self = this
|
||||||
|
this.isLoading = true
|
||||||
|
let url = FETCH_URL
|
||||||
|
let params = {
|
||||||
|
page: this.page,
|
||||||
|
page_size: this.paginateBy,
|
||||||
|
q: this.query,
|
||||||
|
ordering: this.getOrderingAsString()
|
||||||
|
}
|
||||||
|
axios.get(url, {params: params}).then((response) => {
|
||||||
|
self.result = response.data
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
}, 500),
|
||||||
|
selectPage: function (page) {
|
||||||
|
this.page = page
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
page () {
|
||||||
|
this.updateQueryString()
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
paginateBy () {
|
||||||
|
this.updateQueryString()
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
ordering () {
|
||||||
|
this.updateQueryString()
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
orderingDirection () {
|
||||||
|
this.updateQueryString()
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
query () {
|
||||||
|
this.updateQueryString()
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -1,4 +1,4 @@
|
||||||
import {truncate, markdown, ago, capitalize} from '@/filters'
|
import {truncate, markdown, ago, capitalize, year} from '@/filters'
|
||||||
|
|
||||||
describe('filters', () => {
|
describe('filters', () => {
|
||||||
describe('truncate', () => {
|
describe('truncate', () => {
|
||||||
|
@ -32,6 +32,13 @@ describe('filters', () => {
|
||||||
expect(output).to.equal('a few seconds ago')
|
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', () => {
|
describe('capitalize', () => {
|
||||||
it('works', () => {
|
it('works', () => {
|
||||||
const input = 'hello world'
|
const input = 'hello world'
|
||||||
|
|
|
@ -180,7 +180,8 @@ describe('store/auth', () => {
|
||||||
{ type: 'permission', payload: {key: 'admin', status: true} }
|
{ type: 'permission', payload: {key: 'admin', status: true} }
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
{ type: 'favorites/fetch', payload: null, options: {root: true} }
|
{ type: 'favorites/fetch', payload: null, options: {root: true} },
|
||||||
|
{ type: 'playlists/fetchOwn', payload: null, options: {root: true} },
|
||||||
]
|
]
|
||||||
}, done)
|
}, done)
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
var sinon = require('sinon')
|
||||||
|
import moxios from 'moxios'
|
||||||
|
import store from '@/store/playlists'
|
||||||
|
|
||||||
|
import { testAction } from '../../utils'
|
||||||
|
|
||||||
|
describe('store/playlists', () => {
|
||||||
|
var sandbox
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
sandbox = sinon.sandbox.create()
|
||||||
|
moxios.install()
|
||||||
|
})
|
||||||
|
afterEach(function () {
|
||||||
|
sandbox.restore()
|
||||||
|
moxios.uninstall()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mutations', () => {
|
||||||
|
it('set playlists', () => {
|
||||||
|
const state = { playlists: [] }
|
||||||
|
store.mutations.playlists(state, [{id: 1, name: 'test'}])
|
||||||
|
expect(state.playlists).to.deep.equal([{id: 1, name: 'test'}])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('actions', () => {
|
||||||
|
it('fetchOwn does nothing with no user', (done) => {
|
||||||
|
testAction({
|
||||||
|
action: store.actions.fetchOwn,
|
||||||
|
payload: null,
|
||||||
|
params: {state: { playlists: [] }, rootState: {auth: {profile: {}}}},
|
||||||
|
expectedMutations: []
|
||||||
|
}, done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue