diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e4accd722d66823a067fe8f2c1212746bc18b076..38492f61454b3e4503bbe98b7b284c0182d0a1d0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,13 +16,15 @@ test_api: stage: test image: funkwhale/funkwhale:latest cache: - key: "$CI_PROJECT_ID/pip_cache" + key: "$CI_PROJECT_ID__pip_cache" paths: - "$PIP_CACHE_DIR" variables: DJANGO_ALLOWED_HOSTS: "localhost" DATABASE_URL: "postgresql://postgres@postgres/postgres" FUNKWHALE_URL: "https://funkwhale.ci" + CACHEOPS_ENABLED: "false" + before_script: - cd api - pip install -r requirements/base.txt @@ -44,7 +46,7 @@ test_front: - yarn install - yarn run unit cache: - key: "$CI_PROJECT_ID/front_dependencies" + key: "$CI_PROJECT_ID__front_dependencies" paths: - front/node_modules - front/yarn.lock @@ -66,7 +68,7 @@ build_front: - yarn install - yarn run build cache: - key: "$CI_PROJECT_ID/front_dependencies" + key: "$CI_PROJECT_ID__front_dependencies" paths: - front/node_modules - front/yarn.lock @@ -84,15 +86,12 @@ build_front: pages: stage: test - image: alpine + image: python:3.6-alpine before_script: - cd docs script: - - apk --no-cache add py2-pip python-dev - pip install sphinx - - apk --no-cache add make - - make html - - mv _build/html/ ../public + - python -m sphinx . ../public artifacts: paths: - public diff --git a/CHANGELOG b/CHANGELOG index 6881b7bf0fbd38b8fc2073bd298cfa7e4d0ec97c..0b91987235f620475b00f26af87787c43670913b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,7 +3,37 @@ Changelog .. towncrier -0.6.1 (unreleased) +0.7 (2018-03-21) +---------------- + +Features: + +- Can now filter artists and albums with no listenable tracks (#114) +- Improve the style of the sidebar to make it easier to understand which tab is + selected (#118) +- On artist page, albums are not sorted by release date, if any (#116) +- Playlists are here \o/ :tada: (#3, #93, #94) +- Use django-cacheops to cache common ORM requests (#117) + + +Bugfixes: + +- Fixed broken import request admin (#115) +- Fixed forced redirection to login event with + API_AUTHENTICATION_REQUIRED=False (#119) +- Fixed position not being reseted properly when playing the same track + multiple times in a row +- Fixed synchronized start/stop radio buttons for all custom radios (#103) +- Fixed typo and missing icon on homepage (#96) + + +Documentation: + +- Up-to-date and complete development and contribution instructions in + README.rst (#123) + + +0.6.1 (2018-03-06) ------------------ Features: diff --git a/README.rst b/README.rst index 1cec0a6b70b2962c75e19e492d3300f0db7ea498..93281d26fb6abd65d0590183a416236a585b68e9 100644 --- a/README.rst +++ b/README.rst @@ -5,27 +5,107 @@ A self-hosted tribute to Grooveshark.com. LICENSE: BSD -Setting up a development environment (docker) ----------------------------------------------- +Getting help +------------ -First of all, pull the repository. +We offer various Matrix.org rooms to discuss about funkwhale: -Then, pull and build all the containers:: +- `#funkwhale:matrix.org <https://riot.im/app/#/room/#funkwhale:matrix.org>`_ for general questions about funkwhale +- `#funkwhale-dev:matrix.org <https://riot.im/app/#/room/#funkwhale-dev:matrix.org>`_ for development-focused discussion + +Please join those rooms if you have any questions! + +Running the development version +------------------------------- + +If you want to fix a bug or implement a feature, you'll need +to run a local, development copy of funkwhale. + +We provide a docker based development environment, which should +be both easy to setup and work similarly regardless of your +development machine setup. + +Instructions for bare-metal setup will come in the future (Merge requests +are welcome). + +Installing docker and docker-compose +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This is already cover in the relevant documentations: + +- https://docs.docker.com/install/ +- https://docs.docker.com/compose/install/ + +Cloning the project +^^^^^^^^^^^^^^^^^^^ + +Visit https://code.eliotberriot.com/funkwhale/funkwhale and clone the repository using SSH or HTTPS. Exemple using SSH:: + + git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git + cd funkwhale + + +A note about branches +^^^^^^^^^^^^^^^^^^^^^ + +Next release development occurs on the "develop" branch, and releases are made on the "master" branch. Therefor, when submitting Merge Requests, ensure you are merging on the develop branch. + + +Working with docker +^^^^^^^^^^^^^^^^^^^ + +In developpement, we use the docker-compose file named ``dev.yml``, and this is why all our docker-compose commands will look like this:: + + docker-compose -f dev.yml logs + +If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this command before starting your work:: + + export COMPOSE_FILE=dev.yml + + +Building the containers +^^^^^^^^^^^^^^^^^^^^^^^ + +On your initial clone, or if there have been some changes in the +app dependencies, you will have to rebuild your containers. This is done +via the following command:: docker-compose -f dev.yml build - docker-compose -f dev.yml pull -API setup -^^^^^^^^^^ +Database management +^^^^^^^^^^^^^^^^^^^ + +To setup funkwhale's database schema, run this:: + + docker-compose -f dev.yml run --rm api python manage.py migrate + +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. + +It is safe to run this command multiple times, so you can run it whenever +you fetch develop. + -You'll have apply database migrations:: +Development data +^^^^^^^^^^^^^^^^ - docker-compose -f dev.yml run celeryworker python manage.py migrate +You'll need at least an admin user and some artists/tracks/albums to work +locally. -And to create an admin user:: +Create an admin user with the following command:: - docker-compose -f dev.yml run celeryworker python manage.py createsuperuser + docker-compose -f dev.yml run --rm api python manage.py createsuperuser + +Injecting fake data is done by running the fllowing script:: + + artists=25 + command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)" + echo $command | docker-compose -f dev.yml run --rm api python manage.py shell -i python + +The previous command will create 25 artists with random albums, tracks +and metadata. Launch all services @@ -33,18 +113,83 @@ Launch all services Then you can run everything with:: - docker-compose up + docker-compose -f dev.yml up + +This will launch all services, and output the logs in your current terminal window. +If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``. + +Once everything is up, you can access the various funkwhale's components: + +- The Vue webapp, on http://localhost:8080 +- The API, on http://localhost:8080/api/v1/ +- The django admin, on http://localhost:8080/api/admin/ -The API server will be accessible at http://localhost:6001, and the front-end at http://localhost:8080. Running API tests ------------------- +^^^^^^^^^^^^^^^^^ + +To run the pytest test suite, use the following command:: + + docker-compose -f dev.yml run --rm api pytest + +This is regular pytest, so you can use any arguments/options that pytest usually accept:: + + # 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. -Everything is managed using docker and docker-compose, just run:: +You can keep your data by removing the ``-v`` flag. - ./api/runtests -This bash script invoke `python manage.py test` in a docker container under the hood, so you can use -traditional django test arguments and options, such as:: +Typical workflow for a merge request +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ./api/runtests funkwhale_api.music # run a specific app test +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! diff --git a/api/compose/django/dev-entrypoint.sh b/api/compose/django/dev-entrypoint.sh new file mode 100755 index 0000000000000000000000000000000000000000..416207b43d66d6ec7d05b12dd508c33577b8afda --- /dev/null +++ b/api/compose/django/dev-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e +if [ $1 = "pytest" ]; then + # let pytest.ini handle it + unset DJANGO_SETTINGS_MODULE +fi +exec "$@" diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 1def688241366373a3d495c21358b4bd0a76e7bf..077566d1c6a82e329f334a7fe94764cafbd92a70 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -57,9 +57,9 @@ THIRD_PARTY_APPS = ( 'taggit', 'rest_auth', 'rest_auth.registration', - 'mptt', 'dynamic_preferences', 'django_filters', + 'cacheops', ) @@ -369,7 +369,19 @@ MUSICBRAINZ_CACHE_DURATION = env.int( 'MUSICBRAINZ_CACHE_DURATION', default=300 ) +CACHEOPS_REDIS = env('CACHE_URL', default=CACHE_DEFAULT) +CACHEOPS_ENABLED = env.bool('CACHEOPS_ENABLED', default=True) +CACHEOPS = { + 'music.artist': {'ops': 'all', 'timeout': 60 * 60}, + 'music.album': {'ops': 'all', 'timeout': 60 * 60}, + 'music.track': {'ops': 'all', 'timeout': 60 * 60}, + 'music.trackfile': {'ops': 'all', 'timeout': 60 * 60}, + 'taggit.tag': {'ops': 'all', 'timeout': 60 * 60}, +} # Custom Admin URL, use {% url 'admin:index' %} ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/') CSRF_USE_SESSIONS = True + +# Playlist settings +PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250) diff --git a/api/config/settings/test.py b/api/config/settings/test.py index a0b6b2503a1762295c960cae50c06c65c50a4d76..aff29c6571252d1f0d401c550cedf09998a5c512 100644 --- a/api/config/settings/test.py +++ b/api/config/settings/test.py @@ -19,10 +19,6 @@ CACHES = { CELERY_BROKER_URL = 'memory://' -# TESTING -# ------------------------------------------------------------------------------ -TEST_RUNNER = 'django.test.runner.DiscoverRunner' - ########## CELERY # In development, all tasks will be executed locally by blocking until the task returns CELERY_TASK_ALWAYS_EAGER = True @@ -30,3 +26,4 @@ CELERY_TASK_ALWAYS_EAGER = True # Your local stuff: Below this line define 3rd party library settings API_AUTHENTICATION_REQUIRED = False +CACHEOPS_ENABLED = False diff --git a/api/docker-compose.yml b/api/docker-compose.yml deleted file mode 100644 index 4f79eb301b1241c91573c7e4d34d70686f82e7c2..0000000000000000000000000000000000000000 --- a/api/docker-compose.yml +++ /dev/null @@ -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 diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test index 00638e9dd3cb5d290d336698a842f826d73583e9..0990efa512e6a1084249e331e4146f8a223332f5 100644 --- a/api/docker/Dockerfile.test +++ b/api/docker/Dockerfile.test @@ -23,3 +23,4 @@ RUN pip install -r /requirements/test.txt COPY . /app WORKDIR /app +ENTRYPOINT ["compose/django/dev-entrypoint.sh"] diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index 841f2a299128d2b5f5c6ac52dc87a67fefd0954a..0a12b794efb05892fa6fca0bff82b192e936f108 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = '0.6.1' +__version__ = '0.7' __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py new file mode 100644 index 0000000000000000000000000000000000000000..ef9f840dc763409c8a1555d693d1939030877fd5 --- /dev/null +++ b/api/funkwhale_api/common/fields.py @@ -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' + ] + }) diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py index ecfea4c9a51582031a69c8592d4c2592cb2988e6..c99c275c1f636b292c71ab1abb427daa658566fb 100644 --- a/api/funkwhale_api/common/permissions.py +++ b/api/funkwhale_api/common/permissions.py @@ -1,4 +1,7 @@ +import operator + from django.conf import settings +from django.http import Http404 from rest_framework.permissions import BasePermission, DjangoModelPermissions @@ -20,3 +23,39 @@ class HasModelPermission(DjangoModelPermissions): """ def get_required_permissions(self, method, model_cls): return super().get_required_permissions(method, self.model) + + +class OwnerPermission(BasePermission): + """ + Ensure the request user is the owner of the object. + + Usage: + + class MyView(APIView): + model = MyModel + permission_classes = [OwnerPermission] + owner_field = 'owner' + owner_checks = ['read', 'write'] + """ + perms_map = { + 'GET': 'read', + 'OPTIONS': 'read', + 'HEAD': 'read', + 'POST': 'write', + 'PUT': 'write', + 'PATCH': 'write', + 'DELETE': 'write', + } + + def has_object_permission(self, request, view, obj): + method_check = self.perms_map[request.method] + owner_checks = getattr(view, 'owner_checks', ['read', 'write']) + if method_check not in owner_checks: + # check not enabled + return True + + owner_field = getattr(view, 'owner_field', 'user') + owner = operator.attrgetter(owner_field)(obj) + if owner != request.user: + raise Http404 + return True diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index ff937a0f5c3aac8d5609b188d18b796e3b809e3a..fbea3735a267188485bfe236ecbd39ae56ae2e39 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -1,12 +1,36 @@ -import django_filters +from django.db.models import Count + +from django_filters import rest_framework as filters from . import models -class ArtistFilter(django_filters.FilterSet): +class ListenableMixin(filters.FilterSet): + listenable = filters.BooleanFilter(name='_', method='filter_listenable') + + def filter_listenable(self, queryset, name, value): + queryset = queryset.annotate( + files_count=Count('tracks__files') + ) + if value: + return queryset.filter(files_count__gt=0) + else: + return queryset.filter(files_count=0) + + +class ArtistFilter(ListenableMixin): class Meta: model = models.Artist fields = { - 'name': ['exact', 'iexact', 'startswith', 'icontains'] + 'name': ['exact', 'iexact', 'startswith', 'icontains'], + 'listenable': 'exact', } + + +class AlbumFilter(ListenableMixin): + listenable = filters.BooleanFilter(name='_', method='filter_listenable') + + class Meta: + model = models.Album + fields = ['listenable'] diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index d026c9847ca3db7f9920469a225399b0d6c0d3dc..0d33855a6b09060eb5094f8b56aaac9aac641a89 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -54,6 +54,7 @@ class TagViewSetMixin(object): queryset = queryset.filter(tags__pk=tag) return queryset + class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): queryset = ( models.Artist.objects.all() @@ -67,6 +68,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): filter_class = filters.ArtistFilter ordering_fields = ('id', 'name', 'creation_date') + class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): queryset = ( models.Album.objects.all() @@ -78,6 +80,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): permission_classes = [ConditionalAuthentication] search_fields = ['title__unaccent'] ordering_fields = ('creation_date',) + filter_class = filters.AlbumFilter class ImportBatchViewSet( @@ -237,6 +240,7 @@ class TagViewSet(viewsets.ReadOnlyModelViewSet): class Search(views.APIView): max_results = 3 + permission_classes = [ConditionalAuthentication] def get(self, request, *args, **kwargs): query = request.GET['query'] diff --git a/api/funkwhale_api/playlists/admin.py b/api/funkwhale_api/playlists/admin.py index b337154c9c6ae3e9d86a22a250ca39c8b97533bd..68e447f3842d61ea392b262db83a70e87d09cd89 100644 --- a/api/funkwhale_api/playlists/admin.py +++ b/api/funkwhale_api/playlists/admin.py @@ -5,13 +5,13 @@ from . import models @admin.register(models.Playlist) class PlaylistAdmin(admin.ModelAdmin): - list_display = ['name', 'user', 'is_public', 'creation_date'] + list_display = ['name', 'user', 'privacy_level', 'creation_date'] search_fields = ['name', ] list_select_related = True @admin.register(models.PlaylistTrack) class PlaylistTrackAdmin(admin.ModelAdmin): - list_display = ['playlist', 'track', 'position', ] + list_display = ['playlist', 'track', 'index'] search_fields = ['track__name', 'playlist__name'] list_select_related = True diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py index 19e4770cfae15fd6d790063026dbdb04770b2e1b..cddea60024bec3f6b02941b0d0ae5335f0322b8f 100644 --- a/api/funkwhale_api/playlists/factories.py +++ b/api/funkwhale_api/playlists/factories.py @@ -1,6 +1,7 @@ import factory from funkwhale_api.factories import registry +from funkwhale_api.music.factories import TrackFactory from funkwhale_api.users.factories import UserFactory @@ -11,3 +12,12 @@ class PlaylistFactory(factory.django.DjangoModelFactory): class Meta: model = 'playlists.Playlist' + + +@registry.register +class PlaylistTrackFactory(factory.django.DjangoModelFactory): + playlist = factory.SubFactory(PlaylistFactory) + track = factory.SubFactory(TrackFactory) + + class Meta: + model = 'playlists.PlaylistTrack' diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..bc49415100a3c5d3d53fb5fcb680195934d538c3 --- /dev/null +++ b/api/funkwhale_api/playlists/filters.py @@ -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) diff --git a/api/funkwhale_api/playlists/migrations/0001_initial.py b/api/funkwhale_api/playlists/migrations/0001_initial.py index bc97d81227ea49f8b46c0e0344fca6978a57b959..987b2f9cfec9be140658b6f5428d70273333c4b8 100644 --- a/api/funkwhale_api/playlists/migrations/0001_initial.py +++ b/api/funkwhale_api/playlists/migrations/0001_initial.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals from django.db import migrations, models from django.conf import settings import django.utils.timezone -import mptt.fields class Migration(migrations.Migration): @@ -34,7 +33,7 @@ class Migration(migrations.Migration): ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), ('position', models.PositiveIntegerField(db_index=True, editable=False)), ('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)), - ('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)), + ('previous', models.OneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)), ('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)), ], options={ diff --git a/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py b/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py new file mode 100644 index 0000000000000000000000000000000000000000..23d0a8eab6a0d174397ff7c6ea62d2fafa9654a7 --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py @@ -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), + ), + ] diff --git a/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py b/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py new file mode 100644 index 0000000000000000000000000000000000000000..0284f8f2cf88f1b04e5a05334b73789fb9233e8c --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py @@ -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')}, + ), + ] diff --git a/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py b/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py new file mode 100644 index 0000000000000000000000000000000000000000..415b53612a43178051a4103a0d415ff2f7583d02 --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py @@ -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(), + ), + ] diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index e89dce81c93d23a4827e2b812e5894abcc948485..6bb8fe17820a20b82b2a4468c2c0421350cfa53d 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -1,43 +1,130 @@ +from django.conf import settings from django.db import models +from django.db import transaction from django.utils import timezone -from mptt.models import MPTTModel, TreeOneToOneField +from rest_framework import exceptions + +from funkwhale_api.common import fields class Playlist(models.Model): name = models.CharField(max_length=50) - is_public = models.BooleanField(default=False) user = models.ForeignKey( 'users.User', related_name="playlists", on_delete=models.CASCADE) creation_date = models.DateTimeField(default=timezone.now) + modification_date = models.DateTimeField( + auto_now=True) + privacy_level = fields.get_privacy_field() def __str__(self): return self.name - def add_track(self, track, previous=None): - plt = PlaylistTrack(previous=previous, track=track, playlist=self) - plt.save() + @transaction.atomic + def insert(self, plt, index=None): + """ + Given a PlaylistTrack, insert it at the correct index in the playlist, + and update other tracks index if necessary. + """ + old_index = plt.index + move = old_index is not None + if index is not None and index == old_index: + # moving at same position, just skip + return index + + 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) - return plt + 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) -class PlaylistTrack(MPTTModel): + @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(models.Model): track = models.ForeignKey( 'music.Track', related_name='playlist_tracks', on_delete=models.CASCADE) - previous = TreeOneToOneField( - 'self', - blank=True, - null=True, - related_name='next', - on_delete=models.CASCADE) + index = models.PositiveIntegerField(null=True, blank=True) playlist = models.ForeignKey( Playlist, related_name='playlist_tracks', on_delete=models.CASCADE) + creation_date = models.DateTimeField(default=timezone.now) - class MPTTMeta: - level_attr = 'position' - parent_attr = 'previous' + objects = PlaylistTrackQuerySet.as_manager() class Meta: - ordering = ('-playlist', 'position') + ordering = ('-playlist', 'index') + unique_together = ('playlist', 'index') + + def delete(self, *args, **kwargs): + playlist = self.playlist + index = self.index + update_indexes = kwargs.pop('update_indexes', False) + r = super().delete(*args, **kwargs) + if index is not None and update_indexes: + playlist.remove(index) + return r diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index 7f889d53e8b7c3163dfb1c017af2df14665d3df7..6caf9aa4aa13de423c0fd322afc0af6c02f2afd3 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -1,8 +1,11 @@ +from django.conf import settings +from django.db import transaction from rest_framework import serializers from taggit.models import Tag +from funkwhale_api.music.models import Track from funkwhale_api.music.serializers import TrackSerializerNested - +from funkwhale_api.users.serializers import UserBasicSerializer from . import models @@ -11,20 +14,81 @@ class PlaylistTrackSerializer(serializers.ModelSerializer): class Meta: model = models.PlaylistTrack - fields = ('id', 'track', 'playlist', 'position') + fields = ('id', 'track', 'playlist', 'index', 'creation_date') -class PlaylistTrackCreateSerializer(serializers.ModelSerializer): +class PlaylistTrackWriteSerializer(serializers.ModelSerializer): + index = serializers.IntegerField( + required=False, min_value=0, allow_null=True) class Meta: model = models.PlaylistTrack - fields = ('id', 'track', 'playlist', 'position') + fields = ('id', 'track', 'playlist', 'index') + + def validate_playlist(self, value): + if self.context.get('request'): + # validate proper ownership on the playlist + if self.context['request'].user != value.user: + raise serializers.ValidationError( + 'You do not have the permission to edit this playlist') + existing = value.playlist_tracks.count() + if existing >= settings.PLAYLISTS_MAX_TRACKS: + raise serializers.ValidationError( + 'Playlist has reached the maximum of {} tracks'.format( + settings.PLAYLISTS_MAX_TRACKS)) + return value + + @transaction.atomic + def create(self, validated_data): + index = validated_data.pop('index', None) + instance = super().create(validated_data) + instance.playlist.insert(instance, index) + return instance + + @transaction.atomic + def update(self, instance, validated_data): + update_index = 'index' in validated_data + index = validated_data.pop('index', None) + super().update(instance, validated_data) + if update_index: + instance.playlist.insert(instance, index) + return instance + + def get_unique_together_validators(self): + """ + We explicitely disable unique together validation here + because it collides with our internal logic + """ + return [] class PlaylistSerializer(serializers.ModelSerializer): - playlist_tracks = PlaylistTrackSerializer(many=True, read_only=True) + tracks_count = serializers.SerializerMethodField(read_only=True) + user = UserBasicSerializer(read_only=True) class Meta: model = models.Playlist - fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks') - read_only_fields = ['id', 'playlist_tracks', 'creation_date'] + fields = ( + 'id', + 'name', + 'tracks_count', + 'user', + 'modification_date', + 'creation_date', + 'privacy_level',) + read_only_fields = [ + 'id', + 'modification_date', + 'creation_date',] + + def get_tracks_count(self, obj): + try: + return obj.tracks_count + except AttributeError: + # no annotation? + return obj.playlist_tracks.count() + + +class PlaylistAddManySerializer(serializers.Serializer): + tracks = serializers.PrimaryKeyRelatedField( + many=True, queryset=Track.objects.for_nested_serialization()) diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 1a88d231e46c63d865554f56b40128a24b9edac4..683f90388885ecb5337bed10b16ef3c7d9b866a4 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -1,58 +1,123 @@ +from django.db.models import Count +from django.db import transaction + +from rest_framework import exceptions from rest_framework import generics, mixins, viewsets from rest_framework import status +from rest_framework.decorators import detail_route from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from funkwhale_api.common import permissions +from funkwhale_api.common import fields from funkwhale_api.music.models import Track -from funkwhale_api.common.permissions import ConditionalAuthentication +from . import filters from . import models from . import serializers - class PlaylistViewSet( mixins.RetrieveModelMixin, mixins.CreateModelMixin, + mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = serializers.PlaylistSerializer - queryset = (models.Playlist.objects.all()) - permission_classes = [ConditionalAuthentication] + queryset = ( + models.Playlist.objects.all().select_related('user') + .annotate(tracks_count=Count('playlist_tracks')) + ) + permission_classes = [ + permissions.ConditionalAuthentication, + permissions.OwnerPermission, + IsAuthenticatedOrReadOnly, + ] + owner_checks = ['write'] + filter_class = filters.PlaylistFilter + ordering_fields = ('id', 'name', 'creation_date', 'modification_date') - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + @detail_route(methods=['get']) + def tracks(self, request, *args, **kwargs): + playlist = self.get_object() + plts = playlist.playlist_tracks.all().for_nested_serialization() + serializer = serializers.PlaylistTrackSerializer(plts, many=True) + data = { + 'count': len(plts), + 'results': serializer.data + } + return Response(data, status=200) + + @detail_route(methods=['post']) + @transaction.atomic + def add(self, request, *args, **kwargs): + playlist = self.get_object() + serializer = serializers.PlaylistAddManySerializer(data=request.data) serializer.is_valid(raise_exception=True) - instance = self.perform_create(serializer) - serializer = self.get_serializer(instance=instance) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + try: + plts = playlist.insert_many(serializer.validated_data['tracks']) + except exceptions.ValidationError as e: + payload = {'playlist': e.detail} + return Response(payload, status=400) + ids = [p.id for p in plts] + plts = models.PlaylistTrack.objects.filter( + pk__in=ids).order_by('index').for_nested_serialization() + serializer = serializers.PlaylistTrackSerializer(plts, many=True) + data = { + 'count': len(plts), + 'results': serializer.data + } + return Response(data, status=201) + + @detail_route(methods=['delete']) + @transaction.atomic + def clear(self, request, *args, **kwargs): + playlist = self.get_object() + playlist.playlist_tracks.all().delete() + playlist.save(update_fields=['modification_date']) + return Response(status=204) def get_queryset(self): - return self.queryset.filter(user=self.request.user) + return self.queryset.filter( + fields.privacy_level_query(self.request.user)) def perform_create(self, serializer): - return serializer.save(user=self.request.user) + return serializer.save( + user=self.request.user, + privacy_level=serializer.validated_data.get( + 'privacy_level', self.request.user.privacy_level) + ) class PlaylistTrackViewSet( + mixins.RetrieveModelMixin, mixins.CreateModelMixin, + mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = serializers.PlaylistTrackSerializer - queryset = (models.PlaylistTrack.objects.all()) - permission_classes = [ConditionalAuthentication] + queryset = (models.PlaylistTrack.objects.all().for_nested_serialization()) + permission_classes = [ + permissions.ConditionalAuthentication, + permissions.OwnerPermission, + IsAuthenticatedOrReadOnly, + ] + owner_field = 'playlist.user' + owner_checks = ['write'] - def create(self, request, *args, **kwargs): - serializer = serializers.PlaylistTrackCreateSerializer( - data=request.data) - serializer.is_valid(raise_exception=True) - instance = self.perform_create(serializer) - serializer = self.get_serializer(instance=instance) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + def get_serializer_class(self): + if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']: + return serializers.PlaylistTrackWriteSerializer + return self.serializer_class def get_queryset(self): - return self.queryset.filter(playlist__user=self.request.user) + return self.queryset.filter( + fields.privacy_level_query( + self.request.user, + lookup_field='playlist__privacy_level')) + + def perform_destroy(self, instance): + instance.delete(update_indexes=True) diff --git a/api/funkwhale_api/requests/admin.py b/api/funkwhale_api/requests/admin.py index 71933eaa940d09c8482e6d7b2a2b73cf22afc5b5..8ca008a03a43607240e3102ade0b2ea8a75ac2d6 100644 --- a/api/funkwhale_api/requests/admin.py +++ b/api/funkwhale_api/requests/admin.py @@ -7,8 +7,7 @@ from . import models class ImportRequestAdmin(admin.ModelAdmin): list_display = ['artist_name', 'user', 'status', 'creation_date'] list_select_related = [ - 'user', - 'track' + 'user' ] list_filter = [ 'status', diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index a5478656b178c94037ca232834cc15d36c6b1f9d..9516c108f896275837be3161fe80e07dda6273e7 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -10,15 +10,9 @@ from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from funkwhale_api.common import fields -PRIVACY_LEVEL_CHOICES = [ - ('me', 'Only me'), - ('followers', 'Me and my followers'), - ('instance', 'Everyone on my instance, and my followers'), - ('everyone', 'Everyone, including people on other instances'), -] - @python_2_unicode_compatible class User(AbstractUser): @@ -39,8 +33,8 @@ class User(AbstractUser): }, } - privacy_level = models.CharField( - max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance') + privacy_level = fields.get_privacy_field() + def __str__(self): return self.username diff --git a/api/install_python_dependencies.sh b/api/install_python_dependencies.sh deleted file mode 100755 index 34929e60790b7a6708729fdc35f834d414597da9..0000000000000000000000000000000000000000 --- a/api/install_python_dependencies.sh +++ /dev/null @@ -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 diff --git a/api/pytest.ini b/api/pytest.ini deleted file mode 100644 index 9be63d3531626526f46f196b840c02a9cae1c8b3..0000000000000000000000000000000000000000 --- a/api/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE=config.settings.test - -# -- recommended but optional: -python_files = tests.py test_*.py *_tests.py -testpatsh = tests diff --git a/api/requirements/base.txt b/api/requirements/base.txt index d6800f3b50d9d6e662a2514b46f76815f3c8d84d..efcc4eea40e66fadd74b25dc8cfa1c89f4e8abe7 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -33,7 +33,6 @@ musicbrainzngs==0.6 youtube_dl>=2017.12.14 djangorestframework>=3.7,<3.8 djangorestframework-jwt>=1.11,<1.12 -django-mptt>=0.9,<0.10 google-api-python-client>=1.6,<1.7 arrow>=0.12,<0.13 persisting-theory>=0.2,<0.3 @@ -58,3 +57,6 @@ python-magic==0.4.15 ffmpeg-python==0.1.10 channels>=2,<2.1 channels_redis>=2.1,<2.2 +django-cacheops>=4,<4.1 + +daphne==2.0.4 diff --git a/api/requirements/local.txt b/api/requirements/local.txt index b466b20fdfc20c5815fb2f7be8daedccc3f360ac..c5f2ad0b7f7921a3af127f971f93eb4a3c80ad30 100644 --- a/api/requirements/local.txt +++ b/api/requirements/local.txt @@ -2,9 +2,6 @@ coverage>=4.4,<4.5 django_coverage_plugin>=1.5,<1.6 -Sphinx>=1.6,<1.7 -django-extensions>=1.9,<1.10 -Werkzeug>=0.13,<0.14 factory_boy>=2.8.1 # django-debug-toolbar that works with Django 1.5+ diff --git a/api/requirements/production.txt b/api/requirements/production.txt index 4ad8edf940dc4524f77c6ac9b339e55c7b1c5a7c..d51ee863ad7e465c5ddcfcf9de5ac8cfcfa51e4f 100644 --- a/api/requirements/production.txt +++ b/api/requirements/production.txt @@ -3,5 +3,3 @@ # WSGI Handler # ------------------------------------------------ - -daphne==2.0.4 diff --git a/api/runtests b/api/runtests deleted file mode 100755 index 48e7b82679e6cab413f4d435cfc0b8f794019bb0..0000000000000000000000000000000000000000 --- a/api/runtests +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -docker-compose -f $DIR/test.yml run test pytest "$@" diff --git a/api/setup.cfg b/api/setup.cfg index c18b80d95bcd54716236d261e572b6b2143d6e3a..34daa8c6834452229971467c7876400b842b64c1 100644 --- a/api/setup.cfg +++ b/api/setup.cfg @@ -5,3 +5,8 @@ exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules [pep8] max-line-length = 120 exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules + +[tool:pytest] +DJANGO_SETTINGS_MODULE=config.settings.test +python_files = tests.py test_*.py *_tests.py +testpaths = tests diff --git a/api/test.yml b/api/test.yml deleted file mode 100644 index 5e785cb1ac3e20eba418a5d89673a207a84519de..0000000000000000000000000000000000000000 --- a/api/test.yml +++ /dev/null @@ -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 diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..7c63431a38ae4e5eaf41c5285966afd80a2dd6e2 --- /dev/null +++ b/api/tests/common/test_fields.py @@ -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 diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..b5c5160f8accdf6e0bbeb29f9ee4d464962dff5b --- /dev/null +++ b/api/tests/common/test_permissions.py @@ -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 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 2d655f23f28dd2ea1ad56d4073df1147451c603c..62bc5ada676327aa1d5044c7bd31eaea45904dea 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,9 +1,13 @@ +import factory import tempfile import shutil import pytest + from django.core.cache import cache as django_cache from dynamic_preferences.registries import global_preferences_registry + from rest_framework.test import APIClient +from rest_framework.test import APIRequestFactory from funkwhale_api.activity import record from funkwhale_api.taskapp import celery @@ -26,6 +30,16 @@ def cache(): @pytest.fixture def factories(db): from funkwhale_api import factories + for v in factories.registry.values(): + v._meta.strategy = factory.CREATE_STRATEGY + yield factories.registry + + +@pytest.fixture +def nodb_factories(): + from funkwhale_api import factories + for v in factories.registry.values(): + v._meta.strategy = factory.BUILD_STRATEGY yield factories.registry @@ -84,6 +98,11 @@ def superuser_client(db, factories, client): delattr(client, 'user') +@pytest.fixture +def api_request(): + return APIRequestFactory() + + @pytest.fixture def activity_registry(): r = record.registry diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..2956046168ca30487976512518bedce919752f2e --- /dev/null +++ b/api/tests/music/test_views.py @@ -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 diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..c9def4dab85c4798c337b8876387c2ff5a6f05ed --- /dev/null +++ b/api/tests/playlists/test_models.py @@ -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]) diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..8e30919e6eaab42e3d8111c59cff3a2118271abc --- /dev/null +++ b/api/tests/playlists/test_serializers.py @@ -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 diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..5bf83488859177aaf4e756a5f25668f568641b35 --- /dev/null +++ b/api/tests/playlists/test_views.py @@ -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 diff --git a/api/tests/test_playlists.py b/api/tests/test_playlists.py deleted file mode 100644 index f496a64cb5d93a14aacc8bb73ddc2a58e3c7b50e..0000000000000000000000000000000000000000 --- a/api/tests/test_playlists.py +++ /dev/null @@ -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] diff --git a/dev.yml b/dev.yml index 2c102f3aea33e790f292ac1ec9755648690cc584..8d2129bef978e78bc8c1a874ae19692d8ee62997 100644 --- a/dev.yml +++ b/dev.yml @@ -71,3 +71,12 @@ services: - ./api/funkwhale_api/media:/protected/media ports: - "0.0.0.0:6001:6001" + + docs: + build: docs + command: python serve.py + volumes: + - ".:/app/" + ports: + - '35730:35730' + - '8001:8001' diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..1067eb8be427bcb66cb4ad824993ef300ba04cb6 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,4 @@ +FROM python:3.6-alpine + +RUN pip install sphinx livereload +WORKDIR /app/docs diff --git a/docs/serve.py b/docs/serve.py new file mode 100644 index 0000000000000000000000000000000000000000..9a381c74be8caffbadbc04bc7d38764e535fefdb --- /dev/null +++ b/docs/serve.py @@ -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') diff --git a/front/src/App.vue b/front/src/App.vue index b26959fe7006a72c45cac37e72d468c8f3c614ff..d15eebdba69db25580d2d86a055819439a7c113a 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -29,6 +29,8 @@ v-if="$store.state.instance.settings.raven.front_enabled.value" :dsn="$store.state.instance.settings.raven.front_dsn.value"> </raven> + <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> + </div> </template> @@ -39,11 +41,14 @@ import logger from '@/logging' import Sidebar from '@/components/Sidebar' import Raven from '@/components/Raven' +import PlaylistModal from '@/components/playlists/PlaylistModal' + export default { name: 'app', components: { Sidebar, - Raven + Raven, + PlaylistModal }, created () { this.$store.dispatch('instance/fetchSettings') @@ -56,6 +61,9 @@ export default { }, methods: { openWebsocket () { + if (!this.$store.state.auth.authenticated) { + return + } let self = this let token = this.$store.state.auth.token // let token = 'test' diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index 0cea1c25ab4361d019d6df6be7c4cdad4b1c43a5..ad1ad93cb08c2a29cff5b84859b769345dee9872 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -94,9 +94,9 @@ <p>Funkwhale is dead simple to use.</p> <div class="ui list"> <div class="item"> - <i class="libraryr icon"></i> + <i class="book icon"></i> <div class="content"> - No add-ons, no plugins : you only need a web libraryr + No add-ons, no plugins : you only need a web library </div> </div> <div class="item"> diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue index 71813a18a400ec2c712d19889d8e1a2a4372d414..47cf5183ab31aeb6fa4575139d42db73fc53a550 100644 --- a/front/src/components/Pagination.vue +++ b/front/src/components/Pagination.vue @@ -1,6 +1,7 @@ <template> <div class="ui pagination borderless menu"> <a + v-if="current - 1 >= 1" @click="selectPage(current - 1)" :class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></a> <template> @@ -16,6 +17,7 @@ </a> </template> <a + v-if="current + 1 <= maxPage" @click="selectPage(current + 1)" :class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></a> </div> @@ -62,7 +64,6 @@ export default { } } }) - console.log(final) return final }, maxPage: function () { diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index f5229b2075c1811c24fcd758dc8db3611b2724a7..f225313b6b5f64139ed668473a6ebea51bc58eec 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -36,6 +36,12 @@ <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link> <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link> <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link> + <a + @click="$store.commit('playlists/chooseTrack', null)" + v-if="$store.state.auth.authenticated" + class="item"> + <i class="list icon"></i> Playlists + </a> <router-link v-if="$store.state.auth.authenticated" class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link> @@ -70,7 +76,7 @@ <td> <template v-if="$store.getters['favorites/isFavorite'](track.id)"> <i class="pink heart icon"></i> - </template + </template> </td> <td> <i @click.stop="cleanTrack(index)" class="circular trash icon"></i> @@ -148,7 +154,7 @@ export default { <style scoped lang="scss"> @import '../style/vendor/media'; -$sidebar-color: #1B1C1D; +$sidebar-color: #3D3E3F; .sidebar { background: $sidebar-color; @@ -159,7 +165,7 @@ $sidebar-color: #1B1C1D; } @include media(">desktop") { .collapse.button { - display: none; + display: none !important; } } @include media("<desktop") { @@ -176,16 +182,26 @@ $sidebar-color: #1B1C1D; margin: 0; background-color: $sidebar-color; } - .menu { + .menu.vertical { + background: $sidebar-color; } } .menu-area { - padding: 0.5rem; .menu .item:not(.active):not(:hover) { - background-color: rgba(255, 255, 255, 0.06); + opacity: 0.75; + } + + .menu .item { + border-radius: 0; } + .menu .item.active { + background-color: $sidebar-color; + &:hover { + background-color: rgba(255, 255, 255, 0.06); + } + } } .tabs { overflow-y: auto; @@ -216,14 +232,33 @@ $sidebar-color: #1B1C1D; .logo { cursor: pointer; display: inline-block; + margin: 0px; } .ui.search { - display: block; - .collapse.button { - margin-right: 0.5rem; - margin-top: 0.5rem; - float: right; + display: flex; + + .collapse.button, .collapse.button:hover, .collapse.button:active { + box-shadow: none !important; + margin: 0px; + display: flex; + flex-direction: column; + justify-content: center; + } +} + +.ui.message.black { + background: $sidebar-color; +} +</style> + +<style lang="scss"> +.sidebar { + .ui.search .input { + flex: 1; + .prompt { + border-radius: 0; + } } } </style> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 679f8b795f01cf83031998d2a5078e7cfca5350d..f2a3898622c09852eb6e05b77c549a76bbf9a6ee 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -3,11 +3,11 @@ <button title="Add to current queue" @click="add" - :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: playableTracks.length === 0}, 'button']"> + :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']"> <i class="ui play icon"></i> <template v-if="!discrete"><slot>Play</slot></template> </button> - <div v-if="!discrete" class="ui floating dropdown icon button"> + <div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']"> <i class="dropdown icon"></i> <div class="menu"> <div class="item"@click="add"><i class="plus icon"></i> Add to queue</div> @@ -19,6 +19,7 @@ </template> <script> +import axios from 'axios' import logger from '@/logging' import jQuery from 'jquery' @@ -27,6 +28,7 @@ export default { // we can either have a single or multiple tracks to play when clicked tracks: {type: Array, required: false}, track: {type: Object, required: false}, + playlist: {type: Object, required: false}, discrete: {type: Boolean, default: false} }, data () { @@ -35,8 +37,8 @@ export default { } }, created () { - if (!this.track & !this.tracks) { - logger.default.error('You have to provide either a track or tracks property') + if (!this.playlist && !this.track && !this.tracks) { + logger.default.error('You have to provide either a track playlist or tracks property') } }, mounted () { @@ -45,19 +47,40 @@ export default { } }, computed: { - playableTracks () { - let tracks + playable () { if (this.track) { - tracks = [this.track] - } else { - tracks = this.tracks + return true + } else if (this.tracks) { + return this.tracks.length > 0 + } else if (this.playlist) { + return true } - return tracks.filter(e => { - return e.files.length > 0 - }) + return false } }, methods: { + getPlayableTracks () { + let self = this + let getTracks = new Promise((resolve, reject) => { + if (self.track) { + resolve([self.track]) + } else if (self.tracks) { + resolve(self.tracks) + } else if (self.playlist) { + let url = 'playlists/' + self.playlist.id + '/' + axios.get(url + 'tracks').then((response) => { + resolve(response.data.results.map(plt => { + return plt.track + })) + }) + } + }) + return getTracks.then((tracks) => { + return tracks.filter(e => { + return e.files.length > 0 + }) + }) + }, triggerLoad () { let self = this this.isLoading = true @@ -66,15 +89,21 @@ export default { }, 500) }, add () { + let self = this this.triggerLoad() - this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks}) + this.getPlayableTracks().then((tracks) => { + self.$store.dispatch('queue/appendMany', {tracks: tracks}) + }) }, addNext (next) { + let self = this this.triggerLoad() - this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1}) - if (next) { - this.$store.dispatch('queue/next') - } + this.getPlayableTracks().then((tracks) => { + self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1}) + if (next) { + self.$store.dispatch('queue/next') + } + }) } } } diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index b5cbd8f81e0bcd1c9bca8cdc7dd192044f94d968..75a01c52e015b419d4c919b90bfe97e7db8ab02b 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -3,7 +3,7 @@ <div class="player"> <audio-track ref="currentAudio" - v-if="currentTrack" + v-if="renderAudio && currentTrack" :key="(currentIndex, currentTrack.id)" :is-current="true" :start-time="$store.state.player.currentTime" @@ -30,7 +30,12 @@ </router-link> </div> <div class="description"> - <track-favorite-icon :track="currentTrack"></track-favorite-icon> + <track-favorite-icon + v-if="$store.state.auth.authenticated" + :track="currentTrack"></track-favorite-icon> + <track-playlist-icon + v-if="$store.state.auth.authenticated" + :track="currentTrack"></track-playlist-icon> </div> </div> </div> @@ -140,17 +145,20 @@ import ColorThief from '@/vendor/color-thief' import Track from '@/audio/track' import AudioTrack from '@/components/audio/Track' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' +import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' export default { name: 'player', components: { TrackFavoriteIcon, + TrackPlaylistIcon, GlobalEvents, AudioTrack }, data () { let defaultAmbiantColors = [[46, 46, 46], [46, 46, 46], [46, 46, 46], [46, 46, 46]] return { + renderAudio: true, sliderVolume: this.volume, Track: Track, defaultAmbiantColors: defaultAmbiantColors, @@ -163,7 +171,6 @@ export default { }, methods: { ...mapActions({ - pause: 'player/pause', togglePlay: 'player/togglePlay', clean: 'queue/clean', next: 'queue/next', @@ -230,6 +237,17 @@ export default { this.ambiantColors = this.defaultAmbiantColors } }, + currentIndex (newValue, oldValue) { + if (newValue !== oldValue) { + // why this? to ensure the audio tag is deleted and fully + // rerendered, so we don't have any issues with cached position + // or whatever + this.renderAudio = false + this.$nextTick(() => { + this.renderAudio = true + }) + } + }, volume (newValue) { this.sliderVolume = newValue }, @@ -270,6 +288,7 @@ export default { cursor: pointer } .track-area { + margin-top: 0; .header, .meta, .artist, .album { color: white !important; } @@ -373,4 +392,5 @@ export default { .ui.feed.icon { margin: 0; } + </style> diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index 988ff0a7d7ccb78707e3b66c3474e380e6838e72..99896d04beda7bc51a559fb10b8205a45e254610 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -30,6 +30,9 @@ export default { }, apiSettings: { beforeXHR: function (xhrObject) { + if (!self.$store.state.auth.authenticated) { + return xhrObject + } xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) return xhrObject }, diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index ea42f06a8392871cca63b6d5fbe62d5f26d75455..bb37b2b2f86635e3324652f4245b08e3fa4a26a9 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -6,12 +6,13 @@ <img v-else src="../../../assets/audio/default-cover.png"> </div> <div class="header"> - <router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">{{ album.title }}</router-link> + <router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">{{ album.title }} </router-link> </div> <div class="meta"> - By <router-link :to="{name: 'library.artists.detail', params: {id: album.artist.id }}"> - {{ album.artist.name }} - </router-link> + <span> + By <router-link tag="span" :to="{name: 'library.artists.detail', params: {id: album.artist.id }}"> + {{ album.artist.name }}</router-link> + </span><span class="time" v-if="album.release_date">– {{ album.release_date | year }}</span> </div> <div class="description" v-if="mode === 'rich'"> <table class="ui very basic fixed single line compact unstackable table"> diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue new file mode 100644 index 0000000000000000000000000000000000000000..8310e89c4a4ad6ab749f2386003f1c59ae7dccea --- /dev/null +++ b/front/src/components/audio/track/Row.vue @@ -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> diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index 00bcf9f7de239ab6f54f1925d7550760aba95c23..512ba1b493d35f71b098d112959a9ac9d3c5e4ef 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -11,34 +11,11 @@ </tr> </thead> <tbody> - <tr v-for="track in tracks"> - <td> - <play-button class="basic icon" :discrete="true" :track="track"></play-button> - </td> - <td> - <img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)"> - <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png"> - </td> - <td colspan="6"> - <router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}"> - <template v-if="displayPosition && track.position"> - {{ track.position }}. - </template> - {{ track.title }} - </router-link> - </td> - <td colspan="6"> - <router-link class="artist discrete link" :to="{name: 'library.artists.detail', params: {id: track.artist.id }}"> - {{ track.artist.name }} - </router-link> - </td> - <td colspan="6"> - <router-link class="album discrete link" :to="{name: 'library.albums.detail', params: {id: track.album.id }}"> - {{ track.album.title }} - </router-link> - </td> - <td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td> - </tr> + <track-row + :display-position="displayPosition" + :track="track" + :key="index + '-' + track.id" + v-for="(track, index) in tracks"></track-row> </tbody> <tfoot class="full-width"> <tr> @@ -83,9 +60,8 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.aut <script> import backend from '@/audio/backend' -import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' -import PlayButton from '@/components/audio/PlayButton' +import TrackRow from '@/components/audio/track/Row' import Modal from '@/components/semantic/Modal' export default { @@ -95,8 +71,7 @@ export default { }, components: { Modal, - TrackFavoriteIcon, - PlayButton + TrackRow }, data () { return { diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..525b4c48ff259babf599b54a2368ad2017c677d3 --- /dev/null +++ b/front/src/components/common/DangerousButton.vue @@ -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> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index b1d7d61041d31df9f9e47076e6ae273c8119c33b..79bbcf1b93a4a74ecf3c3b3d3d4e724870d7120c 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -8,4 +8,8 @@ import Username from '@/components/common/Username' Vue.component('username', Username) +import DangerousButton from '@/components/common/DangerousButton' + +Vue.component('dangerous-button', DangerousButton) + export default {} diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index 7724428cac36ced03671c461dd76bea3acdd6364..9a546aa0e9fa98aa4bdda13fcde57fd6ad4b2986 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -31,7 +31,7 @@ <div class="ui vertical stripe segment"> <h2>Albums by this artist</h2> <div class="ui stackable doubling three column grid"> - <div class="column" :key="album.id" v-for="album in albums"> + <div class="column" :key="album.id" v-for="album in sortedAlbums"> <album-card :mode="'rich'" class="fluid" :album="album"></album-card> </div> </div> @@ -41,6 +41,7 @@ </template> <script> +import _ from 'lodash' import axios from 'axios' import logger from '@/logging' import backend from '@/audio/backend' @@ -83,6 +84,10 @@ export default { } }, computed: { + sortedAlbums () { + let a = this.albums || [] + return _.orderBy(a, ['release_date'], ['asc']) + }, totalTracks () { return this.albums.map((album) => { return album.tracks.length diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 3cf123447128d8198853036ab1be5c0b52ebbc0a..52ccbdd7465c654b28eb53c141f50a6e62ca0209 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -129,7 +129,8 @@ export default { page: this.page, page_size: this.paginateBy, name__icontains: this.query, - ordering: this.getOrderingAsString() + ordering: this.getOrderingAsString(), + listenable: 'true' } logger.default.debug('Fetching artists') axios.get(url, {params: params}).then((response) => { diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index e4e22fc09919f815038795762f408ac0a760aa58..40f6808f98f3c382353fd9bb205d3c09cbbcb8bd 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -20,7 +20,7 @@ </div> <div class="column"> <h2 class="ui header">Music requests</h2> - <request-form></request-form> + <request-form v-if="$store.state.auth.authenticated"></request-form> </div> </div> </div> diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index 6cd156493f4487683df91fd9314f31fb8807de1d..161d4519b2fab561022fc4a8b8f706e67dc3258c 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -4,8 +4,9 @@ <router-link class="ui item" to="/library" exact>Browse</router-link> <router-link class="ui item" to="/library/artists" exact>Artists</router-link> <router-link class="ui item" to="/library/radios" exact>Radios</router-link> + <router-link class="ui item" to="/library/playlists" exact>Playlists</router-link> <div class="ui secondary right menu"> - <router-link class="ui item" to="/library/requests/" exact> + <router-link v-if="$store.state.auth.authenticated" class="ui item" to="/library/requests/" exact> Requests <div class="ui teal label">{{ requestsCount }}</div> </router-link> @@ -32,8 +33,11 @@ export default { }, methods: { fetchRequestsCount () { + if (!this.$store.state.authenticated) { + return + } let self = this - axios.get('requests/import-requests', {params: {status: 'pending'}}).then(response => { + axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { self.requestsCount = response.data.count }) } diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index a40409615dc75bb0e29ebe230f626a1757b7a9f4..0437ac88151ad166ea6704579b8069adf35007f2 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -24,6 +24,11 @@ <play-button class="orange" :track="track">Play</play-button> <track-favorite-icon :track="track" :button="true"></track-favorite-icon> + <track-playlist-icon + :button="true" + v-if="$store.state.auth.authenticated" + :track="track"></track-playlist-icon> + <a :href="wikipediaUrl" target="_blank" class="ui button"> <i class="wikipedia icon"></i> Search on wikipedia @@ -66,6 +71,7 @@ import logger from '@/logging' import backend from '@/audio/backend' import PlayButton from '@/components/audio/PlayButton' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' +import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' const FETCH_URL = 'tracks/' @@ -73,6 +79,7 @@ export default { props: ['id'], components: { PlayButton, + TrackPlaylistIcon, TrackFavoriteIcon }, data () { diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue new file mode 100644 index 0000000000000000000000000000000000000000..6dd1b0a0ce477713af1e9dd9e7b33cff40906730 --- /dev/null +++ b/front/src/components/playlists/Card.vue @@ -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> diff --git a/front/src/components/playlists/CardList.vue b/front/src/components/playlists/CardList.vue new file mode 100644 index 0000000000000000000000000000000000000000..4d4746090f757dddaf5d33cd4d924440e725ca09 --- /dev/null +++ b/front/src/components/playlists/CardList.vue @@ -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> diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue new file mode 100644 index 0000000000000000000000000000000000000000..c668857ea1f88557b22d77ecb81acdfd5fe52162 --- /dev/null +++ b/front/src/components/playlists/Editor.vue @@ -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> diff --git a/front/src/components/playlists/Form.vue b/front/src/components/playlists/Form.vue new file mode 100644 index 0000000000000000000000000000000000000000..634e310bcdc2bb57484a8e048f65cfd8834da0d0 --- /dev/null +++ b/front/src/components/playlists/Form.vue @@ -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> diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..5fdf585dfbd7ce44ee9fcb4a3a9f6610e9d00d6c --- /dev/null +++ b/front/src/components/playlists/PlaylistModal.vue @@ -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> diff --git a/front/src/components/playlists/TrackPlaylistIcon.vue b/front/src/components/playlists/TrackPlaylistIcon.vue new file mode 100644 index 0000000000000000000000000000000000000000..bba4c515b0c90644182016f786c06e60b233b552 --- /dev/null +++ b/front/src/components/playlists/TrackPlaylistIcon.vue @@ -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> diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index 819aa8651f3509827b0460c54c9cec9ded18d23b..0869313a875eb153812a5b9fce0cf1ea1a932d81 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -31,7 +31,7 @@ export default { if (!state.running) { return false } else { - return current.type === this.type & current.objectId === this.objectId + return current.type === this.type && current.objectId === this.objectId && current.customRadioId === this.customRadioId } } } diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue index ec7a5a0884262ac2437570cd6a360e231eaccaba..fec8fdd0595ffd8866d93e54bd2cffaedd2f52d5 100644 --- a/front/src/components/semantic/Modal.vue +++ b/front/src/components/semantic/Modal.vue @@ -2,7 +2,7 @@ <div :class="['ui', {'active': show}, 'modal']"> <i class="close icon"></i> <slot> - + </slot> </div> </template> @@ -19,26 +19,38 @@ export default { control: null } }, - mounted () { - this.control = $(this.$el).modal({ - onApprove: function () { - this.$emit('approved') - }.bind(this), - onDeny: function () { - this.$emit('deny') - }.bind(this), - onHidden: function () { - this.$emit('update:show', false) - }.bind(this) - }) + beforeDestroy () { + if (this.control) { + this.control.remove() + } + }, + methods: { + initModal () { + this.control = $(this.$el).modal({ + duration: 100, + onApprove: function () { + this.$emit('approved') + }.bind(this), + onDeny: function () { + this.$emit('deny') + }.bind(this), + onHidden: function () { + this.$emit('update:show', false) + }.bind(this) + }) + } }, watch: { show: { handler (newValue) { if (newValue) { + this.initModal() this.control.modal('show') } else { - this.control.modal('hide') + if (this.control) { + this.control.modal('hide') + this.control.remove() + } } } } diff --git a/front/src/filters.js b/front/src/filters.js index 22d93149bb402657e56d37d9ef68cf2959d9c767..afc393d402e8f80e046d2bd4443e3f03da2f07a7 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -35,6 +35,12 @@ export function momentFormat (date, format) { Vue.filter('moment', momentFormat) +export function year (date) { + return moment(date).year() +} + +Vue.filter('year', year) + export function capitalize (str) { return str.charAt(0).toUpperCase() + str.slice(1) } diff --git a/front/src/router/index.js b/front/src/router/index.js index ba9aadd981d844972a874a5fe0870a640b2f95e8..802844461325560a48cf8002f21ef8e3f5462594 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -21,7 +21,8 @@ import RadioBuilder from '@/components/library/radios/Builder' import BatchList from '@/components/library/import/BatchList' import BatchDetail from '@/components/library/import/BatchDetail' import RequestsList from '@/components/requests/RequestsList' - +import PlaylistDetail from '@/views/playlists/Detail' +import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' Vue.use(Router) @@ -110,6 +111,25 @@ export default new Router({ }, { path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true }, { path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true }, + { + path: 'playlists/', + name: 'library.playlists.browse', + component: PlaylistList, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, + { + path: 'playlists/:id', + name: 'library.playlists.detail', + component: PlaylistDetail, + props: (route) => ({ + id: route.params.id, + defaultEdit: route.query.mode === 'edit' }) + }, { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true }, diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 7944cae0836f58a6eed111b2134fb9ab702123ef..e72e1968f52f1ca3593abfbbdabf4e06fda7b1d5 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -91,6 +91,7 @@ export default { commit('profile', data) commit('username', data.username) dispatch('favorites/fetch', null, {root: true}) + dispatch('playlists/fetchOwn', null, {root: true}) Object.keys(data.permissions).forEach(function (key) { // this makes it easier to check for permissions in templates commit('permission', {key, status: data.permissions[String(key)].status}) diff --git a/front/src/store/index.js b/front/src/store/index.js index 2453c0e7134124e3f9d9dc3ffac47d2558a9827d..298fa04ec13166fead7a12955636d3bc4340948a 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -8,6 +8,7 @@ import instance from './instance' import queue from './queue' import radios from './radios' import player from './player' +import playlists from './playlists' import ui from './ui' Vue.use(Vuex) @@ -20,6 +21,7 @@ export default new Vuex.Store({ instance, queue, radios, + playlists, player }, plugins: [ diff --git a/front/src/store/playlists.js b/front/src/store/playlists.js new file mode 100644 index 0000000000000000000000000000000000000000..b3ed3ab235bf09456a4b7cc9d7129963443488ca --- /dev/null +++ b/front/src/store/playlists.js @@ -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) + }) + } + } +} diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue new file mode 100644 index 0000000000000000000000000000000000000000..6c3a988fd195ae3df5d4e597a69f4bb3dee9c40b --- /dev/null +++ b/front/src/views/playlists/Detail.vue @@ -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> diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue new file mode 100644 index 0000000000000000000000000000000000000000..fc5dcbe54b441c4ee2009ba4ebe7881173ab108a --- /dev/null +++ b/front/src/views/playlists/List.vue @@ -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> diff --git a/front/test/unit/specs/filters/filters.spec.js b/front/test/unit/specs/filters/filters.spec.js index c2b43da44a83eb0a983d981c7891853a634fb31a..f4789ca48c640c0da83ab7d89cf148f60270854c 100644 --- a/front/test/unit/specs/filters/filters.spec.js +++ b/front/test/unit/specs/filters/filters.spec.js @@ -1,4 +1,4 @@ -import {truncate, markdown, ago, capitalize} from '@/filters' +import {truncate, markdown, ago, capitalize, year} from '@/filters' describe('filters', () => { describe('truncate', () => { @@ -32,6 +32,13 @@ describe('filters', () => { expect(output).to.equal('a few seconds ago') }) }) + describe('year', () => { + it('works', () => { + const input = '2017-07-13' + let output = year(input) + expect(output).to.equal(2017) + }) + }) describe('capitalize', () => { it('works', () => { const input = 'hello world' diff --git a/front/test/unit/specs/store/auth.spec.js b/front/test/unit/specs/store/auth.spec.js index 3271f5168f335a95f5e2799007bd3ec64cf77155..518dc10d4db29f680567d6c996853086cd6b4516 100644 --- a/front/test/unit/specs/store/auth.spec.js +++ b/front/test/unit/specs/store/auth.spec.js @@ -180,7 +180,8 @@ describe('store/auth', () => { { type: 'permission', payload: {key: 'admin', status: true} } ], expectedActions: [ - { type: 'favorites/fetch', payload: null, options: {root: true} } + { type: 'favorites/fetch', payload: null, options: {root: true} }, + { type: 'playlists/fetchOwn', payload: null, options: {root: true} }, ] }, done) }) diff --git a/front/test/unit/specs/store/playlists.spec.js b/front/test/unit/specs/store/playlists.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e82af60bbb470f59bae63d1b2fe89ee30ca6bbb5 --- /dev/null +++ b/front/test/unit/specs/store/playlists.spec.js @@ -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) + }) + }) +})