diff --git a/CHANGELOG b/CHANGELOG index aed490eaa3224a062e6b2fd6b4fb0d33e53c768b..fbf8d1fbff5a3350570db2b94b43806421dfbdd7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,26 @@ Changelog 0.2.7 (Unreleased) ------------------ +- Shortcuts: can now use the ``f`` shortcut to toggle the currently playing track + as a favorite (#53) +- Shortcuts: avoid collisions between shortcuts by using the exact modifier (#53) +- Player: Added looping controls and shortcuts (#52) +- Player: Added shuffling controls and shortcuts (#52) +- Favorites: can now modify the ordering of track list (#50) +- Library: can now search/reorder results on artist browsing view (#50) +- Upgraded celery to 4.1, added endpoint logic for fingerprinting audio files +- Fixed #56: invalidate tokens on password change, also added change password form +- Fixed #57: now refresh jwt token on page refresh +- removed ugly dividers in batch import list +- Fixed a few padding issues +- Now persist/restore queue/radio/player state automatically +- Removed old broken imports +- Now force tests paths +- Fixed #54: Now use pytest everywhere \o/ +- Now use vuex to manage state for favorites +- Now use vuex to manage state for authentication +- Now use vuex to manage state for player/queue/radios + 0.2.6 (2017-12-15) ------------------ diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 13205fe3de7bf93b9c46add6a71970da6ac68d4f..a41c7bc0f3eb1b524f15b88f166b93141af2b1d9 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -46,9 +46,8 @@ v1_patterns += [ include( ('funkwhale_api.users.api_urls', 'users'), namespace='users')), - url(r'^token/', - jwt_views.obtain_jwt_token), - url(r'^token/refresh/', jwt_views.refresh_jwt_token), + url(r'^token/$', jwt_views.obtain_jwt_token, name='token'), + url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'), ] urlpatterns = [ diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 9804bb9c08d133b74bfd08440f5d207314ded2cf..7dffebe94ba9e528006778a1ca924626449e8f15 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -280,8 +280,9 @@ JWT_AUTH = { 'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7), 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=30), 'JWT_AUTH_HEADER_PREFIX': 'JWT', + 'JWT_GET_USER_SECRET_KEY': lambda user: user.secret_key } - +OLD_PASSWORD_FIELD_ENABLED = True ACCOUNT_ADAPTER = 'funkwhale_api.users.adapters.FunkwhaleAccountAdapter' CORS_ORIGIN_ALLOW_ALL = True # CORS_ORIGIN_WHITELIST = ( diff --git a/api/config/urls.py b/api/config/urls.py index 8c490a5e6599e2f44bb60bb110a6a362f4153f93..de67ebb571de4b5f4e15cedd969d1adadeb42aee 100644 --- a/api/config/urls.py +++ b/api/config/urls.py @@ -31,3 +31,9 @@ if settings.DEBUG: url(r'^404/$', default_views.page_not_found), url(r'^500/$', default_views.server_error), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + if 'debug_toolbar' in settings.INSTALLED_APPS: + import debug_toolbar + urlpatterns += [ + url(r'^__debug__/', include(debug_toolbar.urls)), + ] diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index f1a7b86a37a61722ccccfcd98eaf4a2cb7ce9a5d..1f0087ecb79198b12d4efc4924a8aa54298beeb6 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = '0.2.6' +__version__ = '0.3' __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) diff --git a/api/funkwhale_api/common/tests/test_jwt_querystring.py b/api/funkwhale_api/common/tests/test_jwt_querystring.py deleted file mode 100644 index 90e63775d9ef7e5e2aeeaac6a144d3ff1812bd6e..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/common/tests/test_jwt_querystring.py +++ /dev/null @@ -1,32 +0,0 @@ -from test_plus.test import TestCase -from rest_framework_jwt.settings import api_settings - -from funkwhale_api.users.models import User - - -jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER -jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER - - -class TestJWTQueryString(TestCase): - www_authenticate_realm = 'api' - - def test_can_authenticate_using_token_param_in_url(self): - user = User.objects.create_superuser( - username='test', email='test@test.com', password='test') - - url = self.reverse('api:v1:tracks-list') - with self.settings(API_AUTHENTICATION_REQUIRED=True): - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - payload = jwt_payload_handler(user) - token = jwt_encode_handler(payload) - print(payload, token) - with self.settings(API_AUTHENTICATION_REQUIRED=True): - response = self.client.get(url, data={ - 'jwt': token - }) - - self.assertEqual(response.status_code, 200) diff --git a/api/funkwhale_api/downloader/tests/test_downloader.py b/api/funkwhale_api/downloader/tests/test_downloader.py deleted file mode 100644 index 7cfaa63c83e40f040aaa4f21de1a519c74d18582..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/downloader/tests/test_downloader.py +++ /dev/null @@ -1,14 +0,0 @@ -import os -from test_plus.test import TestCase -from .. import downloader -from funkwhale_api.utils.tests import TMPDirTestCaseMixin - - -class TestDownloader(TMPDirTestCaseMixin, TestCase): - - def test_can_download_audio_from_youtube_url_to_vorbis(self): - data = downloader.download('https://www.youtube.com/watch?v=tPEE9ZwTmy0', target_directory=self.download_dir) - self.assertEqual( - data['audio_file_path'], - os.path.join(self.download_dir, 'tPEE9ZwTmy0.ogg')) - self.assertTrue(os.path.exists(data['audio_file_path'])) diff --git a/api/funkwhale_api/factories.py b/api/funkwhale_api/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..6fed66edb2ed000e75df819ffb862949672889a5 --- /dev/null +++ b/api/funkwhale_api/factories.py @@ -0,0 +1,30 @@ +import factory +import persisting_theory + + +class FactoriesRegistry(persisting_theory.Registry): + look_into = 'factories' + + def prepare_name(self, data, name=None): + return name or data._meta.model._meta.label + + +registry = FactoriesRegistry() + + +def ManyToManyFromList(field_name): + """ + To automate the pattern described in + http://factoryboy.readthedocs.io/en/latest/recipes.html#simple-many-to-many-relationship + """ + + @factory.post_generation + def inner(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + field = getattr(self, field_name) + field.add(*extracted) + + return inner diff --git a/api/funkwhale_api/favorites/factories.py b/api/funkwhale_api/favorites/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..233dd049c5477fdb0c83719d397f41c9f62536d4 --- /dev/null +++ b/api/funkwhale_api/favorites/factories.py @@ -0,0 +1,15 @@ +import factory + +from funkwhale_api.factories import registry + +from funkwhale_api.music.factories import TrackFactory +from funkwhale_api.users.factories import UserFactory + + +@registry.register +class TrackFavorite(factory.django.DjangoModelFactory): + track = factory.SubFactory(TrackFactory) + user = factory.SubFactory(UserFactory) + + class Meta: + model = 'favorites.TrackFavorite' diff --git a/api/funkwhale_api/favorites/tests/test_favorites.py b/api/funkwhale_api/favorites/tests/test_favorites.py deleted file mode 100644 index 78c64a41338607a11830a8fdb64df758d9214ab6..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/favorites/tests/test_favorites.py +++ /dev/null @@ -1,113 +0,0 @@ -import json -from test_plus.test import TestCase -from django.urls import reverse - -from funkwhale_api.music.models import Track, Artist -from funkwhale_api.favorites.models import TrackFavorite -from funkwhale_api.users.models import User - -class TestFavorites(TestCase): - - def setUp(self): - super().setUp() - self.artist = Artist.objects.create(name='test') - self.track = Track.objects.create(title='test', artist=self.artist) - self.user = User.objects.create_user(username='test', email='test@test.com', password='test') - - def test_user_can_add_favorite(self): - TrackFavorite.add(self.track, self.user) - - favorite = TrackFavorite.objects.latest('id') - self.assertEqual(favorite.track, self.track) - self.assertEqual(favorite.user, self.user) - - def test_user_can_get_his_favorites(self): - favorite = TrackFavorite.add(self.track, self.user) - - url = reverse('api:v1:favorites:tracks-list') - self.client.login(username=self.user.username, password='test') - - response = self.client.get(url) - - expected = [ - { - 'track': self.track.pk, - 'id': favorite.id, - 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), - } - ] - parsed_json = json.loads(response.content.decode('utf-8')) - - self.assertEqual(expected, parsed_json['results']) - - def test_user_can_add_favorite_via_api(self): - url = reverse('api:v1:favorites:tracks-list') - self.client.login(username=self.user.username, password='test') - response = self.client.post(url, {'track': self.track.pk}) - - favorite = TrackFavorite.objects.latest('id') - expected = { - 'track': self.track.pk, - 'id': favorite.id, - 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), - } - parsed_json = json.loads(response.content.decode('utf-8')) - - self.assertEqual(expected, parsed_json) - self.assertEqual(favorite.track, self.track) - self.assertEqual(favorite.user, self.user) - - def test_user_can_remove_favorite_via_api(self): - favorite = TrackFavorite.add(self.track, self.user) - - url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk}) - self.client.login(username=self.user.username, password='test') - response = self.client.delete(url, {'track': self.track.pk}) - self.assertEqual(response.status_code, 204) - self.assertEqual(TrackFavorite.objects.count(), 0) - - def test_user_can_remove_favorite_via_api_using_track_id(self): - favorite = TrackFavorite.add(self.track, self.user) - - url = reverse('api:v1:favorites:tracks-remove') - self.client.login(username=self.user.username, password='test') - response = self.client.delete( - url, json.dumps({'track': self.track.pk}), - content_type='application/json' - ) - - self.assertEqual(response.status_code, 204) - self.assertEqual(TrackFavorite.objects.count(), 0) - - from funkwhale_api.users.models import User - - def test_can_restrict_api_views_to_authenticated_users(self): - urls = [ - ('api:v1:favorites:tracks-list', 'get'), - ] - - for route_name, method in urls: - url = self.reverse(route_name) - with self.settings(API_AUTHENTICATION_REQUIRED=True): - response = getattr(self.client, method)(url) - self.assertEqual(response.status_code, 401) - - self.client.login(username=self.user.username, password='test') - - for route_name, method in urls: - url = self.reverse(route_name) - with self.settings(API_AUTHENTICATION_REQUIRED=False): - response = getattr(self.client, method)(url) - self.assertEqual(response.status_code, 200) - - def test_can_filter_tracks_by_favorites(self): - favorite = TrackFavorite.add(self.track, self.user) - - url = reverse('api:v1:tracks-list') - self.client.login(username=self.user.username, password='test') - - response = self.client.get(url, data={'favorites': True}) - - parsed_json = json.loads(response.content.decode('utf-8')) - self.assertEqual(parsed_json['count'], 1) - self.assertEqual(parsed_json['results'][0]['id'], self.track.id) diff --git a/api/funkwhale_api/history/tests/factories.py b/api/funkwhale_api/history/factories.py similarity index 58% rename from api/funkwhale_api/history/tests/factories.py rename to api/funkwhale_api/history/factories.py index 0a411adf0ce02467e41fe8dd94958755606f0b95..86fea64d251c3a0228bc2a0c5edd5525e10cd7dd 100644 --- a/api/funkwhale_api/history/tests/factories.py +++ b/api/funkwhale_api/history/factories.py @@ -1,9 +1,11 @@ import factory -from funkwhale_api.music.tests import factories -from funkwhale_api.users.tests.factories import UserFactory +from funkwhale_api.factories import registry +from funkwhale_api.music import factories +from funkwhale_api.users.factories import UserFactory +@registry.register class ListeningFactory(factory.django.DjangoModelFactory): user = factory.SubFactory(UserFactory) track = factory.SubFactory(factories.TrackFactory) diff --git a/api/funkwhale_api/history/tests/test_history.py b/api/funkwhale_api/history/tests/test_history.py deleted file mode 100644 index 5cb45c946d092d8e0d58d357397581b117bb616f..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/history/tests/test_history.py +++ /dev/null @@ -1,50 +0,0 @@ -import random -import json -from test_plus.test import TestCase -from django.urls import reverse -from django.core.exceptions import ValidationError -from django.utils import timezone - -from funkwhale_api.music.tests.factories import TrackFactory - -from funkwhale_api.users.models import User -from funkwhale_api.history import models - - -class TestHistory(TestCase): - - def setUp(self): - super().setUp() - self.user = User.objects.create_user(username='test', email='test@test.com', password='test') - - def test_can_create_listening(self): - track = TrackFactory() - now = timezone.now() - l = models.Listening.objects.create(user=self.user, track=track) - - def test_anonymous_user_can_create_listening_via_api(self): - track = TrackFactory() - url = self.reverse('api:v1:history:listenings-list') - response = self.client.post(url, { - 'track': track.pk, - }) - - listening = models.Listening.objects.latest('id') - - self.assertEqual(listening.track, track) - self.assertIsNotNone(listening.session_key) - - def test_logged_in_user_can_create_listening_via_api(self): - track = TrackFactory() - - self.client.login(username=self.user.username, password='test') - - url = self.reverse('api:v1:history:listenings-list') - response = self.client.post(url, { - 'track': track.pk, - }) - - listening = models.Listening.objects.latest('id') - - self.assertEqual(listening.track, track) - self.assertEqual(listening.user, self.user) diff --git a/api/funkwhale_api/music/tests/factories.py b/api/funkwhale_api/music/factories.py similarity index 81% rename from api/funkwhale_api/music/tests/factories.py rename to api/funkwhale_api/music/factories.py index 567e2a765f426e8ca9ce4359bf6644431ef97dec..d776cd9459cd42fdbac62e7168b7262ef2718b0e 100644 --- a/api/funkwhale_api/music/tests/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -1,11 +1,16 @@ import factory import os -from funkwhale_api.users.tests.factories import UserFactory +from funkwhale_api.factories import registry, ManyToManyFromList +from funkwhale_api.users.factories import UserFactory -SAMPLES_PATH = os.path.dirname(os.path.abspath(__file__)) +SAMPLES_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'tests', 'music' +) +@registry.register class ArtistFactory(factory.django.DjangoModelFactory): name = factory.Faker('name') mbid = factory.Faker('uuid4') @@ -14,6 +19,7 @@ class ArtistFactory(factory.django.DjangoModelFactory): model = 'music.Artist' +@registry.register class AlbumFactory(factory.django.DjangoModelFactory): title = factory.Faker('sentence', nb_words=3) mbid = factory.Faker('uuid4') @@ -26,17 +32,19 @@ class AlbumFactory(factory.django.DjangoModelFactory): model = 'music.Album' +@registry.register class TrackFactory(factory.django.DjangoModelFactory): title = factory.Faker('sentence', nb_words=3) mbid = factory.Faker('uuid4') album = factory.SubFactory(AlbumFactory) artist = factory.SelfAttribute('album.artist') position = 1 - + tags = ManyToManyFromList('tags') class Meta: model = 'music.Track' +@registry.register class TrackFileFactory(factory.django.DjangoModelFactory): track = factory.SubFactory(TrackFactory) audio_file = factory.django.FileField( @@ -46,6 +54,7 @@ class TrackFileFactory(factory.django.DjangoModelFactory): model = 'music.TrackFile' +@registry.register class ImportBatchFactory(factory.django.DjangoModelFactory): submitted_by = factory.SubFactory(UserFactory) @@ -53,14 +62,17 @@ class ImportBatchFactory(factory.django.DjangoModelFactory): model = 'music.ImportBatch' +@registry.register class ImportJobFactory(factory.django.DjangoModelFactory): batch = factory.SubFactory(ImportBatchFactory) source = factory.Faker('url') + mbid = factory.Faker('uuid4') class Meta: model = 'music.ImportJob' +@registry.register class WorkFactory(factory.django.DjangoModelFactory): mbid = factory.Faker('uuid4') language = 'eng' @@ -71,6 +83,7 @@ class WorkFactory(factory.django.DjangoModelFactory): model = 'music.Work' +@registry.register class LyricsFactory(factory.django.DjangoModelFactory): work = factory.SubFactory(WorkFactory) url = factory.Faker('url') @@ -80,6 +93,7 @@ class LyricsFactory(factory.django.DjangoModelFactory): model = 'music.Lyrics' +@registry.register class TagFactory(factory.django.DjangoModelFactory): name = factory.SelfAttribute('slug') slug = factory.Faker('slug') diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index ba3fa453d77443eb75247b3987849db972963e1c..ff937a0f5c3aac8d5609b188d18b796e3b809e3a 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -8,5 +8,5 @@ class ArtistFilter(django_filters.FilterSet): class Meta: model = models.Artist fields = { - 'name': ['exact', 'iexact', 'startswith'] + 'name': ['exact', 'iexact', 'startswith', 'icontains'] } diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 43daf9d5a19404309b3ccf1f85e2e4c4713a9840..cf9d8749021ca8f5a1c03f297924de2ed1a583ca 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -13,14 +13,14 @@ class TagSerializer(serializers.ModelSerializer): class SimpleArtistSerializer(serializers.ModelSerializer): class Meta: model = models.Artist - fields = ('id', 'mbid', 'name') + fields = ('id', 'mbid', 'name', 'creation_date') class ArtistSerializer(serializers.ModelSerializer): tags = TagSerializer(many=True, read_only=True) class Meta: model = models.Artist - fields = ('id', 'mbid', 'name', 'tags') + fields = ('id', 'mbid', 'name', 'tags', 'creation_date') class TrackFileSerializer(serializers.ModelSerializer): diff --git a/api/funkwhale_api/music/tests/test_api.py b/api/funkwhale_api/music/tests/test_api.py deleted file mode 100644 index 2460fa97d0830a8b66ceaeef80f680def65b02cc..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/music/tests/test_api.py +++ /dev/null @@ -1,256 +0,0 @@ -import json -import unittest -from test_plus.test import TestCase -from django.urls import reverse - -from funkwhale_api.music import models -from funkwhale_api.utils.tests import TMPDirTestCaseMixin -from funkwhale_api.musicbrainz import api -from funkwhale_api.music import serializers -from funkwhale_api.users.models import User - -from . import data as api_data -from . import factories - - -class TestAPI(TMPDirTestCaseMixin, TestCase): - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures']) - @unittest.mock.patch('funkwhale_api.music.models.TrackFile.download_file', return_value=None) - def test_can_submit_youtube_url_for_track_import(self, *mocks): - mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' - video_id = 'tPEE9ZwTmy0' - url = reverse('api:v1:submit-single') - user = User.objects.create_superuser(username='test', email='test@test.com', password='test') - self.client.login(username=user.username, password='test') - response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid}) - track = models.Track.objects.get(mbid=mbid) - self.assertEqual(track.artist.name, 'Adhesive Wombat') - self.assertEqual(track.album.title, 'Marsupial Madness') - # self.assertIn(video_id, track.files.first().audio_file.name) - - def test_import_creates_an_import_with_correct_data(self): - user = User.objects.create_superuser(username='test', email='test@test.com', password='test') - mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' - video_id = 'tPEE9ZwTmy0' - url = reverse('api:v1:submit-single') - self.client.login(username=user.username, password='test') - with self.settings(CELERY_ALWAYS_EAGER=False): - response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid}) - - batch = models.ImportBatch.objects.latest('id') - self.assertEqual(batch.jobs.count(), 1) - self.assertEqual(batch.submitted_by, user) - self.assertEqual(batch.status, 'pending') - job = batch.jobs.first() - self.assertEqual(str(job.mbid), mbid) - self.assertEqual(job.status, 'pending') - self.assertEqual(job.source, 'https://www.youtube.com/watch?v={0}'.format(video_id)) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'') - @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize']) - def test_can_import_whole_album(self, *mocks): - user = User.objects.create_superuser(username='test', email='test@test.com', password='test') - payload = { - 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94', - 'tracks': [ - { - 'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed', - 'source': 'https://www.youtube.com/watch?v=1111111111', - }, - { - 'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed', - 'source': 'https://www.youtube.com/watch?v=2222222222', - }, - { - 'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed', - 'source': 'https://www.youtube.com/watch?v=3333333333', - }, - ] - } - url = reverse('api:v1:submit-album') - self.client.login(username=user.username, password='test') - with self.settings(CELERY_ALWAYS_EAGER=False): - response = self.client.post(url, json.dumps(payload), content_type="application/json") - - batch = models.ImportBatch.objects.latest('id') - self.assertEqual(batch.jobs.count(), 3) - self.assertEqual(batch.submitted_by, user) - self.assertEqual(batch.status, 'pending') - - album = models.Album.objects.latest('id') - self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94') - medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0] - self.assertEqual(int(medium_data['track-count']), album.tracks.all().count()) - - for track in medium_data['track-list']: - instance = models.Track.objects.get(mbid=track['recording']['id']) - self.assertEqual(instance.title, track['recording']['title']) - self.assertEqual(instance.position, int(track['position'])) - self.assertEqual(instance.title, track['recording']['title']) - - for row in payload['tracks']: - job = models.ImportJob.objects.get(mbid=row['mbid']) - self.assertEqual(str(job.mbid), row['mbid']) - self.assertEqual(job.status, 'pending') - self.assertEqual(job.source, row['source']) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'') - @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize']) - def test_can_import_whole_artist(self, *mocks): - user = User.objects.create_superuser(username='test', email='test@test.com', password='test') - payload = { - 'artistId': 'mbid', - 'albums': [ - { - 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94', - 'tracks': [ - { - 'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed', - 'source': 'https://www.youtube.com/watch?v=1111111111', - }, - { - 'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed', - 'source': 'https://www.youtube.com/watch?v=2222222222', - }, - { - 'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed', - 'source': 'https://www.youtube.com/watch?v=3333333333', - }, - ] - } - ] - } - url = reverse('api:v1:submit-artist') - self.client.login(username=user.username, password='test') - with self.settings(CELERY_ALWAYS_EAGER=False): - response = self.client.post(url, json.dumps(payload), content_type="application/json") - - batch = models.ImportBatch.objects.latest('id') - self.assertEqual(batch.jobs.count(), 3) - self.assertEqual(batch.submitted_by, user) - self.assertEqual(batch.status, 'pending') - - album = models.Album.objects.latest('id') - self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94') - medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0] - self.assertEqual(int(medium_data['track-count']), album.tracks.all().count()) - - for track in medium_data['track-list']: - instance = models.Track.objects.get(mbid=track['recording']['id']) - self.assertEqual(instance.title, track['recording']['title']) - self.assertEqual(instance.position, int(track['position'])) - self.assertEqual(instance.title, track['recording']['title']) - - for row in payload['albums'][0]['tracks']: - job = models.ImportJob.objects.get(mbid=row['mbid']) - self.assertEqual(str(job.mbid), row['mbid']) - self.assertEqual(job.status, 'pending') - self.assertEqual(job.source, row['source']) - - def test_user_can_query_api_for_his_own_batches(self): - user1 = User.objects.create_superuser(username='test1', email='test1@test.com', password='test') - user2 = User.objects.create_superuser(username='test2', email='test2@test.com', password='test') - mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' - source = 'https://www.youtube.com/watch?v=tPEE9ZwTmy0' - - batch = models.ImportBatch.objects.create(submitted_by=user1) - job = models.ImportJob.objects.create(batch=batch, mbid=mbid, source=source) - - url = reverse('api:v1:import-batches-list') - - self.client.login(username=user2.username, password='test') - response2 = self.client.get(url) - self.assertJSONEqual(response2.content.decode('utf-8'), '{"count":0,"next":null,"previous":null,"results":[]}') - self.client.logout() - - self.client.login(username=user1.username, password='test') - response1 = self.client.get(url) - self.assertIn(mbid, response1.content.decode('utf-8')) - - def test_can_search_artist(self): - artist1 = models.Artist.objects.create(name='Test1') - artist2 = models.Artist.objects.create(name='Test2') - query = 'test1' - expected = '[{0}]'.format(json.dumps(serializers.ArtistSerializerNested(artist1).data)) - url = self.reverse('api:v1:artists-search') - response = self.client.get(url + '?query={0}'.format(query)) - - self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8'))) - - def test_can_search_artist_by_name_start(self): - artist1 = factories.ArtistFactory(name='alpha') - artist2 = factories.ArtistFactory(name='beta') - results = { - 'next': None, - 'previous': None, - 'count': 1, - 'results': [serializers.ArtistSerializerNested(artist1).data] - } - expected = json.dumps(results) - url = self.reverse('api:v1:artists-list') - response = self.client.get(url, {'name__startswith': 'a'}) - - self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8'))) - - def test_can_search_tracks(self): - artist1 = models.Artist.objects.create(name='Test1') - artist2 = models.Artist.objects.create(name='Test2') - track1 = models.Track.objects.create(artist=artist1, title="test_track1") - track2 = models.Track.objects.create(artist=artist2, title="test_track2") - query = 'test track 1' - expected = '[{0}]'.format(json.dumps(serializers.TrackSerializerNested(track1).data)) - url = self.reverse('api:v1:tracks-search') - response = self.client.get(url + '?query={0}'.format(query)) - - self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8'))) - - def test_can_restrict_api_views_to_authenticated_users(self): - urls = [ - ('api:v1:tags-list', 'get'), - ('api:v1:tracks-list', 'get'), - ('api:v1:artists-list', 'get'), - ('api:v1:albums-list', 'get'), - ] - - for route_name, method in urls: - url = self.reverse(route_name) - with self.settings(API_AUTHENTICATION_REQUIRED=True): - response = getattr(self.client, method)(url) - self.assertEqual(response.status_code, 401) - - user = User.objects.create_superuser(username='test', email='test@test.com', password='test') - self.client.login(username=user.username, password='test') - - for route_name, method in urls: - url = self.reverse(route_name) - with self.settings(API_AUTHENTICATION_REQUIRED=False): - response = getattr(self.client, method)(url) - self.assertEqual(response.status_code, 200) - - def test_track_file_url_is_restricted_to_authenticated_users(self): - f = factories.TrackFileFactory() - self.assertNotEqual(f.audio_file, None) - url = f.path - - with self.settings(API_AUTHENTICATION_REQUIRED=True): - response = self.client.get(url) - - self.assertEqual(response.status_code, 401) - - user = User.objects.create_superuser( - username='test', email='test@test.com', password='test') - self.client.login(username=user.username, password='test') - with self.settings(API_AUTHENTICATION_REQUIRED=True): - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - - self.assertEqual( - response['X-Accel-Redirect'], - '/_protected{}'.format(f.audio_file.url) - ) diff --git a/api/funkwhale_api/music/tests/test_lyrics.py b/api/funkwhale_api/music/tests/test_lyrics.py deleted file mode 100644 index 9a05e5eb87b88752ea9aa81ac081a0e2981d9570..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/music/tests/test_lyrics.py +++ /dev/null @@ -1,75 +0,0 @@ -import json -import unittest -from test_plus.test import TestCase -from django.urls import reverse - -from funkwhale_api.music import models -from funkwhale_api.musicbrainz import api -from funkwhale_api.music import serializers -from funkwhale_api.users.models import User -from funkwhale_api.music import lyrics as lyrics_utils - -from .mocking import lyricswiki -from . import factories -from . import data as api_data - - - -class TestLyrics(TestCase): - - @unittest.mock.patch('funkwhale_api.music.lyrics._get_html', - return_value=lyricswiki.content) - def test_works_import_lyrics_if_any(self, *mocks): - lyrics = factories.LyricsFactory( - url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!') - - lyrics.fetch_content() - self.assertIn( - 'Grab a brush and put on a little makeup', - lyrics.content, - ) - - def test_clean_content(self): - c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>""" - d = lyrics_utils.extract_content(c) - d = lyrics_utils.clean_content(d) - - expected = """Hello -Is it me you're looking for? -""" - self.assertEqual(d, expected) - - def test_markdown_rendering(self): - content = """Hello -Is it me you're looking for?""" - - l = factories.LyricsFactory(content=content) - - expected = "<p>Hello<br />Is it me you're looking for?</p>" - self.assertHTMLEqual(expected, l.content_rendered) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', - return_value=api_data.tracks['get']['chop_suey']) - @unittest.mock.patch('funkwhale_api.music.lyrics._get_html', - return_value=lyricswiki.content) - def test_works_import_lyrics_if_any(self, *mocks): - track = factories.TrackFactory( - work=None, - mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') - - url = reverse('api:v1:tracks-lyrics', kwargs={'pk': track.pk}) - user = User.objects.create_user( - username='test', email='test@test.com', password='test') - self.client.login(username=user.username, password='test') - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - - track.refresh_from_db() - lyrics = models.Lyrics.objects.latest('id') - work = models.Work.objects.latest('id') - - self.assertEqual(track.work, work) - self.assertEqual(lyrics.work, work) diff --git a/api/funkwhale_api/music/tests/test_metadata.py b/api/funkwhale_api/music/tests/test_metadata.py deleted file mode 100644 index 9b8c7665337597c04d5770efb4bdc829f18ba380..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/music/tests/test_metadata.py +++ /dev/null @@ -1,80 +0,0 @@ -import unittest -import os -import datetime -from test_plus.test import TestCase -from funkwhale_api.music import metadata - -DATA_DIR = os.path.dirname(os.path.abspath(__file__)) - - -class TestMetadata(TestCase): - - def test_can_get_metadata_from_ogg_file(self, *mocks): - path = os.path.join(DATA_DIR, 'test.ogg') - data = metadata.Metadata(path) - - self.assertEqual( - data.get('title'), - 'Peer Gynt Suite no. 1, op. 46: I. Morning' - ) - self.assertEqual( - data.get('artist'), - 'Edvard Grieg' - ) - self.assertEqual( - data.get('album'), - 'Peer Gynt Suite no. 1, op. 46' - ) - self.assertEqual( - data.get('date'), - datetime.date(2012, 8, 15), - ) - self.assertEqual( - data.get('track_number'), - 1 - ) - - self.assertEqual( - data.get('musicbrainz_albumid'), - 'a766da8b-8336-47aa-a3ee-371cc41ccc75') - self.assertEqual( - data.get('musicbrainz_recordingid'), - 'bd21ac48-46d8-4e78-925f-d9cc2a294656') - self.assertEqual( - data.get('musicbrainz_artistid'), - '013c8e5b-d72a-4cd3-8dee-6c64d6125823') - - def test_can_get_metadata_from_id3_mp3_file(self, *mocks): - path = os.path.join(DATA_DIR, 'test.mp3') - data = metadata.Metadata(path) - - self.assertEqual( - data.get('title'), - 'Bend' - ) - self.assertEqual( - data.get('artist'), - 'Binärpilot' - ) - self.assertEqual( - data.get('album'), - 'You Can\'t Stop Da Funk' - ) - self.assertEqual( - data.get('date'), - datetime.date(2006, 2, 7), - ) - self.assertEqual( - data.get('track_number'), - 1 - ) - - self.assertEqual( - data.get('musicbrainz_albumid'), - 'ce40cdb1-a562-4fd8-a269-9269f98d4124') - self.assertEqual( - data.get('musicbrainz_recordingid'), - 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb') - self.assertEqual( - data.get('musicbrainz_artistid'), - '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13') diff --git a/api/funkwhale_api/music/tests/test_music.py b/api/funkwhale_api/music/tests/test_music.py deleted file mode 100644 index 5cf9d0cf96d53c6060759b6a8c65cf1aaef6917f..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/music/tests/test_music.py +++ /dev/null @@ -1,113 +0,0 @@ -from test_plus.test import TestCase -import unittest.mock -from funkwhale_api.music import models -import datetime - -from . import factories -from . import data as api_data -from .cover import binary_data - - -class TestMusic(TestCase): - - @unittest.mock.patch('musicbrainzngs.search_artists', return_value=api_data.artists['search']['adhesive_wombat']) - def test_can_create_artist_from_api(self, *mocks): - artist = models.Artist.create_from_api(query="Adhesive wombat") - data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0] - - self.assertEqual(int(data['ext:score']), 100) - self.assertEqual(data['id'], '62c3befb-6366-4585-b256-809472333801') - self.assertEqual(artist.mbid, data['id']) - self.assertEqual(artist.name, 'Adhesive Wombat') - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.search', return_value=api_data.albums['search']['hypnotize']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad']) - def test_can_create_album_from_api(self, *mocks): - album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album') - data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0] - - self.assertEqual(album.mbid, data['id']) - self.assertEqual(album.title, 'Hypnotize') - with self.assertRaises(ValueError): - self.assertFalse(album.cover.path is None) - self.assertEqual(album.release_date, datetime.date(2005, 1, 1)) - self.assertEqual(album.artist.name, 'System of a Down') - self.assertEqual(album.artist.mbid, data['artist-credit'][0]['artist']['id']) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures']) - def test_can_create_track_from_api(self, *mocks): - track = models.Track.create_from_api(query="8-bit adventure") - data = models.Track.api.search(query='8-bit adventure')['recording-list'][0] - self.assertEqual(int(data['ext:score']), 100) - self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed') - self.assertEqual(track.mbid, data['id']) - self.assertTrue(track.artist.pk is not None) - self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801') - self.assertEqual(track.artist.name, 'Adhesive Wombat') - self.assertEqual(str(track.album.mbid), 'a50d2a81-2a50-484d-9cb4-b9f6833f583e') - self.assertEqual(track.album.title, 'Marsupial Madness') - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures']) - def test_can_create_track_from_api_with_corresponding_tags(self, *mocks): - track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed') - expected_tags = ['techno', 'good-music'] - track_tags = [tag.slug for tag in track.tags.all()] - for tag in expected_tags: - self.assertIn(tag, track_tags) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures']) - def test_can_get_or_create_track_from_api(self, *mocks): - track = models.Track.create_from_api(query="8-bit adventure") - data = models.Track.api.search(query='8-bit adventure')['recording-list'][0] - self.assertEqual(int(data['ext:score']), 100) - self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed') - self.assertEqual(track.mbid, data['id']) - self.assertTrue(track.artist.pk is not None) - self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801') - self.assertEqual(track.artist.name, 'Adhesive Wombat') - - track2, created = models.Track.get_or_create_from_api(mbid=data['id']) - self.assertFalse(created) - self.assertEqual(track, track2) - - def test_album_tags_deduced_from_tracks_tags(self): - tag = factories.TagFactory() - album = factories.AlbumFactory() - tracks = factories.TrackFactory.create_batch(album=album, size=5) - - for track in tracks: - track.tags.add(tag) - - album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk) - - with self.assertNumQueries(0): - self.assertIn(tag, album.tags) - - def test_artist_tags_deduced_from_album_tags(self): - tag = factories.TagFactory() - artist = factories.ArtistFactory() - album = factories.AlbumFactory(artist=artist) - tracks = factories.TrackFactory.create_batch(album=album, size=5) - - for track in tracks: - track.tags.add(tag) - - artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk) - - with self.assertNumQueries(0): - self.assertIn(tag, artist.tags) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=binary_data) - def test_can_download_image_file_for_album(self, *mocks): - # client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed') - album = factories.AlbumFactory(mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed') - album.get_image() - album.save() - - self.assertEqual(album.cover.file.read(), binary_data) diff --git a/api/funkwhale_api/music/tests/test_works.py b/api/funkwhale_api/music/tests/test_works.py deleted file mode 100644 index 55714bce2c142c36b86f52c9361292df79689695..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/music/tests/test_works.py +++ /dev/null @@ -1,66 +0,0 @@ -import json -import unittest -from test_plus.test import TestCase -from django.urls import reverse - -from funkwhale_api.music import models -from funkwhale_api.musicbrainz import api -from funkwhale_api.music import serializers -from funkwhale_api.music.tests import factories -from funkwhale_api.users.models import User - -from . import data as api_data - - -class TestWorks(TestCase): - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) - def test_can_import_work(self, *mocks): - recording = factories.TrackFactory( - mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') - mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' - work = models.Work.create_from_api(id=mbid) - - self.assertEqual(work.title, 'Chop Suey!') - self.assertEqual(work.nature, 'song') - self.assertEqual(work.language, 'eng') - self.assertEqual(work.mbid, mbid) - - # a imported work should also be linked to corresponding recordings - - recording.refresh_from_db() - self.assertEqual(recording.work, work) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) - @unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', - return_value=api_data.tracks['get']['chop_suey']) - def test_can_get_work_from_recording(self, *mocks): - recording = factories.TrackFactory( - work=None, - mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') - mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' - - self.assertEqual(recording.work, None) - - work = recording.get_work() - - self.assertEqual(work.title, 'Chop Suey!') - self.assertEqual(work.nature, 'song') - self.assertEqual(work.language, 'eng') - self.assertEqual(work.mbid, mbid) - - recording.refresh_from_db() - self.assertEqual(recording.work, work) - - @unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) - def test_works_import_lyrics_if_any(self, *mocks): - mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' - work = models.Work.create_from_api(id=mbid) - - lyrics = models.Lyrics.objects.latest('id') - self.assertEqual(lyrics.work, work) - self.assertEqual( - lyrics.url, 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!') diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index c32fa8f7ff49caa1ef9fbfff533e12a32835373e..532942e2e651c0262d96b61464b3411473f90fce 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -47,16 +47,15 @@ class TagViewSetMixin(object): class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): queryset = ( models.Artist.objects.all() - .order_by('name') .prefetch_related( 'albums__tracks__files', + 'albums__tracks__artist', 'albums__tracks__tags')) serializer_class = serializers.ArtistSerializerNested permission_classes = [ConditionalAuthentication] search_fields = ['name'] - ordering_fields = ('creation_date', 'name') filter_class = filters.ArtistFilter - + ordering_fields = ('id', 'name', 'creation_date') class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): queryset = ( @@ -96,7 +95,12 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): serializer_class = serializers.TrackSerializerNested permission_classes = [ConditionalAuthentication] search_fields = ['title', 'artist__name'] - ordering_fields = ('creation_date',) + ordering_fields = ( + 'creation_date', + 'title', + 'album__title', + 'artist__name', + ) def get_queryset(self): queryset = super().get_queryset() diff --git a/api/funkwhale_api/musicbrainz/tests/__init__.py b/api/funkwhale_api/musicbrainz/tests/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/funkwhale_api/musicbrainz/tests/test_api.py b/api/funkwhale_api/musicbrainz/tests/test_api.py deleted file mode 100644 index b0911f1c54983fd0f41eda12027ea0c836d587a0..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/musicbrainz/tests/test_api.py +++ /dev/null @@ -1,87 +0,0 @@ -import json -import unittest -from test_plus.test import TestCase -from django.urls import reverse - -from funkwhale_api.musicbrainz import api -from . import data as api_data - - -class TestAPI(TestCase): - @unittest.mock.patch( - 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=api_data.recordings['search']['brontide matador']) - def test_can_search_recording_in_musicbrainz_api(self, *mocks): - query = 'brontide matador' - url = reverse('api:v1:providers:musicbrainz:search-recordings') - expected = api_data.recordings['search']['brontide matador'] - response = self.client.get(url, data={'query': query}) - - self.assertEqual(expected, json.loads(response.content.decode('utf-8'))) - - @unittest.mock.patch( - 'funkwhale_api.musicbrainz.api.releases.search', - return_value=api_data.releases['search']['brontide matador']) - def test_can_search_release_in_musicbrainz_api(self, *mocks): - query = 'brontide matador' - url = reverse('api:v1:providers:musicbrainz:search-releases') - expected = api_data.releases['search']['brontide matador'] - response = self.client.get(url, data={'query': query}) - - self.assertEqual(expected, json.loads(response.content.decode('utf-8'))) - - @unittest.mock.patch( - 'funkwhale_api.musicbrainz.api.artists.search', - return_value=api_data.artists['search']['lost fingers']) - def test_can_search_artists_in_musicbrainz_api(self, *mocks): - query = 'lost fingers' - url = reverse('api:v1:providers:musicbrainz:search-artists') - expected = api_data.artists['search']['lost fingers'] - response = self.client.get(url, data={'query': query}) - - self.assertEqual(expected, json.loads(response.content.decode('utf-8'))) - - @unittest.mock.patch( - 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['lost fingers']) - def test_can_get_artist_in_musicbrainz_api(self, *mocks): - uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9' - url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={ - 'uuid': uuid, - }) - response = self.client.get(url) - expected = api_data.artists['get']['lost fingers'] - - self.assertEqual(expected, json.loads(response.content.decode('utf-8'))) - - @unittest.mock.patch( - 'funkwhale_api.musicbrainz.api.release_groups.browse', - return_value=api_data.release_groups['browse']['lost fingers']) - def test_can_broswe_release_group_using_musicbrainz_api(self, *mocks): - uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9' - url = reverse( - 'api:v1:providers:musicbrainz:release-group-browse', - kwargs={ - 'artist_uuid': uuid, - } - ) - response = self.client.get(url) - expected = api_data.release_groups['browse']['lost fingers'] - - self.assertEqual(expected, json.loads(response.content.decode('utf-8'))) - - @unittest.mock.patch( - 'funkwhale_api.musicbrainz.api.releases.browse', - return_value=api_data.releases['browse']['Lost in the 80s']) - def test_can_broswe_releases_using_musicbrainz_api(self, *mocks): - uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1' - url = reverse( - 'api:v1:providers:musicbrainz:release-browse', - kwargs={ - 'release_group_uuid': uuid, - } - ) - response = self.client.get(url) - expected = api_data.releases['browse']['Lost in the 80s'] - - self.assertEqual(expected, json.loads(response.content.decode('utf-8'))) diff --git a/api/funkwhale_api/musicbrainz/tests/test_cache.py b/api/funkwhale_api/musicbrainz/tests/test_cache.py deleted file mode 100644 index d2d1260ecb1cb36dc95e5268d269b0d8dbcf774c..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/musicbrainz/tests/test_cache.py +++ /dev/null @@ -1,17 +0,0 @@ -import unittest -from test_plus.test import TestCase - -from funkwhale_api.musicbrainz import client - - -class TestAPI(TestCase): - def test_can_search_recording_in_musicbrainz_api(self, *mocks): - r = {'hello': 'world'} - mocked = 'funkwhale_api.musicbrainz.client._api.search_artists' - with unittest.mock.patch(mocked, return_value=r) as m: - self.assertEqual(client.api.artists.search('test'), r) - # now call from cache - self.assertEqual(client.api.artists.search('test'), r) - self.assertEqual(client.api.artists.search('test'), r) - - self.assertEqual(m.call_count, 1) diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..19e4770cfae15fd6d790063026dbdb04770b2e1b --- /dev/null +++ b/api/funkwhale_api/playlists/factories.py @@ -0,0 +1,13 @@ +import factory + +from funkwhale_api.factories import registry +from funkwhale_api.users.factories import UserFactory + + +@registry.register +class PlaylistFactory(factory.django.DjangoModelFactory): + name = factory.Faker('name') + user = factory.SubFactory(UserFactory) + + class Meta: + model = 'playlists.Playlist' diff --git a/api/funkwhale_api/playlists/tests/__init__.py b/api/funkwhale_api/playlists/tests/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/funkwhale_api/playlists/tests/test_playlists.py b/api/funkwhale_api/playlists/tests/test_playlists.py deleted file mode 100644 index 2f61889ee13d5afbc949ba36a06e9b30b6dbfad7..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/playlists/tests/test_playlists.py +++ /dev/null @@ -1,64 +0,0 @@ -import json -from test_plus.test import TestCase -from django.urls import reverse -from django.core.exceptions import ValidationError -from django.utils import timezone - -from funkwhale_api.music.tests import factories -from funkwhale_api.users.models import User -from funkwhale_api.playlists import models -from funkwhale_api.playlists.serializers import PlaylistSerializer - - -class TestPlayLists(TestCase): - - def setUp(self): - super().setUp() - self.user = User.objects.create_user(username='test', email='test@test.com', password='test') - - def test_can_create_playlist(self): - tracks = factories.TrackFactory.create_batch(size=5) - playlist = models.Playlist.objects.create(user=self.user, name="test") - - previous = None - for i in range(len(tracks)): - previous = playlist.add_track(tracks[i], previous=previous) - - playlist_tracks = list(playlist.playlist_tracks.all()) - - previous = None - for idx, track in enumerate(tracks): - plt = playlist_tracks[idx] - self.assertEqual(plt.position, idx) - self.assertEqual(plt.track, track) - if previous: - self.assertEqual(playlist_tracks[idx + 1], previous) - self.assertEqual(plt.playlist, playlist) - - def test_can_create_playlist_via_api(self): - self.client.login(username=self.user.username, password='test') - url = reverse('api:v1:playlists-list') - data = { - 'name': 'test', - } - - response = self.client.post(url, data) - - playlist = self.user.playlists.latest('id') - self.assertEqual(playlist.name, 'test') - - def test_can_add_playlist_track_via_api(self): - tracks = factories.TrackFactory.create_batch(size=5) - playlist = models.Playlist.objects.create(user=self.user, name="test") - - self.client.login(username=self.user.username, password='test') - - url = reverse('api:v1:playlist-tracks-list') - data = { - 'playlist': playlist.pk, - 'track': tracks[0].pk - } - - response = self.client.post(url, data) - plts = self.user.playlists.latest('id').playlist_tracks.all() - self.assertEqual(plts.first().track, tracks[0]) diff --git a/api/funkwhale_api/providers/audiofile/tests/__init__.py b/api/funkwhale_api/providers/audiofile/tests/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py b/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py deleted file mode 100644 index f8d36986a3170fea19b6be1b9030b354fddcba29..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/providers/audiofile/tests/test_disk_import.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -import datetime -import unittest -from test_plus.test import TestCase - -from funkwhale_api.providers.audiofile import tasks - -DATA_DIR = os.path.dirname(os.path.abspath(__file__)) - - -class TestAudioFile(TestCase): - def test_can_import_single_audio_file(self, *mocks): - metadata = { - 'artist': ['Test artist'], - 'album': ['Test album'], - 'title': ['Test track'], - 'TRACKNUMBER': ['4'], - 'date': ['2012-08-15'], - 'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'], - 'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'], - 'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'], - } - - m1 = unittest.mock.patch('mutagen.File', return_value=metadata) - m2 = unittest.mock.patch( - 'funkwhale_api.music.metadata.Metadata.get_file_type', - return_value='OggVorbis', - ) - with m1, m2: - track_file = tasks.from_path( - os.path.join(DATA_DIR, 'dummy_file.ogg')) - - self.assertEqual( - track_file.track.title, metadata['title'][0]) - self.assertEqual( - track_file.track.mbid, metadata['musicbrainz_trackid'][0]) - self.assertEqual( - track_file.track.position, 4) - self.assertEqual( - track_file.track.album.title, metadata['album'][0]) - self.assertEqual( - track_file.track.album.mbid, - metadata['musicbrainz_albumid'][0]) - self.assertEqual( - track_file.track.album.release_date, datetime.date(2012, 8, 15)) - self.assertEqual( - track_file.track.artist.name, metadata['artist'][0]) - self.assertEqual( - track_file.track.artist.mbid, - metadata['musicbrainz_artistid'][0]) diff --git a/api/funkwhale_api/providers/youtube/tests/__init__.py b/api/funkwhale_api/providers/youtube/tests/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/funkwhale_api/providers/youtube/tests/test_youtube.py b/api/funkwhale_api/providers/youtube/tests/test_youtube.py deleted file mode 100644 index 8a1dd1eb70a601e56082ec7dce8fe3cae32c7095..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/providers/youtube/tests/test_youtube.py +++ /dev/null @@ -1,99 +0,0 @@ -import json -from collections import OrderedDict -import unittest -from test_plus.test import TestCase -from django.urls import reverse -from funkwhale_api.providers.youtube.client import client - -from . import data as api_data - -class TestAPI(TestCase): - maxDiff = None - @unittest.mock.patch( - 'funkwhale_api.providers.youtube.client._do_search', - return_value=api_data.search['8 bit adventure']) - def test_can_get_search_results_from_youtube(self, *mocks): - query = '8 bit adventure' - - results = client.search(query) - self.assertEqual(results[0]['id']['videoId'], '0HxZn6CzOIo') - self.assertEqual(results[0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure') - self.assertEqual(results[0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo') - - @unittest.mock.patch( - 'funkwhale_api.providers.youtube.client._do_search', - return_value=api_data.search['8 bit adventure']) - def test_can_get_search_results_from_funkwhale(self, *mocks): - query = '8 bit adventure' - url = self.reverse('api:v1:providers:youtube:search') - response = self.client.get(url + '?query={0}'.format(query)) - # we should cast the youtube result to something more generic - expected = { - "id": "0HxZn6CzOIo", - "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", - "type": "youtube#video", - "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", - "channelId": "UCps63j3krzAG4OyXeEyuhFw", - "title": "AdhesiveWombat - 8 Bit Adventure", - "channelTitle": "AdhesiveWombat", - "publishedAt": "2012-08-22T18:41:03.000Z", - "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" - } - - self.assertEqual( - json.loads(response.content.decode('utf-8'))[0], expected) - - @unittest.mock.patch( - 'funkwhale_api.providers.youtube.client._do_search', - side_effect=[ - api_data.search['8 bit adventure'], - api_data.search['system of a down toxicity'], - ] - ) - def test_can_send_multiple_queries_at_once(self, *mocks): - queries = OrderedDict() - queries['1'] = { - 'q': '8 bit adventure', - } - queries['2'] = { - 'q': 'system of a down toxicity', - } - - results = client.search_multiple(queries) - - self.assertEqual(results['1'][0]['id']['videoId'], '0HxZn6CzOIo') - self.assertEqual(results['1'][0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure') - self.assertEqual(results['1'][0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo') - self.assertEqual(results['2'][0]['id']['videoId'], 'BorYwGi2SJc') - self.assertEqual(results['2'][0]['snippet']['title'], 'System of a Down: Toxicity') - self.assertEqual(results['2'][0]['full_url'], 'https://www.youtube.com/watch?v=BorYwGi2SJc') - - @unittest.mock.patch( - 'funkwhale_api.providers.youtube.client._do_search', - return_value=api_data.search['8 bit adventure'], - ) - def test_can_send_multiple_queries_at_once_from_funwkhale(self, *mocks): - queries = OrderedDict() - queries['1'] = { - 'q': '8 bit adventure', - } - - expected = { - "id": "0HxZn6CzOIo", - "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", - "type": "youtube#video", - "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", - "channelId": "UCps63j3krzAG4OyXeEyuhFw", - "title": "AdhesiveWombat - 8 Bit Adventure", - "channelTitle": "AdhesiveWombat", - "publishedAt": "2012-08-22T18:41:03.000Z", - "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" - } - - url = self.reverse('api:v1:providers:youtube:searchs') - response = self.client.post( - url, json.dumps(queries), content_type='application/json') - - self.assertEqual( - expected, - json.loads(response.content.decode('utf-8'))['1'][0]) diff --git a/api/funkwhale_api/radios/tests/__init__.py b/api/funkwhale_api/radios/tests/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/funkwhale_api/radios/tests/test_radios.py b/api/funkwhale_api/radios/tests/test_radios.py deleted file mode 100644 index ab27d45166a343c3bb731608db750312b934d241..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/radios/tests/test_radios.py +++ /dev/null @@ -1,196 +0,0 @@ -import random -import json -from test_plus.test import TestCase -from django.urls import reverse -from django.core.exceptions import ValidationError - - -from funkwhale_api.radios import radios -from funkwhale_api.radios import models -from funkwhale_api.favorites.models import TrackFavorite -from funkwhale_api.users.models import User -from funkwhale_api.music.models import Artist -from funkwhale_api.music.tests import factories -from funkwhale_api.history.tests.factories import ListeningFactory - - -class TestRadios(TestCase): - - def setUp(self): - super().setUp() - self.user = User.objects.create_user(username='test', email='test@test.com', password='test') - - def test_can_pick_track_from_choices(self): - choices = [1, 2, 3, 4, 5] - - radio = radios.SimpleRadio() - - first_pick = radio.pick(choices=choices) - - self.assertIn(first_pick, choices) - - previous_choices = [first_pick] - for remaining_choice in choices: - pick = radio.pick(choices=choices, previous_choices=previous_choices) - self.assertIn(pick, set(choices).difference(previous_choices)) - - def test_can_pick_by_weight(self): - choices_with_weight = [ - # choice, weight - (1, 1), - (2, 2), - (3, 3), - (4, 4), - (5, 5), - ] - - picks = {choice: 0 for choice, weight in choices_with_weight} - - for i in range(1000): - radio = radios.SimpleRadio() - pick = radio.weighted_pick(choices=choices_with_weight) - picks[pick] = picks[pick] + 1 - - self.assertTrue(picks[5] > picks[4]) - self.assertTrue(picks[4] > picks[3]) - self.assertTrue(picks[3] > picks[2]) - self.assertTrue(picks[2] > picks[1]) - - def test_can_get_choices_for_favorites_radio(self): - tracks = factories.TrackFactory.create_batch(size=100) - - for i in range(20): - TrackFavorite.add(track=random.choice(tracks), user=self.user) - - radio = radios.FavoritesRadio() - choices = radio.get_choices(user=self.user) - - self.assertEqual(choices.count(), self.user.track_favorites.all().count()) - - for favorite in self.user.track_favorites.all(): - self.assertIn(favorite.track, choices) - - for i in range(20): - pick = radio.pick(user=self.user) - self.assertIn(pick, choices) - - def test_can_use_radio_session_to_filter_choices(self): - tracks = factories.TrackFactory.create_batch(size=30) - radio = radios.RandomRadio() - session = radio.start_session(self.user) - - for i in range(30): - p = radio.pick() - - # ensure 30 differents tracks have been suggested - tracks_id = [session_track.track.pk for session_track in session.session_tracks.all()] - self.assertEqual(len(set(tracks_id)), 30) - - def test_can_restore_radio_from_previous_session(self): - tracks = factories.TrackFactory.create_batch(size=30) - - radio = radios.RandomRadio() - session = radio.start_session(self.user) - - restarted_radio = radios.RandomRadio(session) - self.assertEqual(radio.session, restarted_radio.session) - - def test_can_get_start_radio_from_api(self): - url = reverse('api:v1:radios:sessions-list') - response = self.client.post(url, {'radio_type': 'random'}) - session = models.RadioSession.objects.latest('id') - self.assertEqual(session.radio_type, 'random') - self.assertEqual(session.user, None) - - self.client.login(username=self.user.username, password='test') - response = self.client.post(url, {'radio_type': 'random'}) - session = models.RadioSession.objects.latest('id') - self.assertEqual(session.radio_type, 'random') - self.assertEqual(session.user, self.user) - - def test_can_start_radio_for_anonymous_user(self): - url = reverse('api:v1:radios:sessions-list') - response = self.client.post(url, {'radio_type': 'random'}) - session = models.RadioSession.objects.latest('id') - - self.assertIsNone(session.user) - self.assertIsNotNone(session.session_key) - - def test_can_get_track_for_session_from_api(self): - tracks = factories.TrackFactory.create_batch(size=1) - - self.client.login(username=self.user.username, password='test') - url = reverse('api:v1:radios:sessions-list') - response = self.client.post(url, {'radio_type': 'random'}) - session = models.RadioSession.objects.latest('id') - - url = reverse('api:v1:radios:tracks-list') - response = self.client.post(url, {'session': session.pk}) - data = json.loads(response.content.decode('utf-8')) - - self.assertEqual(data['track']['id'], tracks[0].id) - self.assertEqual(data['position'], 1) - - next_track = factories.TrackFactory() - response = self.client.post(url, {'session': session.pk}) - data = json.loads(response.content.decode('utf-8')) - - self.assertEqual(data['track']['id'], next_track.id) - self.assertEqual(data['position'], 2) - - def test_related_object_radio_validate_related_object(self): - # cannot start without related object - radio = radios.ArtistRadio() - with self.assertRaises(ValidationError): - radio.start_session(self.user) - - # cannot start with bad related object type - radio = radios.ArtistRadio() - with self.assertRaises(ValidationError): - radio.start_session(self.user, related_object=self.user) - - def test_can_start_artist_radio(self): - artist = factories.ArtistFactory() - wrong_tracks = factories.TrackFactory.create_batch(size=30) - good_tracks = factories.TrackFactory.create_batch( - artist=artist, size=5) - - radio = radios.ArtistRadio() - session = radio.start_session(self.user, related_object=artist) - self.assertEqual(session.radio_type, 'artist') - for i in range(5): - self.assertIn(radio.pick(), good_tracks) - - def test_can_start_tag_radio(self): - tag = factories.TagFactory() - wrong_tracks = factories.TrackFactory.create_batch(size=30) - good_tracks = factories.TrackFactory.create_batch(size=5) - for track in good_tracks: - track.tags.add(tag) - - radio = radios.TagRadio() - session = radio.start_session(self.user, related_object=tag) - self.assertEqual(session.radio_type, 'tag') - for i in range(5): - self.assertIn(radio.pick(), good_tracks) - - def test_can_start_artist_radio_from_api(self): - artist = factories.ArtistFactory() - url = reverse('api:v1:radios:sessions-list') - - response = self.client.post(url, {'radio_type': 'artist', 'related_object_id': artist.id}) - session = models.RadioSession.objects.latest('id') - self.assertEqual(session.radio_type, 'artist') - self.assertEqual(session.related_object, artist) - - def test_can_start_less_listened_radio(self): - history = ListeningFactory.create_batch(size=5, user=self.user) - wrong_tracks = [h.track for h in history] - - good_tracks = factories.TrackFactory.create_batch(size=30) - - radio = radios.LessListenedRadio() - session = radio.start_session(self.user) - self.assertEqual(session.related_object, self.user) - for i in range(5): - self.assertIn(radio.pick(), good_tracks) diff --git a/api/funkwhale_api/users/tests/factories.py b/api/funkwhale_api/users/factories.py similarity index 82% rename from api/funkwhale_api/users/tests/factories.py rename to api/funkwhale_api/users/factories.py index 351884ff4543a6625cb24dba904ff6c2b415bae3..0af155e77339177323d46f32f2ad9a7e6cf99f5c 100644 --- a/api/funkwhale_api/users/tests/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -1,12 +1,14 @@ import factory +from funkwhale_api.factories import registry from django.contrib.auth.models import Permission +@registry.register class UserFactory(factory.django.DjangoModelFactory): username = factory.Sequence(lambda n: 'user-{0}'.format(n)) email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n)) - password = factory.PostGenerationMethodCall('set_password', 'password') + password = factory.PostGenerationMethodCall('set_password', 'test') class Meta: model = 'users.User' @@ -28,3 +30,9 @@ class UserFactory(factory.django.DjangoModelFactory): ] # A list of permissions were passed in, use them self.user_permissions.add(*perms) + + +@registry.register(name='users.SuperUser') +class SuperUserFactory(UserFactory): + is_staff = True + is_superuser = True diff --git a/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py new file mode 100644 index 0000000000000000000000000000000000000000..fd75795d3fae3b63cb1e5f8830d1aaec7acf2118 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0 on 2017-12-26 13:57 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_auto_20171214_2205'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='secret_key', + field=models.UUIDField(default=uuid.uuid4, null=True), + ), + migrations.AlterField( + model_name='user', + name='last_name', + field=models.CharField(blank=True, max_length=150, verbose_name='last name'), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index c8d0b534c8b84706fcd050eee819e889e186f525..3a0baf11a30c73397b2c31d14fe1ec29d9557a7c 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import +import uuid + from django.contrib.auth.models import AbstractUser from django.urls import reverse from django.db import models @@ -15,6 +17,8 @@ class User(AbstractUser): # around the globe. name = models.CharField(_("Name of User"), blank=True, max_length=255) + # updated on logout or password change, to invalidate JWT + secret_key = models.UUIDField(default=uuid.uuid4, null=True) # permissions that are used for API access and that worth serializing relevant_permissions = { # internal_codename : {external_codename} @@ -31,3 +35,11 @@ class User(AbstractUser): def get_absolute_url(self): return reverse('users:detail', kwargs={'username': self.username}) + + def update_secret_key(self): + self.secret_key = uuid.uuid4() + return self.secret_key + + def set_password(self, raw_password): + super().set_password(raw_password) + self.update_secret_key() diff --git a/api/funkwhale_api/users/rest_auth_urls.py b/api/funkwhale_api/users/rest_auth_urls.py index 9770e69e467cfb6ed377ff09ccbf6e81975be213..31f5384aa7f2a750bcaa4fc9063658876fbbd968 100644 --- a/api/funkwhale_api/users/rest_auth_urls.py +++ b/api/funkwhale_api/users/rest_auth_urls.py @@ -2,11 +2,15 @@ from django.views.generic import TemplateView from django.conf.urls import url from rest_auth.registration.views import VerifyEmailView +from rest_auth.views import PasswordChangeView + from .views import RegisterView + urlpatterns = [ url(r'^$', RegisterView.as_view(), name='rest_register'), url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'), + url(r'^change-password/$', PasswordChangeView.as_view(), name='change_password'), # This url is used by django-allauth and empty TemplateView is # defined just to allow reverse() call inside app, for example when email diff --git a/api/funkwhale_api/users/tests/__init__.py b/api/funkwhale_api/users/tests/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/funkwhale_api/users/tests/test_admin.py b/api/funkwhale_api/users/tests/test_admin.py deleted file mode 100644 index 10b07b749dc56476687502d86cb9fd6e99d71274..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/users/tests/test_admin.py +++ /dev/null @@ -1,40 +0,0 @@ -from test_plus.test import TestCase - -from ..admin import MyUserCreationForm - - -class TestMyUserCreationForm(TestCase): - - def setUp(self): - self.user = self.make_user() - - def test_clean_username_success(self): - # Instantiate the form with a new username - form = MyUserCreationForm({ - 'username': 'alamode', - 'password1': '123456', - 'password2': '123456', - }) - # Run is_valid() to trigger the validation - valid = form.is_valid() - self.assertTrue(valid) - - # Run the actual clean_username method - username = form.clean_username() - self.assertEqual('alamode', username) - - def test_clean_username_false(self): - # Instantiate the form with the same username as self.user - form = MyUserCreationForm({ - 'username': self.user.username, - 'password1': '123456', - 'password2': '123456', - }) - # Run is_valid() to trigger the validation, which is going to fail - # because the username is already taken - valid = form.is_valid() - self.assertFalse(valid) - - # The form.errors dict should contain a single error called 'username' - self.assertTrue(len(form.errors) == 1) - self.assertTrue('username' in form.errors) diff --git a/api/funkwhale_api/users/tests/test_models.py b/api/funkwhale_api/users/tests/test_models.py deleted file mode 100644 index fbc7eb5f9a944c82be63d3beb9fe4a2ef958afff..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/users/tests/test_models.py +++ /dev/null @@ -1,13 +0,0 @@ -from test_plus.test import TestCase - - -class TestUser(TestCase): - - def setUp(self): - self.user = self.make_user() - - def test__str__(self): - self.assertEqual( - self.user.__str__(), - "testuser" # This is the default username for self.make_user() - ) diff --git a/api/funkwhale_api/users/tests/test_views.py b/api/funkwhale_api/users/tests/test_views.py deleted file mode 100644 index 52826cfa4b53da83487d86bea8ca0899d5e2e97c..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/users/tests/test_views.py +++ /dev/null @@ -1,73 +0,0 @@ -import json - -from django.test import RequestFactory - -from test_plus.test import TestCase -from funkwhale_api.users.models import User - -from . factories import UserFactory - - -class UserTestCase(TestCase): - - def setUp(self): - self.user = self.make_user() - self.factory = RequestFactory() - - def test_can_create_user_via_api(self): - url = self.reverse('rest_register') - data = { - 'username': 'test1', - 'email': 'test1@test.com', - 'password1': 'testtest', - 'password2': 'testtest', - } - with self.settings(REGISTRATION_MODE="public"): - response = self.client.post(url, data) - self.assertEqual(response.status_code, 201) - - u = User.objects.get(email='test1@test.com') - self.assertEqual(u.username, 'test1') - - def test_can_disable_registration_view(self): - url = self.reverse('rest_register') - data = { - 'username': 'test1', - 'email': 'test1@test.com', - 'password1': 'testtest', - 'password2': 'testtest', - } - with self.settings(REGISTRATION_MODE="disabled"): - response = self.client.post(url, data) - self.assertEqual(response.status_code, 403) - - def test_can_fetch_data_from_api(self): - url = self.reverse('api:v1:users:users-me') - response = self.client.get(url) - # login required - self.assertEqual(response.status_code, 401) - - user = UserFactory( - is_staff=True, - perms=[ - 'music.add_importbatch', - 'dynamic_preferences.change_globalpreferencemodel', - ] - ) - self.assertTrue(user.has_perm('music.add_importbatch')) - self.login(user) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - payload = json.loads(response.content.decode('utf-8')) - - self.assertEqual(payload['username'], user.username) - self.assertEqual(payload['is_staff'], user.is_staff) - self.assertEqual(payload['is_superuser'], user.is_superuser) - self.assertEqual(payload['email'], user.email) - self.assertEqual(payload['name'], user.name) - self.assertEqual( - payload['permissions']['import.launch']['status'], True) - self.assertEqual( - payload['permissions']['settings.change']['status'], True) diff --git a/api/funkwhale_api/utils/tests.py b/api/funkwhale_api/utils/tests.py deleted file mode 100644 index 2605d3b4c9ff0c5b78393ca515afa9efde6b5308..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/utils/tests.py +++ /dev/null @@ -1,12 +0,0 @@ -import tempfile -import shutil - - -class TMPDirTestCaseMixin(object): - def setUp(self): - super().tearDown() - self.download_dir = tempfile.mkdtemp() - - def tearDown(self): - super().tearDown() - shutil.rmtree(self.download_dir) diff --git a/api/pytest.ini b/api/pytest.ini index 4ab907403097c796663f02d6a878ccd197970a23..9be63d3531626526f46f196b840c02a9cae1c8b3 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -3,3 +3,4 @@ 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 aee1222591882c83062e190a2a7c8b36a10dc7d7..7e56b6cfda24f977288de6ed3b91a5df42f1fde0 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -49,7 +49,7 @@ mutagen>=1.39,<1.40 # Until this is merged #django-taggit>=0.22,<0.23 -git+https://github.com/jdufresne/django-taggit.git@e8f7f216f04c9781bebc84363ab24d575f948ede +git+https://github.com/alex/django-taggit.git@95776ac66948ed7ba7c12e35c1170551e3be66a5 # Until this is merged git+https://github.com/EliotBerriot/PyMemoize.git@django # Until this is merged diff --git a/api/requirements/local.txt b/api/requirements/local.txt index d8a1561e0a31705150d00bdf48ffeaf3431ee4da..b466b20fdfc20c5815fb2f7be8daedccc3f360ac 100644 --- a/api/requirements/local.txt +++ b/api/requirements/local.txt @@ -5,7 +5,6 @@ django_coverage_plugin>=1.5,<1.6 Sphinx>=1.6,<1.7 django-extensions>=1.9,<1.10 Werkzeug>=0.13,<0.14 -django-test-plus>=1.0.20 factory_boy>=2.8.1 # django-debug-toolbar that works with Django 1.5+ diff --git a/api/requirements/test.txt b/api/requirements/test.txt index bde5a2df97b8b21f2a1d7dfe20ad4e18d38eea1c..c12b44827ebbf9440e180de7d686b8f2e190118c 100644 --- a/api/requirements/test.txt +++ b/api/requirements/test.txt @@ -2,7 +2,10 @@ flake8 pytest -pytest-django +# pytest-django until a new release containing django_assert_num_queries +# is deployed to pypi +git+https://github.com/pytest-dev/pytest-django.git@d3d9bb3ef6f0377cb5356eb368992a0834692378 + pytest-mock pytest-sugar pytest-xdist diff --git a/api/funkwhale_api/downloader/tests/__init__.py b/api/tests/__init__.py similarity index 100% rename from api/funkwhale_api/downloader/tests/__init__.py rename to api/tests/__init__.py diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..37f3dae3afd72f88db2d7f1c2a9c5a40a6f43cec --- /dev/null +++ b/api/tests/conftest.py @@ -0,0 +1,42 @@ +import tempfile +import shutil +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def factories_autodiscover(): + from django.apps import apps + from funkwhale_api import factories + app_names = [app.name for app in apps.app_configs.values()] + factories.registry.autodiscover(app_names) + + +@pytest.fixture +def factories(db): + from funkwhale_api import factories + yield factories.registry + + +@pytest.fixture +def tmpdir(): + d = tempfile.mkdtemp() + yield d + shutil.rmtree(d) + + +@pytest.fixture +def logged_in_client(db, factories, client): + user = factories['users.User']() + assert client.login(username=user.username, password='test') + setattr(client, 'user', user) + yield client + delattr(client, 'user') + + +@pytest.fixture +def superuser_client(db, factories, client): + user = factories['users.SuperUser']() + assert client.login(username=user.username, password='test') + setattr(client, 'user', user) + yield client + delattr(client, 'user') diff --git a/api/funkwhale_api/providers/youtube/tests/data.py b/api/tests/data/youtube.py similarity index 100% rename from api/funkwhale_api/providers/youtube/tests/data.py rename to api/tests/data/youtube.py diff --git a/api/funkwhale_api/providers/audiofile/tests/dummy_file.ogg b/api/tests/files/dummy_file.ogg similarity index 100% rename from api/funkwhale_api/providers/audiofile/tests/dummy_file.ogg rename to api/tests/files/dummy_file.ogg diff --git a/api/funkwhale_api/favorites/tests/__init__.py b/api/tests/music/__init__.py similarity index 100% rename from api/funkwhale_api/favorites/tests/__init__.py rename to api/tests/music/__init__.py diff --git a/api/funkwhale_api/music/tests/cover.py b/api/tests/music/cover.py similarity index 100% rename from api/funkwhale_api/music/tests/cover.py rename to api/tests/music/cover.py diff --git a/api/funkwhale_api/music/tests/data.py b/api/tests/music/data.py similarity index 100% rename from api/funkwhale_api/music/tests/data.py rename to api/tests/music/data.py diff --git a/api/funkwhale_api/music/tests/mocking/lyricswiki.py b/api/tests/music/mocking/lyricswiki.py similarity index 100% rename from api/funkwhale_api/music/tests/mocking/lyricswiki.py rename to api/tests/music/mocking/lyricswiki.py diff --git a/api/funkwhale_api/music/tests/test.mp3 b/api/tests/music/test.mp3 similarity index 100% rename from api/funkwhale_api/music/tests/test.mp3 rename to api/tests/music/test.mp3 diff --git a/api/funkwhale_api/music/tests/test.ogg b/api/tests/music/test.ogg similarity index 100% rename from api/funkwhale_api/music/tests/test.ogg rename to api/tests/music/test.ogg diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..e29aaf107c922f117e63c2f67c5ca613bce6733b --- /dev/null +++ b/api/tests/music/test_api.py @@ -0,0 +1,253 @@ +import json +import pytest +from django.urls import reverse + +from funkwhale_api.music import models +from funkwhale_api.musicbrainz import api +from funkwhale_api.music import serializers + +from . import data as api_data + + +def test_can_submit_youtube_url_for_track_import(mocker, superuser_client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['adhesive_wombat']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.get', + return_value=api_data.albums['get']['marsupial']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.recordings.get', + return_value=api_data.tracks['get']['8bitadventures']) + mocker.patch( + 'funkwhale_api.music.models.TrackFile.download_file', + return_value=None) + mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' + video_id = 'tPEE9ZwTmy0' + url = reverse('api:v1:submit-single') + response = superuser_client.post( + url, + {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), + 'mbid': mbid}) + track = models.Track.objects.get(mbid=mbid) + assert track.artist.name == 'Adhesive Wombat' + assert track.album.title == 'Marsupial Madness' + + +def test_import_creates_an_import_with_correct_data(superuser_client, settings): + mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' + video_id = 'tPEE9ZwTmy0' + url = reverse('api:v1:submit-single') + settings.CELERY_ALWAYS_EAGER = False + response = superuser_client.post( + url, + {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), + 'mbid': mbid}) + + batch = models.ImportBatch.objects.latest('id') + assert batch.jobs.count() == 1 + assert batch.submitted_by == superuser_client.user + assert batch.status == 'pending' + job = batch.jobs.first() + assert str(job.mbid) == mbid + assert job.status == 'pending' + assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id) + + +def test_can_import_whole_album(mocker, superuser_client, settings): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['soad']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.images.get_front', + return_value=b'') + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.get', + return_value=api_data.albums['get_with_includes']['hypnotize']) + payload = { + 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94', + 'tracks': [ + { + 'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed', + 'source': 'https://www.youtube.com/watch?v=1111111111', + }, + { + 'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed', + 'source': 'https://www.youtube.com/watch?v=2222222222', + }, + { + 'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed', + 'source': 'https://www.youtube.com/watch?v=3333333333', + }, + ] + } + url = reverse('api:v1:submit-album') + settings.CELERY_ALWAYS_EAGER = False + response = superuser_client.post( + url, json.dumps(payload), content_type="application/json") + + batch = models.ImportBatch.objects.latest('id') + assert batch.jobs.count() == 3 + assert batch.submitted_by == superuser_client.user + assert batch.status == 'pending' + + album = models.Album.objects.latest('id') + assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94' + medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0] + assert int(medium_data['track-count']) == album.tracks.all().count() + + for track in medium_data['track-list']: + instance = models.Track.objects.get(mbid=track['recording']['id']) + assert instance.title == track['recording']['title'] + assert instance.position == int(track['position']) + assert instance.title == track['recording']['title'] + + for row in payload['tracks']: + job = models.ImportJob.objects.get(mbid=row['mbid']) + assert str(job.mbid) == row['mbid'] + assert job.status == 'pending' + assert job.source == row['source'] + + +def test_can_import_whole_artist(mocker, superuser_client, settings): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['soad']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.images.get_front', + return_value=b'') + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.get', + return_value=api_data.albums['get_with_includes']['hypnotize']) + payload = { + 'artistId': 'mbid', + 'albums': [ + { + 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94', + 'tracks': [ + { + 'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed', + 'source': 'https://www.youtube.com/watch?v=1111111111', + }, + { + 'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed', + 'source': 'https://www.youtube.com/watch?v=2222222222', + }, + { + 'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed', + 'source': 'https://www.youtube.com/watch?v=3333333333', + }, + ] + } + ] + } + url = reverse('api:v1:submit-artist') + settings.CELERY_ALWAYS_EAGER = False + response = superuser_client.post( + url, json.dumps(payload), content_type="application/json") + + batch = models.ImportBatch.objects.latest('id') + assert batch.jobs.count() == 3 + assert batch.submitted_by == superuser_client.user + assert batch.status == 'pending' + + album = models.Album.objects.latest('id') + assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94' + medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0] + assert int(medium_data['track-count']) == album.tracks.all().count() + + for track in medium_data['track-list']: + instance = models.Track.objects.get(mbid=track['recording']['id']) + assert instance.title == track['recording']['title'] + assert instance.position == int(track['position']) + assert instance.title == track['recording']['title'] + + for row in payload['albums'][0]['tracks']: + job = models.ImportJob.objects.get(mbid=row['mbid']) + assert str(job.mbid) == row['mbid'] + assert job.status == 'pending' + assert job.source == row['source'] + + +def test_user_can_query_api_for_his_own_batches(client, factories): + user1 = factories['users.SuperUser']() + user2 = factories['users.SuperUser']() + + job = factories['music.ImportJob'](batch__submitted_by=user1) + url = reverse('api:v1:import-batches-list') + + client.login(username=user2.username, password='test') + response2 = client.get(url) + results = json.loads(response2.content.decode('utf-8')) + assert results['count'] == 0 + client.logout() + + client.login(username=user1.username, password='test') + response1 = client.get(url) + results = json.loads(response1.content.decode('utf-8')) + assert results['count'] == 1 + assert results['results'][0]['jobs'][0]['mbid'] == job.mbid + + +def test_can_search_artist(factories, client): + artist1 = factories['music.Artist']() + artist2 = factories['music.Artist']() + expected = [serializers.ArtistSerializerNested(artist1).data] + url = reverse('api:v1:artists-search') + response = client.get(url, {'query': artist1.name}) + assert json.loads(response.content.decode('utf-8')) == expected + + +def test_can_search_artist_by_name_start(factories, client): + artist1 = factories['music.Artist'](name='alpha') + artist2 = factories['music.Artist'](name='beta') + expected = { + 'next': None, + 'previous': None, + 'count': 1, + 'results': [serializers.ArtistSerializerNested(artist1).data] + } + url = reverse('api:v1:artists-list') + response = client.get(url, {'name__startswith': 'a'}) + + assert expected == json.loads(response.content.decode('utf-8')) + + +def test_can_search_tracks(factories, client): + track1 = factories['music.Track'](title="test track 1") + track2 = factories['music.Track']() + query = 'test track 1' + expected = [serializers.TrackSerializerNested(track1).data] + url = reverse('api:v1:tracks-search') + response = client.get(url, {'query': query}) + + assert expected == json.loads(response.content.decode('utf-8')) + + +@pytest.mark.parametrize('route,method', [ + ('api:v1:tags-list', 'get'), + ('api:v1:tracks-list', 'get'), + ('api:v1:artists-list', 'get'), + ('api:v1:albums-list', 'get'), +]) +def test_can_restrict_api_views_to_authenticated_users(db, route, method, settings, client): + url = reverse(route) + settings.API_AUTHENTICATION_REQUIRED = True + response = getattr(client, method)(url) + assert response.status_code == 401 + + +def test_track_file_url_is_restricted_to_authenticated_users(client, factories, settings): + settings.API_AUTHENTICATION_REQUIRED = True + f = factories['music.TrackFile']() + assert f.audio_file is not None + url = f.path + response = client.get(url) + assert response.status_code == 401 + + user = factories['users.SuperUser']() + client.login(username=user.username, password='test') + response = client.get(url) + + assert response.status_code == 200 + assert response['X-Accel-Redirect'] == '/_protected{}'.format(f.audio_file.url) diff --git a/api/tests/music/test_lyrics.py b/api/tests/music/test_lyrics.py new file mode 100644 index 0000000000000000000000000000000000000000..3670a2e5c1bfb1e24b673c86613fe16073f0d201 --- /dev/null +++ b/api/tests/music/test_lyrics.py @@ -0,0 +1,73 @@ +import json +from django.urls import reverse + +from funkwhale_api.music import models +from funkwhale_api.musicbrainz import api +from funkwhale_api.music import serializers +from funkwhale_api.music import lyrics as lyrics_utils + +from .mocking import lyricswiki +from . import data as api_data + + + +def test_works_import_lyrics_if_any(mocker, factories): + mocker.patch( + 'funkwhale_api.music.lyrics._get_html', + return_value=lyricswiki.content) + lyrics = factories['music.Lyrics']( + url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!') + + lyrics.fetch_content() + self.assertIn( + 'Grab a brush and put on a little makeup', + lyrics.content, + ) + + +def test_clean_content(): + c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>""" + d = lyrics_utils.extract_content(c) + d = lyrics_utils.clean_content(d) + + expected = """Hello +Is it me you're looking for? +""" + assert d == expected + + +def test_markdown_rendering(factories): + content = """Hello +Is it me you're looking for?""" + + l = factories['music.Lyrics'](content=content) + + expected = "<p>Hello<br />\nIs it me you're looking for?</p>" + assert expected == l.content_rendered + + +def test_works_import_lyrics_if_any(mocker, factories, logged_in_client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.works.get', + return_value=api_data.works['get']['chop_suey']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.recordings.get', + return_value=api_data.tracks['get']['chop_suey']) + mocker.patch( + 'funkwhale_api.music.lyrics._get_html', + return_value=lyricswiki.content) + track = factories['music.Track']( + work=None, + mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') + + url = reverse('api:v1:tracks-lyrics', kwargs={'pk': track.pk}) + response = logged_in_client.get(url) + + assert response.status_code == 200 + + track.refresh_from_db() + lyrics = models.Lyrics.objects.latest('id') + work = models.Work.objects.latest('id') + + assert track.work == work + assert lyrics.work == work diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..5df2dbcf117965cf41563a6d028cbe804c3e0fd8 --- /dev/null +++ b/api/tests/music/test_metadata.py @@ -0,0 +1,41 @@ +import datetime +import os +import pytest + +from funkwhale_api.music import metadata + +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.mark.parametrize('field,value', [ + ('title', 'Peer Gynt Suite no. 1, op. 46: I. Morning'), + ('artist', 'Edvard Grieg'), + ('album', 'Peer Gynt Suite no. 1, op. 46'), + ('date', datetime.date(2012, 8, 15)), + ('track_number', 1), + ('musicbrainz_albumid', 'a766da8b-8336-47aa-a3ee-371cc41ccc75'), + ('musicbrainz_recordingid', 'bd21ac48-46d8-4e78-925f-d9cc2a294656'), + ('musicbrainz_artistid', '013c8e5b-d72a-4cd3-8dee-6c64d6125823'), +]) +def test_can_get_metadata_from_ogg_file(field, value): + path = os.path.join(DATA_DIR, 'test.ogg') + data = metadata.Metadata(path) + + assert data.get(field) == value + + +@pytest.mark.parametrize('field,value', [ + ('title', 'Bend'), + ('artist', 'Binärpilot'), + ('album', 'You Can\'t Stop Da Funk'), + ('date', datetime.date(2006, 2, 7)), + ('track_number', 1), + ('musicbrainz_albumid', 'ce40cdb1-a562-4fd8-a269-9269f98d4124'), + ('musicbrainz_recordingid', 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb'), + ('musicbrainz_artistid', '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13'), +]) +def test_can_get_metadata_from_id3_mp3_file(field, value): + path = os.path.join(DATA_DIR, 'test.mp3') + data = metadata.Metadata(path) + + assert data.get(field) == value diff --git a/api/funkwhale_api/music/tests/test_models.py b/api/tests/music/test_models.py similarity index 78% rename from api/funkwhale_api/music/tests/test_models.py rename to api/tests/music/test_models.py index 4b43e46382ad28d4bbf382fef854c39b8cae5d48..2ec192517bbf7d060baf37bbcbb5670f1bb2635a 100644 --- a/api/funkwhale_api/music/tests/test_models.py +++ b/api/tests/music/test_models.py @@ -2,16 +2,14 @@ import pytest from funkwhale_api.music import models from funkwhale_api.music import importers -from . import factories -def test_can_store_release_group_id_on_album(db): - album = factories.AlbumFactory() +def test_can_store_release_group_id_on_album(factories): + album = factories['music.Album']() assert album.release_group_id is not None -def test_import_album_stores_release_group(db): - +def test_import_album_stores_release_group(factories): album_data = { "artist-credit": [ { @@ -31,7 +29,7 @@ def test_import_album_stores_release_group(db): "title": "Marsupial Madness", 'release-group': {'id': '447b4979-2178-405c-bfe6-46bf0b09e6c7'} } - artist = factories.ArtistFactory( + artist = factories['music.Artist']( mbid=album_data['artist-credit'][0]['artist']['id'] ) cleaned_data = models.Album.clean_musicbrainz_data(album_data) @@ -41,9 +39,9 @@ def test_import_album_stores_release_group(db): assert album.artist == artist -def test_import_job_is_bound_to_track_file(db, mocker): - track = factories.TrackFactory() - job = factories.ImportJobFactory(mbid=track.mbid) +def test_import_job_is_bound_to_track_file(factories, mocker): + track = factories['music.Track']() + job = factories['music.ImportJob'](mbid=track.mbid) mocker.patch('funkwhale_api.music.models.TrackFile.download_file') job.run() diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py new file mode 100644 index 0000000000000000000000000000000000000000..076ad2bd05cb714c6436592666dffeeae61396ba --- /dev/null +++ b/api/tests/music/test_music.py @@ -0,0 +1,138 @@ +import pytest +from funkwhale_api.music import models +import datetime + +from . import data as api_data +from .cover import binary_data + + +def test_can_create_artist_from_api(mocker, db): + mocker.patch( + 'musicbrainzngs.search_artists', + return_value=api_data.artists['search']['adhesive_wombat']) + artist = models.Artist.create_from_api(query="Adhesive wombat") + data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0] + + assert int(data['ext:score']), 100 + assert data['id'], '62c3befb-6366-4585-b256-809472333801' + assert artist.mbid, data['id'] + assert artist.name, 'Adhesive Wombat' + + +def test_can_create_album_from_api(mocker, db): + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.search', + return_value=api_data.albums['search']['hypnotize']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['soad']) + album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album') + data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0] + + assert album.mbid, data['id'] + assert album.title, 'Hypnotize' + with pytest.raises(ValueError): + assert album.cover.path is not None + assert album.release_date, datetime.date(2005, 1, 1) + assert album.artist.name, 'System of a Down' + assert album.artist.mbid, data['artist-credit'][0]['artist']['id'] + + +def test_can_create_track_from_api(mocker, db): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['adhesive_wombat']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.get', + return_value=api_data.albums['get']['marsupial']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.recordings.search', + return_value=api_data.tracks['search']['8bitadventures']) + track = models.Track.create_from_api(query="8-bit adventure") + data = models.Track.api.search(query='8-bit adventure')['recording-list'][0] + assert int(data['ext:score']) == 100 + assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed' + assert track.mbid == data['id'] + assert track.artist.pk is not None + assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801' + assert track.artist.name == 'Adhesive Wombat' + assert str(track.album.mbid) == 'a50d2a81-2a50-484d-9cb4-b9f6833f583e' + assert track.album.title == 'Marsupial Madness' + + +def test_can_create_track_from_api_with_corresponding_tags(mocker, db): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['adhesive_wombat']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.get', + return_value=api_data.albums['get']['marsupial']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.recordings.get', + return_value=api_data.tracks['get']['8bitadventures']) + track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed') + expected_tags = ['techno', 'good-music'] + track_tags = [tag.slug for tag in track.tags.all()] + for tag in expected_tags: + assert tag in track_tags + + +def test_can_get_or_create_track_from_api(mocker, db): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['adhesive_wombat']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.get', + return_value=api_data.albums['get']['marsupial']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.recordings.search', + return_value=api_data.tracks['search']['8bitadventures']) + track = models.Track.create_from_api(query="8-bit adventure") + data = models.Track.api.search(query='8-bit adventure')['recording-list'][0] + assert int(data['ext:score']) == 100 + assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed' + assert track.mbid == data['id'] + assert track.artist.pk is not None + assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801' + assert track.artist.name == 'Adhesive Wombat' + + track2, created = models.Track.get_or_create_from_api(mbid=data['id']) + assert not created + assert track == track2 + + +def test_album_tags_deduced_from_tracks_tags(factories, django_assert_num_queries): + tag = factories['taggit.Tag']() + album = factories['music.Album']() + tracks = factories['music.Track'].create_batch( + 5, album=album, tags=[tag]) + + album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk) + + with django_assert_num_queries(0): + assert tag in album.tags + + +def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_queries): + tag = factories['taggit.Tag']() + album = factories['music.Album']() + artist = album.artist + tracks = factories['music.Track'].create_batch( + 5, album=album, tags=[tag]) + + artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk) + + with django_assert_num_queries(0): + assert tag in artist.tags + + +def test_can_download_image_file_for_album(mocker, factories): + mocker.patch( + 'funkwhale_api.musicbrainz.api.images.get_front', + return_value=binary_data) + # client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed') + album = factories['music.Album'](mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed') + album.get_image() + album.save() + + assert album.cover.file.read() == binary_data diff --git a/api/tests/music/test_works.py b/api/tests/music/test_works.py new file mode 100644 index 0000000000000000000000000000000000000000..9b72768ad07bf05adae848eef73784866de83c7d --- /dev/null +++ b/api/tests/music/test_works.py @@ -0,0 +1,65 @@ +import json +from django.urls import reverse + +from funkwhale_api.music import models +from funkwhale_api.musicbrainz import api +from funkwhale_api.music import serializers + +from . import data as api_data + + +def test_can_import_work(factories, mocker): + mocker.patch( + 'funkwhale_api.musicbrainz.api.works.get', + return_value=api_data.works['get']['chop_suey']) + recording = factories['music.Track']( + mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') + mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' + work = models.Work.create_from_api(id=mbid) + + assert work.title == 'Chop Suey!' + assert work.nature == 'song' + assert work.language == 'eng' + assert work.mbid == mbid + + # a imported work should also be linked to corresponding recordings + + recording.refresh_from_db() + assert recording.work == work + + +def test_can_get_work_from_recording(factories, mocker): + mocker.patch( + 'funkwhale_api.musicbrainz.api.works.get', + return_value=api_data.works['get']['chop_suey']) + mocker.patch( + 'funkwhale_api.musicbrainz.api.recordings.get', + return_value=api_data.tracks['get']['chop_suey']) + recording = factories['music.Track']( + work=None, + mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') + mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' + + assert recording.work == None + + work = recording.get_work() + + assert work.title == 'Chop Suey!' + assert work.nature == 'song' + assert work.language == 'eng' + assert work.mbid == mbid + + recording.refresh_from_db() + assert recording.work == work + + +def test_works_import_lyrics_if_any(db, mocker): + mocker.patch( + 'funkwhale_api.musicbrainz.api.works.get', + return_value=api_data.works['get']['chop_suey']) + mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' + work = models.Work.create_from_api(id=mbid) + + lyrics = models.Lyrics.objects.latest('id') + assert lyrics.work == work + assert lyrics.url == 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!' diff --git a/api/funkwhale_api/history/tests/__init__.py b/api/tests/musicbrainz/__init__.py similarity index 100% rename from api/funkwhale_api/history/tests/__init__.py rename to api/tests/musicbrainz/__init__.py diff --git a/api/funkwhale_api/musicbrainz/tests/data.py b/api/tests/musicbrainz/data.py similarity index 100% rename from api/funkwhale_api/musicbrainz/tests/data.py rename to api/tests/musicbrainz/data.py diff --git a/api/tests/musicbrainz/test_api.py b/api/tests/musicbrainz/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..bbade340060dae4cbc4f2a64d296eb86e6c59e79 --- /dev/null +++ b/api/tests/musicbrainz/test_api.py @@ -0,0 +1,90 @@ +import json +from django.urls import reverse + +from funkwhale_api.musicbrainz import api +from . import data as api_data + + + +def test_can_search_recording_in_musicbrainz_api(db, mocker, client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.recordings.search', + return_value=api_data.recordings['search']['brontide matador']) + query = 'brontide matador' + url = reverse('api:v1:providers:musicbrainz:search-recordings') + expected = api_data.recordings['search']['brontide matador'] + response = client.get(url, data={'query': query}) + + assert expected == json.loads(response.content.decode('utf-8')) + + +def test_can_search_release_in_musicbrainz_api(db, mocker, client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.search', + return_value=api_data.releases['search']['brontide matador']) + query = 'brontide matador' + url = reverse('api:v1:providers:musicbrainz:search-releases') + expected = api_data.releases['search']['brontide matador'] + response = client.get(url, data={'query': query}) + + assert expected == json.loads(response.content.decode('utf-8')) + + +def test_can_search_artists_in_musicbrainz_api(db, mocker, client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.search', + return_value=api_data.artists['search']['lost fingers']) + query = 'lost fingers' + url = reverse('api:v1:providers:musicbrainz:search-artists') + expected = api_data.artists['search']['lost fingers'] + response = client.get(url, data={'query': query}) + + assert expected == json.loads(response.content.decode('utf-8')) + + +def test_can_get_artist_in_musicbrainz_api(db, mocker, client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.artists.get', + return_value=api_data.artists['get']['lost fingers']) + uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9' + url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={ + 'uuid': uuid, + }) + response = client.get(url) + expected = api_data.artists['get']['lost fingers'] + + assert expected == json.loads(response.content.decode('utf-8')) + + +def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.release_groups.browse', + return_value=api_data.release_groups['browse']['lost fingers']) + uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9' + url = reverse( + 'api:v1:providers:musicbrainz:release-group-browse', + kwargs={ + 'artist_uuid': uuid, + } + ) + response = client.get(url) + expected = api_data.release_groups['browse']['lost fingers'] + + assert expected == json.loads(response.content.decode('utf-8')) + + +def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client): + mocker.patch( + 'funkwhale_api.musicbrainz.api.releases.browse', + return_value=api_data.releases['browse']['Lost in the 80s']) + uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1' + url = reverse( + 'api:v1:providers:musicbrainz:release-browse', + kwargs={ + 'release_group_uuid': uuid, + } + ) + response = client.get(url) + expected = api_data.releases['browse']['Lost in the 80s'] + + assert expected == json.loads(response.content.decode('utf-8')) diff --git a/api/tests/musicbrainz/test_cache.py b/api/tests/musicbrainz/test_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..fe0d5677302b08bb3f75c45a31f23f3238d034ff --- /dev/null +++ b/api/tests/musicbrainz/test_cache.py @@ -0,0 +1,13 @@ +from funkwhale_api.musicbrainz import client + + +def test_can_search_recording_in_musicbrainz_api(mocker): + r = {'hello': 'world'} + m = mocker.patch( + 'funkwhale_api.musicbrainz.client._api.search_artists', + return_value=r) + assert client.api.artists.search('test') == r + # now call from cache + assert client.api.artists.search('test') == r + assert client.api.artists.search('test') == r + assert m.call_count == 1 diff --git a/api/tests/test_disk_import.py b/api/tests/test_disk_import.py new file mode 100644 index 0000000000000000000000000000000000000000..9aaf399750e3c756c9087d58e6e61931834ab19a --- /dev/null +++ b/api/tests/test_disk_import.py @@ -0,0 +1,39 @@ +import os +import datetime + +from funkwhale_api.providers.audiofile import tasks + +DATA_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'files' +) + + +def test_can_import_single_audio_file(db, mocker): + metadata = { + 'artist': ['Test artist'], + 'album': ['Test album'], + 'title': ['Test track'], + 'TRACKNUMBER': ['4'], + 'date': ['2012-08-15'], + 'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'], + 'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'], + 'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'], + } + + m1 = mocker.patch('mutagen.File', return_value=metadata) + m2 = mocker.patch( + 'funkwhale_api.music.metadata.Metadata.get_file_type', + return_value='OggVorbis', + ) + track_file = tasks.from_path(os.path.join(DATA_DIR, 'dummy_file.ogg')) + track = track_file.track + + assert track.title == metadata['title'][0] + assert track.mbid == metadata['musicbrainz_trackid'][0] + assert track.position == 4 + assert track.album.title == metadata['album'][0] + assert track.album.mbid == metadata['musicbrainz_albumid'][0] + assert track.album.release_date == datetime.date(2012, 8, 15) + assert track.artist.name == metadata['artist'][0] + assert track.artist.mbid == metadata['musicbrainz_artistid'][0] diff --git a/api/tests/test_downloader.py b/api/tests/test_downloader.py new file mode 100644 index 0000000000000000000000000000000000000000..ede7bb16cc9571607f0b3c73114010f02e411349 --- /dev/null +++ b/api/tests/test_downloader.py @@ -0,0 +1,11 @@ +import os + +from funkwhale_api import downloader + + +def test_can_download_audio_from_youtube_url_to_vorbis(tmpdir): + data = downloader.download( + 'https://www.youtube.com/watch?v=tPEE9ZwTmy0', + target_directory=tmpdir) + assert data['audio_file_path'] == os.path.join(tmpdir, 'tPEE9ZwTmy0.ogg') + assert os.path.exists(data['audio_file_path']) diff --git a/api/tests/test_favorites.py b/api/tests/test_favorites.py new file mode 100644 index 0000000000000000000000000000000000000000..418166d8e0c11aa133e87a44a55f7b1713a3ad2f --- /dev/null +++ b/api/tests/test_favorites.py @@ -0,0 +1,92 @@ +import json +import pytest +from django.urls import reverse + +from funkwhale_api.music.models import Track, Artist +from funkwhale_api.favorites.models import TrackFavorite + + + +def test_user_can_add_favorite(factories): + track = factories['music.Track']() + user = factories['users.User']() + f = TrackFavorite.add(track, user) + + assert f.track == track + assert f.user == user + + +def test_user_can_get_his_favorites(factories, logged_in_client, client): + favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) + url = reverse('api:v1:favorites:tracks-list') + response = logged_in_client.get(url) + + expected = [ + { + 'track': favorite.track.pk, + 'id': favorite.id, + 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), + } + ] + parsed_json = json.loads(response.content.decode('utf-8')) + + assert expected == parsed_json['results'] + + +def test_user_can_add_favorite_via_api(factories, logged_in_client, client): + track = factories['music.Track']() + url = reverse('api:v1:favorites:tracks-list') + response = logged_in_client.post(url, {'track': track.pk}) + + favorite = TrackFavorite.objects.latest('id') + expected = { + 'track': track.pk, + 'id': favorite.id, + 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), + } + parsed_json = json.loads(response.content.decode('utf-8')) + + assert expected == parsed_json + assert favorite.track == track + assert favorite.user == logged_in_client.user + + +def test_user_can_remove_favorite_via_api(logged_in_client, factories, client): + favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) + url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk}) + response = client.delete(url, {'track': favorite.track.pk}) + assert response.status_code == 204 + assert TrackFavorite.objects.count() == 0 + +def test_user_can_remove_favorite_via_api_using_track_id(factories, logged_in_client): + favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) + + url = reverse('api:v1:favorites:tracks-remove') + response = logged_in_client.delete( + url, json.dumps({'track': favorite.track.pk}), + content_type='application/json' + ) + + assert response.status_code == 204 + assert TrackFavorite.objects.count() == 0 + + +@pytest.mark.parametrize('url,method', [ + ('api:v1:favorites:tracks-list', 'get'), +]) +def test_url_require_auth(url, method, db, settings, client): + settings.API_AUTHENTICATION_REQUIRED = True + url = reverse(url) + response = getattr(client, method)(url) + assert response.status_code == 401 + + +def test_can_filter_tracks_by_favorites(factories, logged_in_client): + favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) + + url = reverse('api:v1:tracks-list') + response = logged_in_client.get(url, data={'favorites': True}) + + parsed_json = json.loads(response.content.decode('utf-8')) + assert parsed_json['count'] == 1 + assert parsed_json['results'][0]['id'] == favorite.track.id diff --git a/api/tests/test_history.py b/api/tests/test_history.py new file mode 100644 index 0000000000000000000000000000000000000000..113e5ff6405f60665305f04b379997e8b9343d9c --- /dev/null +++ b/api/tests/test_history.py @@ -0,0 +1,42 @@ +import random +import json +from django.urls import reverse +from django.core.exceptions import ValidationError +from django.utils import timezone + +from funkwhale_api.history import models + + +def test_can_create_listening(factories): + track = factories['music.Track']() + user = factories['users.User']() + now = timezone.now() + l = models.Listening.objects.create(user=user, track=track) + + +def test_anonymous_user_can_create_listening_via_api(client, factories, settings): + settings.API_AUTHENTICATION_REQUIRED = False + track = factories['music.Track']() + url = reverse('api:v1:history:listenings-list') + response = client.post(url, { + 'track': track.pk, + }) + + listening = models.Listening.objects.latest('id') + + assert listening.track == track + assert listening.session_key == client.session.session_key + + +def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories): + track = factories['music.Track']() + + url = reverse('api:v1:history:listenings-list') + response = logged_in_client.post(url, { + 'track': track.pk, + }) + + listening = models.Listening.objects.latest('id') + + assert listening.track == track + assert listening.user == logged_in_client.user diff --git a/api/tests/test_jwt_querystring.py b/api/tests/test_jwt_querystring.py new file mode 100644 index 0000000000000000000000000000000000000000..bd07e1dc3212476f01dd7c60f341e8718c60351c --- /dev/null +++ b/api/tests/test_jwt_querystring.py @@ -0,0 +1,21 @@ +from django.urls import reverse +from rest_framework_jwt.settings import api_settings + +jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER +jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER + + +def test_can_authenticate_using_token_param_in_url(factories, settings, client): + user = factories['users.User']() + settings.API_AUTHENTICATION_REQUIRED = True + url = reverse('api:v1:tracks-list') + response = client.get(url) + + assert response.status_code == 401 + + payload = jwt_payload_handler(user) + token = jwt_encode_handler(payload) + response = client.get(url, data={ + 'jwt': token + }) + assert response.status_code == 200 diff --git a/api/tests/test_playlists.py b/api/tests/test_playlists.py new file mode 100644 index 0000000000000000000000000000000000000000..f496a64cb5d93a14aacc8bb73ddc2a58e3c7b50e --- /dev/null +++ b/api/tests/test_playlists.py @@ -0,0 +1,54 @@ +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/api/tests/test_radios.py b/api/tests/test_radios.py new file mode 100644 index 0000000000000000000000000000000000000000..d67611123ce0febb2623345d4fe388304b86f7bf --- /dev/null +++ b/api/tests/test_radios.py @@ -0,0 +1,195 @@ +import json +import random +import pytest + +from django.urls import reverse +from django.core.exceptions import ValidationError + + +from funkwhale_api.radios import radios +from funkwhale_api.radios import models +from funkwhale_api.favorites.models import TrackFavorite + + +def test_can_pick_track_from_choices(): + choices = [1, 2, 3, 4, 5] + + radio = radios.SimpleRadio() + + first_pick = radio.pick(choices=choices) + + assert first_pick in choices + + previous_choices = [first_pick] + for remaining_choice in choices: + pick = radio.pick(choices=choices, previous_choices=previous_choices) + assert pick in set(choices).difference(previous_choices) + + +def test_can_pick_by_weight(): + choices_with_weight = [ + # choice, weight + (1, 1), + (2, 2), + (3, 3), + (4, 4), + (5, 5), + ] + + picks = {choice: 0 for choice, weight in choices_with_weight} + + for i in range(1000): + radio = radios.SimpleRadio() + pick = radio.weighted_pick(choices=choices_with_weight) + picks[pick] = picks[pick] + 1 + + assert picks[5] > picks[4] + assert picks[4] > picks[3] + assert picks[3] > picks[2] + assert picks[2] > picks[1] + + +def test_can_get_choices_for_favorites_radio(factories): + tracks = factories['music.Track'].create_batch(100) + user = factories['users.User']() + for i in range(20): + TrackFavorite.add(track=random.choice(tracks), user=user) + + radio = radios.FavoritesRadio() + choices = radio.get_choices(user=user) + + assert choices.count() == user.track_favorites.all().count() + + for favorite in user.track_favorites.all(): + assert favorite.track in choices + + for i in range(20): + pick = radio.pick(user=user) + assert pick in choices + + +def test_can_use_radio_session_to_filter_choices(factories): + tracks = factories['music.Track'].create_batch(30) + user = factories['users.User']() + radio = radios.RandomRadio() + session = radio.start_session(user) + + for i in range(30): + p = radio.pick() + + # ensure 30 differents tracks have been suggested + tracks_id = [ + session_track.track.pk + for session_track in session.session_tracks.all()] + assert len(set(tracks_id)) == 30 + + +def test_can_restore_radio_from_previous_session(factories): + user = factories['users.User']() + radio = radios.RandomRadio() + session = radio.start_session(user) + + restarted_radio = radios.RandomRadio(session) + assert radio.session == restarted_radio.session + + +def test_can_start_radio_for_logged_in_user(logged_in_client): + url = reverse('api:v1:radios:sessions-list') + response = logged_in_client.post(url, {'radio_type': 'random'}) + session = models.RadioSession.objects.latest('id') + assert session.radio_type == 'random' + assert session.user == logged_in_client.user + + +def test_can_start_radio_for_anonymous_user(client, db): + url = reverse('api:v1:radios:sessions-list') + response = client.post(url, {'radio_type': 'random'}) + session = models.RadioSession.objects.latest('id') + + assert session.radio_type == 'random' + assert session.user is None + assert session.session_key == client.session.session_key + + +def test_can_get_track_for_session_from_api(factories, logged_in_client): + tracks = factories['music.Track'].create_batch(size=1) + + url = reverse('api:v1:radios:sessions-list') + response = logged_in_client.post(url, {'radio_type': 'random'}) + session = models.RadioSession.objects.latest('id') + + url = reverse('api:v1:radios:tracks-list') + response = logged_in_client.post(url, {'session': session.pk}) + data = json.loads(response.content.decode('utf-8')) + + assert data['track']['id'] == tracks[0].id + assert data['position'] == 1 + + next_track = factories['music.Track']() + response = logged_in_client.post(url, {'session': session.pk}) + data = json.loads(response.content.decode('utf-8')) + + assert data['track']['id'] == next_track.id + assert data['position'] == 2 + + +def test_related_object_radio_validate_related_object(factories): + user = factories['users.User']() + # cannot start without related object + radio = radios.ArtistRadio() + with pytest.raises(ValidationError): + radio.start_session(user) + + # cannot start with bad related object type + radio = radios.ArtistRadio() + with pytest.raises(ValidationError): + radio.start_session(user, related_object=user) + + +def test_can_start_artist_radio(factories): + user = factories['users.User']() + artist = factories['music.Artist']() + wrong_tracks = factories['music.Track'].create_batch(5) + good_tracks = factories['music.Track'].create_batch(5, artist=artist) + + radio = radios.ArtistRadio() + session = radio.start_session(user, related_object=artist) + assert session.radio_type == 'artist' + for i in range(5): + assert radio.pick() in good_tracks + + +def test_can_start_tag_radio(factories): + user = factories['users.User']() + tag = factories['taggit.Tag']() + wrong_tracks = factories['music.Track'].create_batch(5) + good_tracks = factories['music.Track'].create_batch(5, tags=[tag]) + + radio = radios.TagRadio() + session = radio.start_session(user, related_object=tag) + assert session.radio_type =='tag' + for i in range(5): + assert radio.pick() in good_tracks + + +def test_can_start_artist_radio_from_api(client, factories): + artist = factories['music.Artist']() + url = reverse('api:v1:radios:sessions-list') + + response = client.post( + url, {'radio_type': 'artist', 'related_object_id': artist.id}) + session = models.RadioSession.objects.latest('id') + assert session.radio_type, 'artist' + assert session.related_object, artist + + +def test_can_start_less_listened_radio(factories): + user = factories['users.User']() + history = factories['history.Listening'].create_batch(5, user=user) + wrong_tracks = [h.track for h in history] + good_tracks = factories['music.Track'].create_batch(size=5) + radio = radios.LessListenedRadio() + session = radio.start_session(user) + assert session.related_object == user + for i in range(5): + assert radio.pick() in good_tracks diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py new file mode 100644 index 0000000000000000000000000000000000000000..017d742ef834562f33425cbc84046fdf7d580bb4 --- /dev/null +++ b/api/tests/test_youtube.py @@ -0,0 +1,95 @@ +import json +from collections import OrderedDict +from django.urls import reverse +from funkwhale_api.providers.youtube.client import client + +from .data import youtube as api_data + + +def test_can_get_search_results_from_youtube(mocker): + mocker.patch( + 'funkwhale_api.providers.youtube.client._do_search', + return_value=api_data.search['8 bit adventure']) + query = '8 bit adventure' + results = client.search(query) + assert results[0]['id']['videoId'] == '0HxZn6CzOIo' + assert results[0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure' + assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo' + + +def test_can_get_search_results_from_funkwhale(mocker, client, db): + mocker.patch( + 'funkwhale_api.providers.youtube.client._do_search', + return_value=api_data.search['8 bit adventure']) + query = '8 bit adventure' + url = reverse('api:v1:providers:youtube:search') + response = client.get(url, {'query': query}) + # we should cast the youtube result to something more generic + expected = { + "id": "0HxZn6CzOIo", + "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", + "type": "youtube#video", + "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", + "channelId": "UCps63j3krzAG4OyXeEyuhFw", + "title": "AdhesiveWombat - 8 Bit Adventure", + "channelTitle": "AdhesiveWombat", + "publishedAt": "2012-08-22T18:41:03.000Z", + "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" + } + + assert json.loads(response.content.decode('utf-8'))[0] == expected + + +def test_can_send_multiple_queries_at_once(mocker): + mocker.patch( + 'funkwhale_api.providers.youtube.client._do_search', + side_effect=[ + api_data.search['8 bit adventure'], + api_data.search['system of a down toxicity'], + ] + ) + + queries = OrderedDict() + queries['1'] = { + 'q': '8 bit adventure', + } + queries['2'] = { + 'q': 'system of a down toxicity', + } + + results = client.search_multiple(queries) + + assert results['1'][0]['id']['videoId'] == '0HxZn6CzOIo' + assert results['1'][0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure' + assert results['1'][0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo' + assert results['2'][0]['id']['videoId'] == 'BorYwGi2SJc' + assert results['2'][0]['snippet']['title'] == 'System of a Down: Toxicity' + assert results['2'][0]['full_url'] == 'https://www.youtube.com/watch?v=BorYwGi2SJc' + + +def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client): + mocker.patch( + 'funkwhale_api.providers.youtube.client._do_search', + return_value=api_data.search['8 bit adventure']) + queries = OrderedDict() + queries['1'] = { + 'q': '8 bit adventure', + } + + expected = { + "id": "0HxZn6CzOIo", + "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", + "type": "youtube#video", + "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", + "channelId": "UCps63j3krzAG4OyXeEyuhFw", + "title": "AdhesiveWombat - 8 Bit Adventure", + "channelTitle": "AdhesiveWombat", + "publishedAt": "2012-08-22T18:41:03.000Z", + "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" + } + + url = reverse('api:v1:providers:youtube:searchs') + response = client.post( + url, json.dumps(queries), content_type='application/json') + + assert expected == json.loads(response.content.decode('utf-8'))['1'][0] diff --git a/api/funkwhale_api/music/tests/__init__.py b/api/tests/users/__init__.py similarity index 100% rename from api/funkwhale_api/music/tests/__init__.py rename to api/tests/users/__init__.py diff --git a/api/tests/users/test_admin.py b/api/tests/users/test_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..7645a02953b1caa44b6002a741fadf48c406449f --- /dev/null +++ b/api/tests/users/test_admin.py @@ -0,0 +1,35 @@ +from funkwhale_api.users.admin import MyUserCreationForm + + +def test_clean_username_success(db): + # Instantiate the form with a new username + form = MyUserCreationForm({ + 'username': 'alamode', + 'password1': '123456', + 'password2': '123456', + }) + # Run is_valid() to trigger the validation + valid = form.is_valid() + assert valid + + # Run the actual clean_username method + username = form.clean_username() + assert 'alamode' == username + + +def test_clean_username_false(factories): + user = factories['users.User']() + # Instantiate the form with the same username as self.user + form = MyUserCreationForm({ + 'username': user.username, + 'password1': '123456', + 'password2': '123456', + }) + # Run is_valid() to trigger the validation, which is going to fail + # because the username is already taken + valid = form.is_valid() + assert not valid + + # The form.errors dict should contain a single error called 'username' + assert len(form.errors) == 1 + assert 'username' in form.errors diff --git a/api/tests/users/test_jwt.py b/api/tests/users/test_jwt.py new file mode 100644 index 0000000000000000000000000000000000000000..d264494e59bfb06f358e7dd83225cf4eef187c0f --- /dev/null +++ b/api/tests/users/test_jwt.py @@ -0,0 +1,27 @@ +import pytest +import uuid + +from jwt.exceptions import DecodeError +from rest_framework_jwt.settings import api_settings + +from funkwhale_api.users.models import User + +def test_can_invalidate_token_when_changing_user_secret_key(factories): + user = factories['users.User']() + u1 = user.secret_key + jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER + jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER + payload = jwt_payload_handler(user) + payload = jwt_encode_handler(payload) + + # this should work + api_settings.JWT_DECODE_HANDLER(payload) + + # now we update the secret key + user.update_secret_key() + user.save() + assert user.secret_key != u1 + + # token should be invalid + with pytest.raises(DecodeError): + api_settings.JWT_DECODE_HANDLER(payload) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..57793f494bcc59a6994d2f014cf9ae7090495cd6 --- /dev/null +++ b/api/tests/users/test_models.py @@ -0,0 +1,4 @@ + +def test__str__(factories): + user = factories['users.User'](username='hello') + assert user.__str__() == 'hello' diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..1eb8ef222a79d68f41d40c8555c0c7cb9d931680 --- /dev/null +++ b/api/tests/users/test_views.py @@ -0,0 +1,118 @@ +import json + +from django.test import RequestFactory +from django.urls import reverse + +from funkwhale_api.users.models import User + + +def test_can_create_user_via_api(settings, client, db): + url = reverse('rest_register') + data = { + 'username': 'test1', + 'email': 'test1@test.com', + 'password1': 'testtest', + 'password2': 'testtest', + } + settings.REGISTRATION_MODE = "public" + response = client.post(url, data) + assert response.status_code == 201 + + u = User.objects.get(email='test1@test.com') + assert u.username == 'test1' + + +def test_can_disable_registration_view(settings, client, db): + url = reverse('rest_register') + data = { + 'username': 'test1', + 'email': 'test1@test.com', + 'password1': 'testtest', + 'password2': 'testtest', + } + settings.REGISTRATION_MODE = "disabled" + response = client.post(url, data) + assert response.status_code == 403 + + +def test_can_fetch_data_from_api(client, factories): + url = reverse('api:v1:users:users-me') + response = client.get(url) + # login required + assert response.status_code == 401 + + user = factories['users.User']( + is_staff=True, + perms=[ + 'music.add_importbatch', + 'dynamic_preferences.change_globalpreferencemodel', + ] + ) + assert user.has_perm('music.add_importbatch') + client.login(username=user.username, password='test') + response = client.get(url) + assert response.status_code == 200 + + payload = json.loads(response.content.decode('utf-8')) + + assert payload['username'] == user.username + assert payload['is_staff'] == user.is_staff + assert payload['is_superuser'] == user.is_superuser + assert payload['email'] == user.email + assert payload['name'] == user.name + assert payload['permissions']['import.launch']['status'] + assert payload['permissions']['settings.change']['status'] + + +def test_can_get_token_via_api(client, factories): + user = factories['users.User']() + url = reverse('api:v1:token') + payload = { + 'username': user.username, + 'password': 'test' + } + + response = client.post(url, payload) + assert response.status_code == 200 + assert '"token":' in response.content.decode('utf-8') + + +def test_can_refresh_token_via_api(client, factories): + # first, we get a token + user = factories['users.User']() + url = reverse('api:v1:token') + payload = { + 'username': user.username, + 'password': 'test' + } + + response = client.post(url, payload) + assert response.status_code == 200 + + token = json.loads(response.content.decode('utf-8'))['token'] + url = reverse('api:v1:token_refresh') + response = client.post(url,{'token': token}) + + assert response.status_code == 200 + assert '"token":' in response.content.decode('utf-8') + # a different token should be returned + assert token in response.content.decode('utf-8') + + +def test_changing_password_updates_secret_key(logged_in_client): + user = logged_in_client.user + password = user.password + secret_key = user.secret_key + payload = { + 'old_password': 'test', + 'new_password1': 'new', + 'new_password2': 'new', + } + url = reverse('change_password') + + response = logged_in_client.post(url, payload) + + user.refresh_from_db() + + assert user.secret_key != secret_key + assert user.password != password diff --git a/dev.yml b/dev.yml index c71298cfc8932c1b7874a906b582fe85a59ee4bd..44e38e32671073cbeb4d3c9d697a216c2b99b541 100644 --- a/dev.yml +++ b/dev.yml @@ -13,7 +13,6 @@ services: - "8080:8080" volumes: - './front:/app' - - /app/node_modules postgres: env_file: .env.dev diff --git a/front/package.json b/front/package.json index bad90430f4144be99d4b17ac11e82073a6705ff0..9af43238767258ddb082b17659b6b9d278dd7952 100644 --- a/front/package.json +++ b/front/package.json @@ -16,13 +16,16 @@ "dependencies": { "dateformat": "^2.0.0", "js-logger": "^1.3.0", + "jwt-decode": "^2.2.0", "lodash": "^4.17.4", "semantic-ui-css": "^2.2.10", "vue": "^2.3.3", "vue-lazyload": "^1.1.4", "vue-resource": "^1.3.4", "vue-router": "^2.3.1", - "vuedraggable": "^2.14.1" + "vuedraggable": "^2.14.1", + "vuex": "^3.0.1", + "vuex-persistedstate": "^2.4.2" }, "devDependencies": { "autoprefixer": "^6.7.2", diff --git a/front/src/App.vue b/front/src/App.vue index f81d7d3daae22224f9d32450844a9677302c111e..d1d63e65143df782703d035a14c8ca0f21da78e1 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,8 +1,8 @@ <template> <div id="app"> <sidebar></sidebar> - <router-view></router-view> - <div class="ui divider"></div> + <router-view :key="$route.fullPath"></router-view> + <div class="ui fitted divider"></div> <div id="footer" class="ui vertical footer segment"> <div class="ui container"> <div class="ui stackable equal height stackable grid"> @@ -55,7 +55,7 @@ export default { padding: 1.5rem 0; } #footer { - padding: 1.5rem; + padding: 4em; } .ui.stripe.segment { padding: 4em; diff --git a/front/src/audio/index.js b/front/src/audio/index.js deleted file mode 100644 index 4896b83b0f895c8bca9269aebf9141d16ab53087..0000000000000000000000000000000000000000 --- a/front/src/audio/index.js +++ /dev/null @@ -1,184 +0,0 @@ -import logger from '@/logging' -import time from '@/utils/time' - -const Cov = { - on (el, type, func) { - el.addEventListener(type, func) - }, - off (el, type, func) { - el.removeEventListener(type, func) - } -} - -class Audio { - constructor (src, options = {}) { - let preload = true - if (options.preload !== undefined && options.preload === false) { - preload = false - } - this.tmp = { - src: src, - options: options - } - this.onEnded = function (e) { - logger.default.info('track ended') - } - if (options.onEnded) { - this.onEnded = options.onEnded - } - this.onError = options.onError - - this.state = { - preload: preload, - startLoad: false, - failed: false, - try: 3, - tried: 0, - playing: false, - paused: false, - playbackRate: 1.0, - progress: 0, - currentTime: 0, - volume: 0.5, - duration: 0, - loaded: '0', - durationTimerFormat: '00:00', - currentTimeFormat: '00:00', - lastTimeFormat: '00:00' - } - if (options.volume !== undefined) { - this.state.volume = options.volume - } - this.hook = { - playState: [], - loadState: [] - } - if (preload) { - this.init(src, options) - } - } - - init (src, options = {}) { - if (!src) throw Error('src must be required') - this.state.startLoad = true - if (this.state.tried >= this.state.try) { - this.state.failed = true - logger.default.error('Cannot fetch audio', src) - if (this.onError) { - this.onError(src) - } - return - } - this.$Audio = new window.Audio(src) - Cov.on(this.$Audio, 'error', () => { - this.state.tried++ - this.init(src, options) - }) - if (options.autoplay) { - this.play() - } - if (options.rate) { - this.$Audio.playbackRate = options.rate - } - if (options.loop) { - this.$Audio.loop = true - } - if (options.volume) { - this.setVolume(options.volume) - } - this.loadState() - } - - loadState () { - if (this.$Audio.readyState >= 2) { - Cov.on(this.$Audio, 'progress', this.updateLoadState.bind(this)) - } else { - Cov.on(this.$Audio, 'loadeddata', () => { - this.loadState() - }) - } - } - - updateLoadState (e) { - if (!this.$Audio) return - this.hook.loadState.forEach(func => { - func(this.state) - }) - this.state.duration = Math.round(this.$Audio.duration * 100) / 100 - this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100 - this.state.durationTimerFormat = time.parse(this.state.duration) - } - - updatePlayState (e) { - this.state.currentTime = Math.round(this.$Audio.currentTime * 100) / 100 - this.state.duration = Math.round(this.$Audio.duration * 100) / 100 - this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100 - - this.state.durationTimerFormat = time.parse(this.state.duration) - this.state.currentTimeFormat = time.parse(this.state.currentTime) - this.state.lastTimeFormat = time.parse(this.state.duration - this.state.currentTime) - - this.hook.playState.forEach(func => { - func(this.state) - }) - } - - updateHook (type, func) { - if (!(type in this.hook)) throw Error('updateHook: type should be playState or loadState') - this.hook[type].push(func) - } - - play () { - if (this.state.startLoad) { - if (!this.state.playing && this.$Audio.readyState >= 2) { - logger.default.info('Playing track') - this.$Audio.play() - this.state.paused = false - this.state.playing = true - Cov.on(this.$Audio, 'timeupdate', this.updatePlayState.bind(this)) - Cov.on(this.$Audio, 'ended', this.onEnded) - } else { - Cov.on(this.$Audio, 'loadeddata', () => { - this.play() - }) - } - } else { - this.init(this.tmp.src, this.tmp.options) - Cov.on(this.$Audio, 'loadeddata', () => { - this.play() - }) - } - } - - destroyed () { - this.$Audio.pause() - Cov.off(this.$Audio, 'timeupdate', this.updatePlayState) - Cov.off(this.$Audio, 'progress', this.updateLoadState) - Cov.off(this.$Audio, 'ended', this.onEnded) - this.$Audio.remove() - } - - pause () { - logger.default.info('Pausing track') - this.$Audio.pause() - this.state.paused = true - this.state.playing = false - this.$Audio.removeEventListener('timeupdate', this.updatePlayState) - } - - setVolume (number) { - if (number > -0.01 && number <= 1) { - this.state.volume = Math.round(number * 100) / 100 - this.$Audio.volume = this.state.volume - } - } - - setTime (time) { - if (time < 0 && time > this.state.duration) { - return false - } - this.$Audio.currentTime = time - } -} - -export default Audio diff --git a/front/src/audio/queue.js b/front/src/audio/queue.js deleted file mode 100644 index 8c69638e80ceb46adfdd182010ced55898b6850f..0000000000000000000000000000000000000000 --- a/front/src/audio/queue.js +++ /dev/null @@ -1,304 +0,0 @@ -import logger from '@/logging' -import cache from '@/cache' -import config from '@/config' -import Audio from '@/audio' -import backend from '@/audio/backend' -import radios from '@/radios' -import Vue from 'vue' -import url from '@/utils/url' -import auth from '@/auth' - -class Queue { - constructor (options = {}) { - logger.default.info('Instanciating queue') - this.previousQueue = cache.get('queue') - this.tracks = [] - this.currentIndex = -1 - this.currentTrack = null - this.ended = true - this.state = { - volume: cache.get('volume', 0.5) - } - this.audio = { - state: { - startLoad: false, - failed: false, - try: 3, - tried: 0, - playing: false, - paused: false, - playbackRate: 1.0, - progress: 0, - currentTime: 0, - duration: 0, - volume: this.state.volume, - loaded: '0', - durationTimerFormat: '00:00', - currentTimeFormat: '00:00', - lastTimeFormat: '00:00' - } - } - } - - cache () { - let cached = { - tracks: this.tracks.map(track => { - // we keep only valuable fields to make the cache lighter and avoid - // cyclic value serialization errors - let artist = { - id: track.artist.id, - mbid: track.artist.mbid, - name: track.artist.name - } - return { - id: track.id, - title: track.title, - mbid: track.mbid, - album: { - id: track.album.id, - title: track.album.title, - mbid: track.album.mbid, - cover: track.album.cover, - artist: artist - }, - artist: artist, - files: track.files - } - }), - currentIndex: this.currentIndex - } - cache.set('queue', cached) - } - - restore () { - let cached = cache.get('queue') - if (!cached) { - return false - } - logger.default.info('Restoring previous queue...') - this.tracks = cached.tracks - this.play(cached.currentIndex) - this.previousQueue = null - return true - } - removePrevious () { - this.previousQueue = undefined - cache.remove('queue') - } - setVolume (newValue) { - newValue = Math.min(newValue, 1) - newValue = Math.max(newValue, 0) - this.state.volume = newValue - if (this.audio.setVolume) { - this.audio.setVolume(newValue) - } else { - this.audio.state.volume = newValue - } - cache.set('volume', newValue) - } - incrementVolume (value) { - this.setVolume(this.state.volume + value) - } - reorder (oldIndex, newIndex) { - // called when the user uses drag / drop to reorder - // tracks in queue - if (oldIndex === this.currentIndex) { - this.currentIndex = newIndex - return - } - if (oldIndex < this.currentIndex && newIndex >= this.currentIndex) { - // item before was moved after - this.currentIndex -= 1 - } - if (oldIndex > this.currentIndex && newIndex <= this.currentIndex) { - // item after was moved before - this.currentIndex += 1 - } - } - - append (track, index, skipPlay) { - this.previousQueue = null - index = index || this.tracks.length - if (index > this.tracks.length - 1) { - // we simply push to the end - this.tracks.push(track) - } else { - // we insert the track at given position - this.tracks.splice(index, 0, track) - } - if (!skipPlay) { - this.resumeQueue() - } - this.cache() - } - - appendMany (tracks, index) { - logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title })) - let self = this - if (this.tracks.length === 0) { - index = 0 - } else { - index = index || this.tracks.length - } - tracks.forEach((t) => { - self.append(t, index, true) - index += 1 - }) - this.resumeQueue() - } - - resumeQueue () { - if (this.ended | this.errored) { - this.next() - } - } - - populateFromRadio () { - if (!radios.running) { - return - } - var self = this - radios.fetch().then((response) => { - logger.default.info('Adding track to queue from radio') - self.append(response.data.track) - }, (response) => { - logger.default.error('Error while adding track to queue from radio') - }) - } - - clean () { - this.stop() - radios.stop() - this.tracks = [] - this.currentIndex = -1 - this.currentTrack = null - // so we replay automatically on next track append - this.ended = true - } - - cleanTrack (index) { - // are we removing current playin track - let current = index === this.currentIndex - if (current) { - this.stop() - } - if (index < this.currentIndex) { - this.currentIndex -= 1 - } - this.tracks.splice(index, 1) - if (current) { - // we play next track, which now have the same index - this.play(index) - } - if (this.currentIndex === this.tracks.length - 1) { - this.populateFromRadio() - } - } - - stop () { - if (this.audio.pause) { - this.audio.pause() - } - if (this.audio.destroyed) { - this.audio.destroyed() - } - } - play (index) { - let self = this - let currentIndex = index - let currentTrack = this.tracks[index] - - if (this.audio.destroyed) { - logger.default.debug('Destroying previous audio...', index - 1) - this.audio.destroyed() - } - - if (!currentTrack) { - return - } - - this.currentIndex = currentIndex - this.currentTrack = currentTrack - - this.ended = false - this.errored = false - let file = this.currentTrack.files[0] - if (!file) { - this.errored = true - return this.next() - } - let path = backend.absoluteUrl(file.path) - if (auth.user.authenticated) { - // we need to send the token directly in url - // so authentication can be checked by the backend - // because for audio files we cannot use the regular Authentication - // header - path = url.updateQueryString(path, 'jwt', auth.getAuthToken()) - } - - let audio = new Audio(path, { - preload: true, - autoplay: true, - rate: 1, - loop: false, - volume: this.state.volume, - onEnded: this.handleAudioEnded.bind(this), - onError: function (src) { - self.errored = true - self.next() - } - }) - this.audio = audio - audio.updateHook('playState', function (e) { - // in some situations, we may have a race condition, for example - // if the user spams the next / previous buttons, with multiple audios - // playing at the same time. To avoid that, we ensure the audio - // still matches de queue current audio - if (audio !== self.audio) { - logger.default.debug('Destroying duplicate audio') - audio.destroyed() - } - }) - if (this.currentIndex === this.tracks.length - 1) { - this.populateFromRadio() - } - this.cache() - } - - handleAudioEnded (e) { - this.recordListen(this.currentTrack) - if (this.currentIndex < this.tracks.length - 1) { - logger.default.info('Audio track ended, playing next one') - this.next() - } else { - logger.default.info('We reached the end of the queue') - this.ended = true - } - } - - recordListen (track) { - let url = config.API_URL + 'history/listenings/' - let resource = Vue.resource(url) - resource.save({}, {'track': track.id}).then((response) => {}, (response) => { - logger.default.error('Could not record track in history') - }) - } - - previous () { - if (this.currentIndex > 0) { - this.play(this.currentIndex - 1) - } - } - - next () { - if (this.currentIndex < this.tracks.length - 1) { - logger.default.debug('Playing next track') - this.play(this.currentIndex + 1) - } - } - -} - -let queue = new Queue() - -export default queue diff --git a/front/src/auth/index.js b/front/src/auth/index.js deleted file mode 100644 index 80236942858440d517d2fe80b7bb43c71b3ea7c0..0000000000000000000000000000000000000000 --- a/front/src/auth/index.js +++ /dev/null @@ -1,99 +0,0 @@ -import logger from '@/logging' -import config from '@/config' -import cache from '@/cache' -import Vue from 'vue' - -import favoriteTracks from '@/favorites/tracks' - -// URL and endpoint constants -const LOGIN_URL = config.API_URL + 'token/' -const USER_PROFILE_URL = config.API_URL + 'users/users/me/' -// const SIGNUP_URL = API_URL + 'users/' - -let userData = { - authenticated: false, - username: '', - availablePermissions: {}, - profile: {} -} -let auth = { - - // Send a request to the login URL and save the returned JWT - login (context, creds, redirect, onError) { - return context.$http.post(LOGIN_URL, creds).then(response => { - logger.default.info('Successfully logged in as', creds.username) - cache.set('token', response.data.token) - cache.set('username', creds.username) - - this.user.authenticated = true - this.user.username = creds.username - this.connect() - // Redirect to a specified route - if (redirect) { - context.$router.push(redirect) - } - }, response => { - logger.default.error('Error while logging in', response.data) - if (onError) { - onError(response) - } - }) - }, - - // To log out, we just need to remove the token - logout () { - cache.clear() - this.user.authenticated = false - logger.default.info('Log out, goodbye!') - }, - - checkAuth () { - logger.default.info('Checking authentication...') - var jwt = this.getAuthToken() - var username = cache.get('username') - if (jwt) { - this.user.authenticated = true - this.user.username = username - logger.default.info('Logged back in as ' + username) - this.connect() - } else { - logger.default.info('Anonymous user') - this.user.authenticated = false - } - }, - - getAuthToken () { - return cache.get('token') - }, - - // The object to be passed as a header for authenticated requests - getAuthHeader () { - return 'JWT ' + this.getAuthToken() - }, - - fetchProfile () { - let resource = Vue.resource(USER_PROFILE_URL) - return resource.get({}).then((response) => { - logger.default.info('Successfully fetched user profile') - return response.data - }, (response) => { - logger.default.info('Error while fetching user profile') - }) - }, - connect () { - // called once user has logged in successfully / reauthenticated - // e.g. after a page refresh - let self = this - this.fetchProfile().then(data => { - Vue.set(self.user, 'profile', data) - Object.keys(data.permissions).forEach(function (key) { - // this makes it easier to check for permissions in templates - Vue.set(self.user.availablePermissions, key, data.permissions[String(key)].status) - }) - }) - favoriteTracks.fetch() - } -} - -Vue.set(auth, 'user', userData) -export default auth diff --git a/front/src/cache/index.js b/front/src/cache/index.js deleted file mode 100644 index e039ee788078daa4e11e1df194982e33014240af..0000000000000000000000000000000000000000 --- a/front/src/cache/index.js +++ /dev/null @@ -1,29 +0,0 @@ -import logger from '@/logging' -export default { - get (key, d) { - let v = localStorage.getItem(key) - if (v === null) { - return d - } else { - try { - return JSON.parse(v).value - } catch (e) { - logger.default.error('Removing unparsable cached value for key ' + key) - this.remove(key) - return d - } - } - }, - set (key, value) { - return localStorage.setItem(key, JSON.stringify({value: value})) - }, - - remove (key) { - return localStorage.removeItem(key) - }, - - clear () { - localStorage.clear() - } - -} diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 68927a37b09a5e302a9dfb7ce9be44b9e7750b96..a315aab199c341a24db3baed09cdad32a813975b 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -28,8 +28,8 @@ <div class="tabs"> <div class="ui bottom attached active tab" data-tab="library"> <div class="ui inverted vertical fluid menu"> - <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link> - <router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link> + <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i> Logged in as {{ $store.state.auth.username }}</router-link> + <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link> <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link> <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link> <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link> @@ -51,7 +51,7 @@ <div class="ui bottom attached tab" data-tab="queue"> <table class="ui compact inverted very basic fixed single line table"> <draggable v-model="queue.tracks" element="tbody" @update="reorder"> - <tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> + <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> <td class="right aligned">{{ index + 1}}</td> <td class="center aligned"> <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)"> @@ -62,24 +62,24 @@ {{ track.artist.name }} </td> <td> - <template v-if="favoriteTracks.objects[track.id]"> - <i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i> - </template + <template v-if="$store.getters['favorites/isFavorite'](track.id)"> + <i class="pink heart icon"></i> + </template </td> <td> - <i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i> + <i @click.stop="cleanTrack(index)" class="circular trash icon"></i> </td> </tr> </draggable> </table> - <div v-if="radios.running" class="ui black message"> + <div v-if="$store.state.radios.running" class="ui black message"> <div class="content"> <div class="header"> <i class="feed icon"></i> You have a radio playing </div> <p>New tracks will be appended here automatically.</p> - <div @click="radios.stop()" class="ui basic inverted red button">Stop radio</div> + <div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">Stop radio</div> </div> </div> </div> @@ -87,24 +87,17 @@ <div class="ui inverted segment player-wrapper"> <player></player> </div> - <GlobalEvents - @keydown.r.stop="queue.restore" - /> </div> </template> <script> -import GlobalEvents from '@/components/utils/global-events' +import {mapState, mapActions} from 'vuex' import Player from '@/components/audio/Player' -import favoriteTracks from '@/favorites/tracks' import Logo from '@/components/Logo' import SearchBar from '@/components/audio/SearchBar' -import auth from '@/auth' -import queue from '@/audio/queue' import backend from '@/audio/backend' import draggable from 'vuedraggable' -import radios from '@/radios' import $ from 'jquery' @@ -114,24 +107,27 @@ export default { Player, SearchBar, Logo, - draggable, - GlobalEvents + draggable }, data () { return { - auth: auth, - backend: backend, - queue: queue, - radios, - favoriteTracks + backend: backend } }, mounted () { $(this.$el).find('.menu .item').tab() }, + computed: { + ...mapState({ + queue: state => state.queue + }) + }, methods: { - reorder (e) { - this.queue.reorder(e.oldIndex, e.newIndex) + ...mapActions({ + cleanTrack: 'queue/cleanTrack' + }), + reorder: function (oldValue, newValue) { + this.$store.commit('queue/reorder', {oldValue, newValue}) } } } diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 240fa498032c05c2823f0aa3d53cf2e8a7991b0f..4767255ecae8b6bb27a872614f9a4e029146ded9 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -17,7 +17,6 @@ <script> import logger from '@/logging' -import queue from '@/audio/queue' import jQuery from 'jquery' export default { @@ -40,19 +39,19 @@ export default { methods: { add () { if (this.track) { - queue.append(this.track) + this.$store.dispatch('queue/append', {track: this.track}) } else { - queue.appendMany(this.tracks) + this.$store.dispatch('queue/appendMany', {tracks: this.tracks}) } }, addNext (next) { if (this.track) { - queue.append(this.track, queue.currentIndex + 1) + this.$store.dispatch('queue/append', {track: this.track, index: this.$store.state.queue.currentIndex + 1}) } else { - queue.appendMany(this.tracks, queue.currentIndex + 1) + this.$store.dispatch('queue/appendMany', {tracks: this.tracks, index: this.$store.state.queue.currentIndex + 1}) } if (next) { - queue.next() + this.$store.dispatch('queue/next') } } } diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 423c9d12f2d5cf89abf5b1f876a595925ee21370..500f4dc1d2b09e26cf036b871f842ca608b81d3b 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,130 +1,208 @@ <template> <div class="player"> - <div v-if="queue.currentTrack" class="track-area ui items"> + <audio-track + ref="currentAudio" + v-if="currentTrack" + :key="(currentIndex, currentTrack.id)" + :is-current="true" + :start-time="$store.state.player.currentTime" + :autoplay="$store.state.player.playing" + :track="currentTrack"> + </audio-track> + + <div v-if="currentTrack" class="track-area ui items"> <div class="ui inverted item"> <div class="ui tiny image"> - <img v-if="queue.currentTrack.album.cover" :src="Track.getCover(queue.currentTrack)"> + <img v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)"> <img v-else src="../../assets/audio/default-cover.png"> </div> <div class="middle aligned content"> - <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: queue.currentTrack.id }}"> - {{ queue.currentTrack.title }} + <router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"> + {{ currentTrack.title }} </router-link> <div class="meta"> - <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: queue.currentTrack.artist.id }}"> - {{ queue.currentTrack.artist.name }} + <router-link class="artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"> + {{ currentTrack.artist.name }} </router-link> / - <router-link class="album" :to="{name: 'library.albums.detail', params: {id: queue.currentTrack.album.id }}"> - {{ queue.currentTrack.album.title }} + <router-link class="album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"> + {{ currentTrack.album.title }} </router-link> </div> <div class="description"> - <track-favorite-icon :track="queue.currentTrack"></track-favorite-icon> + <track-favorite-icon :track="currentTrack"></track-favorite-icon> </div> </div> </div> </div> - <div class="progress-area" v-if="queue.currentTrack"> + <div class="progress-area" v-if="currentTrack"> <div class="ui grid"> <div class="left floated four wide column"> - <p class="timer start" @click="queue.audio.setTime(0)">{{queue.audio.state.currentTimeFormat}}</p> + <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p> </div> <div class="right floated four wide column"> - <p class="timer total">{{queue.audio.state.durationTimerFormat}}</p> + <p class="timer total">{{durationFormatted}}</p> </div> </div> <div ref="progress" class="ui small orange inverted progress" @click="touchProgress"> - <div class="bar" :data-percent="queue.audio.state.progress" :style="{ 'width': queue.audio.state.progress + '%' }"></div> + <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div> </div> </div> - <div class="controls ui grid"> - <div class="volume-control four wide center aligned column"> + <div class="two wide column controls ui grid"> + <div + @click="previous" + title="Previous track" + class="two wide column control" + :disabled="!hasPrevious"> + <i :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i> + </div> + <div + v-if="!playing" + @click="togglePlay" + title="Play track" + class="two wide column control"> + <i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i> + </div> + <div + v-else + @click="togglePlay" + title="Pause track" + class="two wide column control"> + <i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i> + </div> + <div + @click="next" + title="Next track" + class="two wide column control" + :disabled="!hasNext"> + <i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i> + </div> + <div class="two wide column control volume-control"> + <i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i> + <i title="Mute" @click="$store.commit('player/volume', 0)" v-else-if="volume < 0.5" class="volume down secondary icon"></i> + <i title="Mute" @click="$store.commit('player/volume', 0)" v-else class="volume up secondary icon"></i> <input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" /> - <i title="Unmute" @click="queue.setVolume(1)" v-if="currentVolume === 0" class="volume off secondary icon"></i> - <i title="Mute" @click="queue.setVolume(0)" v-else-if="currentVolume < 0.5" class="volume down secondary icon"></i> - <i title="Mute" @click="queue.setVolume(0)" v-else class="volume up secondary icon"></i> </div> - <div class="eight wide center aligned column"> - <i title="Previous track" @click="queue.previous()" :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" :disabled="!hasPrevious"></i> - <i title="Play track" v-if="!queue.audio.state.playing" :class="['ui', 'play', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i> - <i title="Pause track" v-else :class="['ui', 'pause', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i> - <i title="Next track" @click="queue.next()" :class="['ui', 'step', 'forward', {'disabled': !hasNext}, 'big', 'icon']" :disabled="!hasNext"></i> + <div class="two wide column control looping"> + <i + title="Looping disabled. Click to switch to single-track looping." + v-if="looping === 0" + @click="$store.commit('player/looping', 1)" + :disabled="!currentTrack" + :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'secondary', 'icon']"></i> + <i + title="Looping on a single track. Click to switch to whole queue looping." + v-if="looping === 1" + @click="$store.commit('player/looping', 2)" + :disabled="!currentTrack" + class="repeat secondary icon"> + <span class="ui circular tiny orange label">1</span> + </i> + <i + title="Looping on whole queue. Click to disable looping." + v-if="looping === 2" + @click="$store.commit('player/looping', 0)" + :disabled="!currentTrack" + class="repeat orange secondary icon"> + </i> </div> - <div class="four wide center aligned column"> - <i title="Clear your queue" @click="queue.clean()" :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" :disabled="queue.tracks.length === 0"></i> + <div + @click="shuffle()" + :disabled="queue.tracks.length === 0" + title="Shuffle your queue" + class="two wide column control"> + <i :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> + </div> + <div class="one wide column"></div> + <div + @click="clean()" + :disabled="queue.tracks.length === 0" + title="Clear your queue" + class="two wide column control"> + <i :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i> </div> </div> <GlobalEvents - @keydown.space.prevent="pauseOrPlay" - @keydown.ctrl.left.prevent="queue.previous" - @keydown.ctrl.right.prevent="queue.next" - @keydown.ctrl.down.prevent="queue.incrementVolume(-0.1)" - @keydown.ctrl.up.prevent="queue.incrementVolume(0.1)" + @keydown.space.prevent.exact="togglePlay" + @keydown.ctrl.left.prevent.exact="previous" + @keydown.ctrl.right.prevent.exact="next" + @keydown.ctrl.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)" + @keydown.ctrl.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)" + @keydown.f.prevent.exact="$store.dispatch('favorites/toggle', currentTrack.id)" + @keydown.l.prevent.exact="$store.commit('player/toggleLooping')" + @keydown.s.prevent.exact="shuffle" /> </div> </template> <script> +import {mapState, mapGetters, mapActions} from 'vuex' import GlobalEvents from '@/components/utils/global-events' -import queue from '@/audio/queue' import Track from '@/audio/track' +import AudioTrack from '@/components/audio/Track' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' -import radios from '@/radios' export default { name: 'player', components: { TrackFavoriteIcon, - GlobalEvents + GlobalEvents, + AudioTrack }, data () { return { - sliderVolume: this.currentVolume, - queue: queue, - Track: Track, - radios + sliderVolume: this.volume, + Track: Track } }, mounted () { // we trigger the watcher explicitely it does not work otherwise - this.sliderVolume = this.currentVolume + this.sliderVolume = this.volume }, methods: { - pauseOrPlay () { - if (this.queue.audio.state.playing) { - this.queue.audio.pause() - } else { - this.queue.audio.play() - } - }, + ...mapActions({ + pause: 'player/pause', + togglePlay: 'player/togglePlay', + clean: 'queue/clean', + next: 'queue/next', + previous: 'queue/previous', + shuffle: 'queue/shuffle', + updateProgress: 'player/updateProgress' + }), touchProgress (e) { let time let target = this.$refs.progress - time = e.layerX / target.offsetWidth * this.queue.audio.state.duration - this.queue.audio.setTime(time) + time = e.layerX / target.offsetWidth * this.duration + this.$refs.currentAudio.setCurrentTime(time) } }, computed: { - hasPrevious () { - return this.queue.currentIndex > 0 - }, - hasNext () { - return this.queue.currentIndex < this.queue.tracks.length - 1 - }, - currentVolume () { - return this.queue.audio.state.volume - } + ...mapState({ + currentIndex: state => state.queue.currentIndex, + playing: state => state.player.playing, + volume: state => state.player.volume, + looping: state => state.player.looping, + duration: state => state.player.duration, + queue: state => state.queue + }), + ...mapGetters({ + currentTrack: 'queue/currentTrack', + hasNext: 'queue/hasNext', + hasPrevious: 'queue/hasPrevious', + durationFormatted: 'player/durationFormatted', + currentTimeFormatted: 'player/currentTimeFormatted', + progress: 'player/progress' + }) }, watch: { - currentVolume (newValue) { + volume (newValue) { this.sliderVolume = newValue }, sliderVolume (newValue) { - this.queue.setVolume(parseFloat(newValue)) + this.$store.commit('player/volume', newValue) } } } @@ -184,14 +262,32 @@ export default { .volume-control { position: relative; .icon { - margin: 0; + // margin: 0; } [type="range"] { - max-width: 75%; + max-width: 100%; position: absolute; bottom: 5px; left: 10%; cursor: pointer; + display: none; + } + &:hover { + [type="range"] { + display: block; + } + } +} + +.looping.control { + i { + position: relative; + } + .label { + position: absolute; + font-size: 0.7rem; + bottom: -0.7rem; + right: -0.7rem; } } .ui.feed.icon { diff --git a/front/src/components/audio/Search.vue b/front/src/components/audio/Search.vue index 5c902e5e5abba8e1472feff9cfae3af269e1641e..2811c2b5c4f58ca71f8c5c55c239ffcf41c0e5d6 100644 --- a/front/src/components/audio/Search.vue +++ b/front/src/components/audio/Search.vue @@ -30,7 +30,6 @@ <script> import logger from '@/logging' -import queue from '@/audio/queue' import backend from '@/audio/backend' import AlbumCard from '@/components/audio/album/Card' import ArtistCard from '@/components/audio/artist/Card' @@ -54,8 +53,7 @@ export default { artists: [] }, backend: backend, - isLoading: false, - queue: queue + isLoading: false } }, mounted () { diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index 2324c88392f258f95b7915cce04ea1b9166d5e80..9d8b39f870cac19021fc56d9df3368d8f05f6ddf 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -12,13 +12,13 @@ <script> import jQuery from 'jquery' import config from '@/config' -import auth from '@/auth' import router from '@/router' const SEARCH_URL = config.API_URL + 'search?query={query}' export default { mounted () { + let self = this jQuery(this.$el).search({ type: 'category', minCharacters: 3, @@ -27,7 +27,7 @@ export default { }, apiSettings: { beforeXHR: function (xhrObject) { - xhrObject.setRequestHeader('Authorization', auth.getAuthHeader()) + xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) return xhrObject }, onResponse: function (initialResponse) { diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue new file mode 100644 index 0000000000000000000000000000000000000000..c8627925ebdd404e4d8dc9887d8a0307c22705fd --- /dev/null +++ b/front/src/components/audio/Track.vue @@ -0,0 +1,109 @@ +<template> + <audio + ref="audio" + :src="url" + @error="errored" + @progress="updateLoad" + @loadeddata="loaded" + @timeupdate="updateProgress" + @ended="ended" + preload> + + </audio> +</template> + +<script> +import {mapState} from 'vuex' +import backend from '@/audio/backend' +import url from '@/utils/url' + +// import logger from '@/logging' + +export default { + props: { + track: {type: Object}, + isCurrent: {type: Boolean, default: false}, + startTime: {type: Number, default: 0}, + autoplay: {type: Boolean, default: false} + }, + computed: { + ...mapState({ + playing: state => state.player.playing, + currentTime: state => state.player.currentTime, + duration: state => state.player.duration, + volume: state => state.player.volume, + looping: state => state.player.looping + }), + url: function () { + let file = this.track.files[0] + if (!file) { + this.$store.dispatch('player/trackErrored') + return null + } + let path = backend.absoluteUrl(file.path) + if (this.$store.state.auth.authenticated) { + // we need to send the token directly in url + // so authentication can be checked by the backend + // because for audio files we cannot use the regular Authentication + // header + path = url.updateQueryString(path, 'jwt', this.$store.state.auth.token) + } + return path + } + }, + methods: { + errored: function () { + this.$store.dispatch('player/trackErrored') + }, + updateLoad: function () { + + }, + loaded: function () { + if (this.isCurrent && this.autoplay) { + this.$store.commit('player/duration', this.$refs.audio.duration) + if (this.startTime) { + this.setCurrentTime(this.startTime) + } + this.$store.commit('player/playing', true) + this.$refs.audio.play() + } + }, + updateProgress: function () { + if (this.$refs.audio) { + this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime) + } + }, + ended: function () { + if (this.looping === 1) { + this.setCurrentTime(0) + this.$refs.audio.play() + } else { + this.$store.dispatch('player/trackEnded', this.track) + } + }, + setCurrentTime (t) { + if (t < 0 | t > this.duration) { + return + } + this.updateProgress(t) + this.$refs.audio.currentTime = t + } + }, + watch: { + playing: function (newValue) { + if (newValue === true) { + this.$refs.audio.play() + } else { + this.$refs.audio.pause() + } + }, + volume: function (newValue) { + this.$refs.audio.volume = newValue + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index ce5e832e2794a4288a673460244434f30945615f..4c803b29cc704fbc7e05258bfe595ee9b6908d99 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -51,7 +51,6 @@ </template> <script> -import queue from '@/audio/queue' import backend from '@/audio/backend' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import PlayButton from '@/components/audio/PlayButton' @@ -68,7 +67,6 @@ export default { data () { return { backend: backend, - queue: queue, initialTracks: 4, showAllTracks: false } diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index efb98e382804ecb2623d4f96f5e6b9935559052b..8a591d3bd05e9542484a2b8bf8ef9b931363a837 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -58,9 +58,9 @@ Keep your PRIVATE_TOKEN secret as it gives access to your account. </div> <pre> -export PRIVATE_TOKEN="{{ auth.getAuthToken ()}}" +export PRIVATE_TOKEN="{{ $store.state.auth.token }}" <template v-for="track in tracks"><template v-if="track.files.length > 0"> -curl -G -o "{{ track.files[0].filename }}" <template v-if="auth.user.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template> +curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template> </pre> </div> </div> @@ -83,7 +83,6 @@ curl -G -o "{{ track.files[0].filename }}" <template v-if="auth.user.authenticat <script> import backend from '@/audio/backend' -import auth from '@/auth' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import PlayButton from '@/components/audio/PlayButton' @@ -102,7 +101,6 @@ export default { data () { return { backend: backend, - auth: auth, showDownloadModal: false } } diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue index 54e7b82e096433aacd18d02053e66c164e51ca60..99b439af8b3e25c821ddb1dbc0c86c6419961d2c 100644 --- a/front/src/components/auth/Login.vue +++ b/front/src/components/auth/Login.vue @@ -39,12 +39,11 @@ </template> <script> -import auth from '@/auth' export default { name: 'login', props: { - next: {type: String} + next: {type: String, default: '/'} }, data () { return { @@ -72,14 +71,17 @@ export default { } // We need to pass the component's this context // to properly make use of http in the auth service - auth.login(this, credentials, {path: this.next}, function (response) { - // error callback - if (response.status === 400) { - self.error = 'invalid_credentials' - } else { - self.error = 'unknown_error' + this.$store.dispatch('auth/login', { + credentials, + next: this.next, + onError: response => { + if (response.status === 400) { + self.error = 'invalid_credentials' + } else { + self.error = 'unknown_error' + } } - }).then((response) => { + }).then(e => { self.isLoading = false }) } diff --git a/front/src/components/auth/Logout.vue b/front/src/components/auth/Logout.vue index f4b2979bc05394dc04f7644e88700a08b13989d5..fbacca70338ed295dc284664d443c9b239b25da0 100644 --- a/front/src/components/auth/Logout.vue +++ b/front/src/components/auth/Logout.vue @@ -3,8 +3,8 @@ <div class="ui vertical stripe segment"> <div class="ui small text container"> <h2>Are you sure you want to log out?</h2> - <p>You are currently logged in as {{ auth.user.username }}</p> - <button class="ui button" @click="logout">Yes, log me out!</button> + <p>You are currently logged in as {{ $store.state.auth.username }}</p> + <button class="ui button" @click="$store.dispatch('auth/logout')">Yes, log me out!</button> </form> </div> </div> @@ -12,23 +12,8 @@ </template> <script> -import auth from '@/auth' - export default { - name: 'logout', - data () { - return { - // We need to initialize the component with any - // properties that will be used in it - auth: auth - } - }, - methods: { - logout () { - auth.logout() - this.$router.push({name: 'index'}) - } - } + name: 'logout' } </script> diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue index 2aaae9e2df34d292f5cc66ae04dee5898bc9d335..54af5a11c4c0027fc68f81e9c6eca283d4ed8aee 100644 --- a/front/src/components/auth/Profile.vue +++ b/front/src/components/auth/Profile.vue @@ -3,55 +3,45 @@ <div v-if="isLoading" class="ui vertical segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> - <template v-if="profile"> + <template v-if="$store.state.auth.profile"> <div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']"> <h2 class="ui center aligned icon header"> <i class="circular inverted user green icon"></i> <div class="content"> - {{ profile.username }} + {{ $store.state.auth.profile.username }} <div class="sub header">Registered since {{ signupDate }}</div> </div> </h2> <div class="ui basic green label">this is you!</div> - <div v-if="profile.is_staff" class="ui yellow label"> + <div v-if="$store.state.auth.profile.is_staff" class="ui yellow label"> <i class="star icon"></i> Staff member </div> + <router-link class="ui tiny basic button" :to="{path: '/settings'}"> + <i class="setting icon"> </i>Settings... + </router-link> + </div> </template> </div> </template> <script> -import auth from '@/auth' -var dateFormat = require('dateformat') +const dateFormat = require('dateformat') export default { name: 'login', props: ['username'], - data () { - return { - profile: null - } - }, created () { - this.fetchProfile() - }, - methods: { - fetchProfile () { - let self = this - auth.fetchProfile().then(data => { - self.profile = data - }) - } + this.$store.dispatch('auth/fetchProfile') }, computed: { signupDate () { - let d = new Date(this.profile.date_joined) + let d = new Date(this.$store.state.auth.profile.date_joined) return dateFormat(d, 'longDate') }, isLoading () { - return !this.profile + return !this.$store.state.auth.profile } } } diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue new file mode 100644 index 0000000000000000000000000000000000000000..d93373a1496937380eab3531855696289617996a --- /dev/null +++ b/front/src/components/auth/Settings.vue @@ -0,0 +1,84 @@ +<template> + <div class="main pusher"> + <div class="ui vertical stripe segment"> + <div class="ui small text container"> + <h2>Change my password</h2> + <form class="ui form" @submit.prevent="submit()"> + <div v-if="error" class="ui negative message"> + <div class="header">Cannot change your password</div> + <ul class="list"> + <li v-if="error == 'invalid_credentials'">Please double-check your password is correct</li> + </ul> + </div> + <div class="field"> + <label>Old password</label> + <input + required + type="password" + autofocus + placeholder="Enter your old password" + v-model="old_password"> + </div> + <div class="field"> + <label>New password</label> + <input + required + type="password" + autofocus + placeholder="Enter your new password" + v-model="new_password"> + </div> + <button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Change password</button> + </form> + </div> + </div> + </div> +</template> + +<script> +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' + +export default { + data () { + return { + // We need to initialize the component with any + // properties that will be used in it + old_password: '', + new_password: '', + error: '', + isLoading: false + } + }, + methods: { + submit () { + var self = this + self.isLoading = true + this.error = '' + var credentials = { + old_password: this.old_password, + new_password1: this.new_password, + new_password2: this.new_password + } + let resource = Vue.resource(config.BACKEND_URL + 'api/auth/registration/change-password/') + return resource.save({}, credentials).then(response => { + logger.default.info('Password successfully changed') + self.$router.push('/profile/me') + }, response => { + if (response.status === 400) { + self.error = 'invalid_credentials' + } else { + self.error = 'unknown_error' + } + self.isLoading = false + }) + } + } + +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue index 63c3ba79d18c7360cdef97f8739614cfb1f1f163..8577e84ca5d339e7ed3ad0b10a4e99ef69411c83 100644 --- a/front/src/components/favorites/List.vue +++ b/front/src/components/favorites/List.vue @@ -6,12 +6,39 @@ </div> <h2 v-if="results" class="ui center aligned icon header"> <i class="circular inverted heart pink icon"></i> - {{ favoriteTracks.count }} favorites + {{ $store.state.favorites.count }} favorites </h2> <radio-button type="favorites"></radio-button> - </div> <div class="ui vertical stripe segment"> + <div :class="['ui', {'loading': isLoading}, 'form']"> + <div class="fields"> + <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> + <track-table v-if="results" :tracks="results.results"></track-table> <div class="ui center aligned basic segment"> <pagination @@ -27,54 +54,73 @@ </template> <script> -import Vue from 'vue' +import $ from 'jquery' import logger from '@/logging' import config from '@/config' -import favoriteTracks from '@/favorites/tracks' import TrackTable from '@/components/audio/track/Table' import RadioButton from '@/components/radios/Button' import Pagination from '@/components/Pagination' - +import OrderingMixin from '@/components/mixins/Ordering' +import PaginationMixin from '@/components/mixins/Pagination' const FAVORITES_URL = config.API_URL + 'tracks/' export default { + mixins: [OrderingMixin, PaginationMixin], components: { TrackTable, RadioButton, Pagination }, data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || 'artist__name') return { results: null, isLoading: false, nextLink: null, previousLink: null, - page: 1, - paginateBy: 25, - favoriteTracks + page: parseInt(this.defaultPage), + paginateBy: parseInt(this.defaultPaginateBy || 25), + orderingDirection: defaultOrdering.direction, + ordering: defaultOrdering.field, + orderingOptions: [ + ['title', 'Track name'], + ['album__title', 'Album name'], + ['artist__name', 'Artist name'] + ] } }, created () { this.fetchFavorites(FAVORITES_URL) }, + mounted () { + $('.ui.dropdown').dropdown() + }, methods: { + updateQueryString: function () { + this.$router.replace({ + query: { + page: this.page, + paginateBy: this.paginateBy, + ordering: this.getOrderingAsString() + } + }) + }, fetchFavorites (url) { var self = this this.isLoading = true let params = { favorites: 'true', page: this.page, - page_size: this.paginateBy + page_size: this.paginateBy, + ordering: this.getOrderingAsString() } logger.default.time('Loading user favorites') this.$http.get(url, {params: params}).then((response) => { self.results = response.data self.nextLink = response.data.next self.previousLink = response.data.previous - Vue.set(favoriteTracks, 'count', response.data.count) - favoriteTracks.count = response.data.count self.results.results.forEach((track) => { - Vue.set(favoriteTracks.objects, track.id, true) + self.$store.commit('favorites/track', {id: track.id, value: true}) }) logger.default.timeEnd('Loading user favorites') self.isLoading = false @@ -86,6 +132,19 @@ export default { }, watch: { page: function () { + this.updateQueryString() + this.fetchFavorites(FAVORITES_URL) + }, + paginateBy: function () { + this.updateQueryString() + this.fetchFavorites(FAVORITES_URL) + }, + orderingDirection: function () { + this.updateQueryString() + this.fetchFavorites(FAVORITES_URL) + }, + ordering: function () { + this.updateQueryString() this.fetchFavorites(FAVORITES_URL) } } diff --git a/front/src/components/favorites/TrackFavoriteIcon.vue b/front/src/components/favorites/TrackFavoriteIcon.vue index ef490da9b1caa56340926887b05ce8f4de05c4d1..d4838ba5f336ff0375145ed5925029a19bf68587 100644 --- a/front/src/components/favorites/TrackFavoriteIcon.vue +++ b/front/src/components/favorites/TrackFavoriteIcon.vue @@ -1,5 +1,5 @@ -<template> - <button @click="favoriteTracks.set(track.id, !isFavorite)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']"> + <template> + <button @click="$store.dispatch('favorites/toggle', track.id)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']"> <i class="heart icon"></i> <template v-if="isFavorite"> In favorites @@ -8,27 +8,15 @@ Add to favorites </template> </button> - <i v-else @click="favoriteTracks.set(track.id, !isFavorite)" :class="['favorite-icon', 'heart', {'pink': isFavorite}, {'favorited': isFavorite}, 'link', 'icon']" :title="title"></i> + <i v-else @click="$store.dispatch('favorites/toggle', track.id)" :class="['favorite-icon', 'heart', {'pink': isFavorite}, {'favorited': isFavorite}, 'link', 'icon']" :title="title"></i> </template> <script> -import favoriteTracks from '@/favorites/tracks' - export default { props: { track: {type: Object}, button: {type: Boolean, default: false} }, - data () { - return { - favoriteTracks - } - }, - methods: { - toggleFavorite () { - this.isFavorite = !this.isFavorite - } - }, computed: { title () { if (this.isFavorite) { @@ -38,7 +26,7 @@ export default { } }, isFavorite () { - return favoriteTracks.objects[this.track.id] + return this.$store.getters['favorites/isFavorite'](this.track.id) } } diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 2f0fb0a9236197d725191d4d1106037f855d3955..23a30e7711fda31fcbb61f7860fa70b93807c7c6 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -1,11 +1,40 @@ <template> <div> - <div v-if="isLoading" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - <div v-if="result" class="ui vertical stripe segment"> + <div class="ui vertical stripe segment"> <h2 class="ui header">Browsing artists</h2> - <div class="ui stackable three column grid"> + <div :class="['ui', {'loading': isLoading}, 'form']"> + <div class="fields"> + <div class="field"> + <label>Search</label> + <input type="text" v-model="query" placeholder="Enter an artist 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> + <div v-if="result" class="ui stackable three column grid"> <div v-if="result.results.length > 0" v-for="artist in result.results" @@ -28,41 +57,71 @@ </template> <script> +import _ from 'lodash' +import $ from 'jquery' import config from '@/config' import backend from '@/audio/backend' import logger from '@/logging' + +import OrderingMixin from '@/components/mixins/Ordering' +import PaginationMixin from '@/components/mixins/Pagination' import ArtistCard from '@/components/audio/artist/Card' import Pagination from '@/components/Pagination' const FETCH_URL = config.API_URL + 'artists/' export default { + mixins: [OrderingMixin, PaginationMixin], + props: { + defaultQuery: {type: String, required: false, default: ''} + }, components: { ArtistCard, Pagination }, data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') return { isLoading: true, result: null, - page: 1, - orderBy: 'name', - paginateBy: 12 + page: parseInt(this.defaultPage), + query: this.defaultQuery, + paginateBy: parseInt(this.defaultPaginateBy || 12), + orderingDirection: defaultOrdering.direction, + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'Creation date'], + ['name', 'Name'] + ] } }, created () { this.fetchData() }, + mounted () { + $('.ui.dropdown').dropdown() + }, methods: { - fetchData () { + 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, - order_by: 'name' + name__icontains: this.query, + ordering: this.getOrderingAsString() } logger.default.debug('Fetching artists') this.$http.get(url, {params: params}).then((response) => { @@ -76,13 +135,30 @@ export default { }) 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() } } diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index da9ac19b3ad7cef127384b446296d76e9a39a638..f5303d88c8e14d8a959d541903e23e13ce100528 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -4,25 +4,18 @@ <router-link class="ui item" to="/library" exact>Browse</router-link> <router-link class="ui item" to="/library/artists" exact>Artists</router-link> <div class="ui secondary right menu"> - <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link> - <router-link v-if="auth.user.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> + <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link> + <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link> </div> </div> - <router-view></router-view> + <router-view :key="$route.fullPath"></router-view> </div> </template> <script> -import auth from '@/auth' - export default { - name: 'library', - data: function () { - return { - auth - } - } + name: 'library' } </script> @@ -30,6 +23,10 @@ export default { <style lang="scss"> .library.pusher > .ui.secondary.menu { margin: 0 2.5rem; + .item { + padding-top: 1.5em; + padding-bottom: 1.5em; + } } .library { diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 36a76e822c2ac0218d8c23a1fb2cca8f21db4f29..48cd801c3d8a96fc34b4904febbacb0d6243d66f 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -61,7 +61,6 @@ <script> -import auth from '@/auth' import url from '@/utils/url' import logger from '@/logging' import backend from '@/audio/backend' @@ -124,8 +123,8 @@ export default { downloadUrl () { if (this.track.files.length > 0) { let u = backend.absoluteUrl(this.track.files[0].path) - if (auth.user.authenticated) { - u = url.updateQueryString(u, 'jwt', auth.getAuthToken()) + if (this.$store.state.auth.authenticated) { + u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token) } return u } diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue index 41b94bd4eca31b81a2bf8b66accfd0def921717f..c78f1ea4e19a28195e573880e491baaf81147c7c 100644 --- a/front/src/components/library/import/BatchList.vue +++ b/front/src/components/library/import/BatchList.vue @@ -1,36 +1,38 @@ <template> <div> <div class="ui vertical stripe segment"> - <div v-if="isLoading" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - </div> - <div class="ui vertical stripe segment"> - <button class="ui left floated labeled icon button" @click="fetchData(previousLink)" :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button> - <button class="ui right floated right labeled icon button" @click="fetchData(nextLink)" :disabled="!nextLink">Next <i class="right arrow icon"></i></button> - <div class="ui hidden clearing divider"></div> - <div class="ui hidden clearing divider"></div> - <table v-if="results.length > 0" class="ui table"> - <thead> - <tr> - <th>ID</th> - <th>Launch date</th> - <th>Jobs</th> - <th>Status</th> - </tr> - </thead> - <tbody> - <tr v-for="result in results"> - <td>{{ result.id }}</th> - <td> - <router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}"> - {{ result.creation_date }} - </router-link> - </td> - <td>{{ result.jobs.length }}</td> - <td> - <span - :class="['ui', {'yellow': result.status === 'pending'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span> + <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + <button + class="ui left floated labeled icon button" + @click="fetchData(previousLink)" + :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button> + <button + class="ui right floated right labeled icon button" + @click="fetchData(nextLink)" + :disabled="!nextLink">Next <i class="right arrow icon"></i></button> + <div class="ui hidden clearing divider"></div> + <div class="ui hidden clearing divider"></div> + <table v-if="results.length > 0" class="ui table"> + <thead> + <tr> + <th>ID</th> + <th>Launch date</th> + <th>Jobs</th> + <th>Status</th> + </tr> + </thead> + <tbody> + <tr v-for="result in results"> + <td>{{ result.id }}</th> + <td> + <router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}"> + {{ result.creation_date }} + </router-link> + </td> + <td>{{ result.jobs.length }}</td> + <td> + <span + :class="['ui', {'yellow': result.status === 'pending'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span> </td> </tr> </tbody> diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue index 8a400cf7b0e5b488e794afb62a0ddab8d38ce59a..f2dea6cab9dfa45c0edc8fb3ff995961ca1b3dea 100644 --- a/front/src/components/metadata/Search.vue +++ b/front/src/components/metadata/Search.vue @@ -23,7 +23,6 @@ <script> import jQuery from 'jquery' import config from '@/config' -import auth from '@/auth' export default { props: { @@ -66,7 +65,7 @@ export default { }, apiSettings: { beforeXHR: function (xhrObject, s) { - xhrObject.setRequestHeader('Authorization', auth.getAuthHeader()) + xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) return xhrObject }, onResponse: function (initialResponse) { diff --git a/front/src/components/mixins/Ordering.vue b/front/src/components/mixins/Ordering.vue new file mode 100644 index 0000000000000000000000000000000000000000..494dddcee15983d7449e477e9a7638d9befc2d1a --- /dev/null +++ b/front/src/components/mixins/Ordering.vue @@ -0,0 +1,26 @@ +<script> +export default { + props: { + defaultOrdering: {type: String, required: false} + }, + methods: { + getOrderingFromString (s) { + let parts = s.split('-') + if (parts.length > 1) { + return { + direction: '-', + field: parts.slice(1).join('-') + } + } else { + return { + direction: '', + field: s + } + } + }, + getOrderingAsString () { + return [this.orderingDirection, this.ordering].join('') + } + } +} +</script> diff --git a/front/src/components/mixins/Pagination.vue b/front/src/components/mixins/Pagination.vue new file mode 100644 index 0000000000000000000000000000000000000000..532faaaa3bc52872a6be104d5577f94b8f58d30c --- /dev/null +++ b/front/src/components/mixins/Pagination.vue @@ -0,0 +1,8 @@ +<script> +export default { + props: { + defaultPage: {required: false, default: 1}, + defaultPaginateBy: {required: false} + } +} +</script> diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index b334dce561183cb32a06ba8ce5b6955f202ef244..4bf4279890d05f39ac06a350164b9c2101747068 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -9,33 +9,28 @@ <script> -import radios from '@/radios' - export default { props: { type: {type: String, required: true}, objectId: {type: Number, default: null} }, - data () { - return { - radios - } - }, methods: { toggleRadio () { if (this.running) { - radios.stop() + this.$store.dispatch('radios/stop') } else { - radios.start(this.type, this.objectId) + this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId}) } } }, computed: { running () { - if (!radios.running) { + let state = this.$store.state.radios + let current = state.current + if (!state.running) { return false } else { - return radios.current.type === this.type & radios.current.objectId === this.objectId + return current.type === this.type & current.objectId === this.objectId } } } diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index 1e496324aa893518ca2728fcf6f9f19c168bc9c1..dc8a24ff3c2e31d901ee11fb27eceb976ca7e819 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -13,7 +13,6 @@ </template> <script> -import radios from '@/radios' import RadioButton from './Button' export default { @@ -25,7 +24,7 @@ export default { }, computed: { radio () { - return radios.types[this.type] + return this.$store.getters['radios/types'][this.type] } } } diff --git a/front/src/components/utils/global-events.vue b/front/src/components/utils/global-events.vue index 2905e3a7d337dddc3b66ecc528e0d4f9c607dd9f..dd25865c902d1e0237a45149521279a403a11f66 100644 --- a/front/src/components/utils/global-events.vue +++ b/front/src/components/utils/global-events.vue @@ -27,7 +27,7 @@ export default { let wrapper = function (event) { // we check here the event is not triggered from an input // to avoid collisions - if (!$(event.target).is(':input, [contenteditable]')) { + if (!$(event.target).is('.field, :input, [contenteditable]')) { handler(event) } } diff --git a/front/src/favorites/tracks.js b/front/src/favorites/tracks.js deleted file mode 100644 index ac3cb5eaa2d41609d25b93e269de7622b3fabfad..0000000000000000000000000000000000000000 --- a/front/src/favorites/tracks.js +++ /dev/null @@ -1,53 +0,0 @@ -import config from '@/config' -import logger from '@/logging' -import Vue from 'vue' - -const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/' -const FAVORITES_URL = config.API_URL + 'favorites/tracks/' - -export default { - objects: {}, - count: 0, - set (id, newValue) { - let self = this - Vue.set(self.objects, id, newValue) - if (newValue) { - Vue.set(self, 'count', self.count + 1) - let resource = Vue.resource(FAVORITES_URL) - resource.save({}, {'track': id}).then((response) => { - logger.default.info('Successfully added track to favorites') - }, (response) => { - logger.default.info('Error while adding track to favorites') - Vue.set(self.objects, id, !newValue) - Vue.set(self, 'count', self.count - 1) - }) - } else { - Vue.set(self, 'count', self.count - 1) - let resource = Vue.resource(REMOVE_URL) - resource.delete({}, {'track': id}).then((response) => { - logger.default.info('Successfully removed track from favorites') - }, (response) => { - logger.default.info('Error while removing track from favorites') - Vue.set(self.objects, id, !newValue) - Vue.set(self, 'count', self.count + 1) - }) - } - }, - fetch (url) { - // will fetch favorites by batches from API to have them locally - var self = this - url = url || FAVORITES_URL - let resource = Vue.resource(url) - resource.get().then((response) => { - logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites') - Vue.set(self, 'count', response.data.count) - response.data.results.forEach(result => { - Vue.set(self.objects, result.track, true) - }) - if (response.data.next) { - self.fetch(response.data.next) - } - }) - } - -} diff --git a/front/src/main.js b/front/src/main.js index f153635121ececa77e909defc6defccc8d00c938..f7a6b65f4df7cdd2ddfc4b37914999b12b3e9186 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -9,8 +9,8 @@ import Vue from 'vue' import App from './App' import router from './router' import VueResource from 'vue-resource' -import auth from './auth' import VueLazyload from 'vue-lazyload' +import store from './store' window.$ = window.jQuery = require('jquery') @@ -25,23 +25,25 @@ Vue.config.productionTip = false Vue.http.interceptors.push(function (request, next) { // modify headers - if (auth.user.authenticated) { - request.headers.set('Authorization', auth.getAuthHeader()) + if (store.state.auth.authenticated) { + request.headers.set('Authorization', store.getters['auth/header']) } next(function (response) { // redirect to login form when we get unauthorized response from server if (response.status === 401) { + store.commit('auth/authenticated', false) logger.default.warn('Received 401 response from API, redirecting to login form') router.push({name: 'login', query: {next: router.currentRoute.fullPath}}) } }) }) -auth.checkAuth() +store.dispatch('auth/check') /* eslint-disable no-new */ new Vue({ el: '#app', router, + store, template: '<App/>', components: { App } }) diff --git a/front/src/radios/index.js b/front/src/radios/index.js deleted file mode 100644 index b468830863f1a443a987149128495413b7b5f000..0000000000000000000000000000000000000000 --- a/front/src/radios/index.js +++ /dev/null @@ -1,64 +0,0 @@ -import Vue from 'vue' -import config from '@/config' -import logger from '@/logging' -import queue from '@/audio/queue' - -const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/' -const GET_TRACK_URL = config.API_URL + 'radios/tracks/' - -var radios = { - types: { - random: { - name: 'Random', - description: "Totally random picks, maybe you'll discover new things?" - }, - favorites: { - name: 'Favorites', - description: 'Play your favorites tunes in a never-ending happiness loop.' - }, - 'less-listened': { - name: 'Less listened', - description: "Listen to tracks you usually don't. It's time to restore some balance." - } - }, - start (type, objectId) { - this.current.type = type - this.current.objectId = objectId - this.running = true - let resource = Vue.resource(CREATE_RADIO_URL) - var self = this - var params = { - radio_type: type, - related_object_id: objectId - } - resource.save({}, params).then((response) => { - logger.default.info('Successfully started radio ', type) - self.current.session = response.data.id - queue.populateFromRadio() - }, (response) => { - logger.default.error('Error while starting radio', type) - }) - }, - stop () { - this.current.type = null - this.current.objectId = null - this.running = false - this.session = null - }, - fetch () { - let resource = Vue.resource(GET_TRACK_URL) - var self = this - var params = { - session: self.current.session - } - return resource.save({}, params) - } -} - -Vue.set(radios, 'running', false) -Vue.set(radios, 'current', {}) -Vue.set(radios.current, 'objectId', null) -Vue.set(radios.current, 'type', null) -Vue.set(radios.current, 'session', null) - -export default radios diff --git a/front/src/router/index.js b/front/src/router/index.js index d727276fc7b58f58d288bf4ae42282341cc65ce4..f4efc723f4abc2fb9dfdca2e062d433c09d4e91e 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -4,6 +4,7 @@ import PageNotFound from '@/components/PageNotFound' import Home from '@/components/Home' import Login from '@/components/auth/Login' import Profile from '@/components/auth/Profile' +import Settings from '@/components/auth/Settings' import Logout from '@/components/auth/Logout' import Library from '@/components/library/Library' import LibraryHome from '@/components/library/Home' @@ -39,6 +40,11 @@ export default new Router({ name: 'logout', component: Logout }, + { + path: '/settings', + name: 'settings', + component: Settings + }, { path: '/@:username', name: 'profile', @@ -47,14 +53,29 @@ export default new Router({ }, { path: '/favorites', - component: Favorites + component: Favorites, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultPage: route.query.page, + defaultPaginateBy: route.query.paginateBy + }) }, { path: '/library', component: Library, children: [ { path: '', component: LibraryHome }, - { path: 'artists/', name: 'library.artists.browse', component: LibraryArtists }, + { + path: 'artists/', + name: 'library.artists.browse', + component: LibraryArtists, + props: (route) => ({ + defaultOrdering: route.query.ordering, + defaultQuery: route.query.query, + defaultPaginateBy: route.query.paginateBy, + defaultPage: route.query.page + }) + }, { 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 new file mode 100644 index 0000000000000000000000000000000000000000..d8bd197f33fabd1938fe65a5c71ca950314b663f --- /dev/null +++ b/front/src/store/auth.js @@ -0,0 +1,119 @@ +import Vue from 'vue' +import jwtDecode from 'jwt-decode' +import config from '@/config' +import logger from '@/logging' +import router from '@/router' + +const LOGIN_URL = config.API_URL + 'token/' +const REFRESH_TOKEN_URL = config.API_URL + 'token/refresh/' +const USER_PROFILE_URL = config.API_URL + 'users/users/me/' + +export default { + namespaced: true, + state: { + authenticated: false, + username: '', + availablePermissions: {}, + profile: null, + token: '', + tokenData: {} + }, + getters: { + header: state => { + return 'JWT ' + state.token + } + }, + mutations: { + profile: (state, value) => { + state.profile = value + }, + authenticated: (state, value) => { + state.authenticated = value + if (value === false) { + state.username = null + state.token = null + state.tokenData = null + state.profile = null + state.availablePermissions = {} + } + }, + username: (state, value) => { + state.username = value + }, + token: (state, value) => { + state.token = value + if (value) { + state.tokenData = jwtDecode(value) + } else { + state.tokenData = {} + } + }, + permission: (state, {key, status}) => { + state.availablePermissions[key] = status + } + }, + actions: { + // Send a request to the login URL and save the returned JWT + login ({commit, dispatch, state}, {next, credentials, onError}) { + let resource = Vue.resource(LOGIN_URL) + return resource.save({}, credentials).then(response => { + logger.default.info('Successfully logged in as', credentials.username) + commit('token', response.data.token) + commit('username', credentials.username) + commit('authenticated', true) + dispatch('fetchProfile') + // Redirect to a specified route + router.push(next) + }, response => { + logger.default.error('Error while logging in', response.data) + onError(response) + }) + }, + logout ({commit}) { + commit('authenticated', false) + logger.default.info('Log out, goodbye!') + router.push({name: 'index'}) + }, + check ({commit, dispatch, state}) { + logger.default.info('Checking authentication...') + var jwt = state.token + var username = state.username + if (jwt) { + commit('authenticated', true) + commit('username', username) + commit('token', jwt) + logger.default.info('Logged back in as ' + username) + dispatch('fetchProfile') + dispatch('refreshToken') + } else { + logger.default.info('Anonymous user') + commit('authenticated', false) + } + }, + fetchProfile ({commit, dispatch, state}) { + let resource = Vue.resource(USER_PROFILE_URL) + return resource.get({}).then((response) => { + logger.default.info('Successfully fetched user profile') + let data = response.data + commit('profile', data) + dispatch('favorites/fetch', 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}) + }) + return response.data + }, (response) => { + logger.default.info('Error while fetching user profile') + }) + }, + refreshToken ({commit, dispatch, state}) { + let resource = Vue.resource(REFRESH_TOKEN_URL) + return resource.save({}, {token: state.token}).then(response => { + logger.default.info('Refreshed auth token') + commit('token', response.data.token) + }, response => { + logger.default.error('Error while refreshing token', response.data) + }) + } + } +} diff --git a/front/src/store/favorites.js b/front/src/store/favorites.js new file mode 100644 index 0000000000000000000000000000000000000000..9337966fdf68bc84202a8d2fe3e7add193d1fa20 --- /dev/null +++ b/front/src/store/favorites.js @@ -0,0 +1,73 @@ +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' + +const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/' +const FAVORITES_URL = config.API_URL + 'favorites/tracks/' + +export default { + namespaced: true, + state: { + tracks: [], + count: 0 + }, + mutations: { + track: (state, {id, value}) => { + if (value) { + if (state.tracks.indexOf(id) === -1) { + state.tracks.push(id) + } + } else { + let i = state.tracks.indexOf(id) + if (i > -1) { + state.tracks.splice(i, 1) + } + } + state.count = state.tracks.length + } + }, + getters: { + isFavorite: (state) => (id) => { + return state.tracks.indexOf(id) > -1 + } + }, + actions: { + set ({commit, state}, {id, value}) { + commit('track', {id, value}) + if (value) { + let resource = Vue.resource(FAVORITES_URL) + resource.save({}, {'track': id}).then((response) => { + logger.default.info('Successfully added track to favorites') + }, (response) => { + logger.default.info('Error while adding track to favorites') + commit('track', {id, value: !value}) + }) + } else { + let resource = Vue.resource(REMOVE_URL) + resource.delete({}, {'track': id}).then((response) => { + logger.default.info('Successfully removed track from favorites') + }, (response) => { + logger.default.info('Error while removing track from favorites') + commit('track', {id, value: !value}) + }) + } + }, + toggle ({getters, dispatch}, id) { + dispatch('set', {id, value: !getters['isFavorite'](id)}) + }, + fetch ({dispatch, state, commit}, url) { + // will fetch favorites by batches from API to have them locally + url = url || FAVORITES_URL + let resource = Vue.resource(url) + resource.get().then((response) => { + logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites') + response.data.results.forEach(result => { + commit('track', {id: result.track, value: true}) + }) + if (response.data.next) { + dispatch('fetch', response.data.next) + } + }) + } + } +} diff --git a/front/src/store/index.js b/front/src/store/index.js new file mode 100644 index 0000000000000000000000000000000000000000..507f0b5876772364185fb6654e0b0a25fe95c913 --- /dev/null +++ b/front/src/store/index.js @@ -0,0 +1,100 @@ +import Vue from 'vue' +import Vuex from 'vuex' +import createPersistedState from 'vuex-persistedstate' + +import favorites from './favorites' +import auth from './auth' +import queue from './queue' +import radios from './radios' +import player from './player' + +Vue.use(Vuex) + +export default new Vuex.Store({ + modules: { + auth, + favorites, + queue, + radios, + player + }, + plugins: [ + createPersistedState({ + key: 'auth', + paths: ['auth'], + filter: (mutation) => { + return mutation.type.startsWith('auth/') + } + }), + createPersistedState({ + key: 'radios', + paths: ['radios'], + filter: (mutation) => { + return mutation.type.startsWith('radios/') + } + }), + createPersistedState({ + key: 'player', + paths: [ + 'player.looping', + 'player.playing', + 'player.volume', + 'player.duration', + 'player.errored'], + filter: (mutation) => { + return mutation.type.startsWith('player/') && mutation.type !== 'player/currentTime' + } + }), + createPersistedState({ + key: 'progress', + paths: ['player.currentTime'], + filter: (mutation) => { + let delay = 10 + return mutation.type === 'player/currentTime' && parseInt(mutation.payload) % delay === 0 + }, + reducer: (state) => { + return { + player: { + currentTime: state.player.currentTime + } + } + } + }), + createPersistedState({ + key: 'queue', + filter: (mutation) => { + return mutation.type.startsWith('queue/') + }, + reducer: (state) => { + return { + queue: { + currentIndex: state.queue.currentIndex, + tracks: state.queue.tracks.map(track => { + // we keep only valuable fields to make the cache lighter and avoid + // cyclic value serialization errors + let artist = { + id: track.artist.id, + mbid: track.artist.mbid, + name: track.artist.name + } + return { + id: track.id, + title: track.title, + mbid: track.mbid, + album: { + id: track.album.id, + title: track.album.title, + mbid: track.album.mbid, + cover: track.album.cover, + artist: artist + }, + artist: artist, + files: track.files + } + }) + } + } + } + }) + ] +}) diff --git a/front/src/store/player.js b/front/src/store/player.js new file mode 100644 index 0000000000000000000000000000000000000000..74b0b9f9ea72dcbc38d0c3cefc6fa90d1647fc49 --- /dev/null +++ b/front/src/store/player.js @@ -0,0 +1,91 @@ +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' +import time from '@/utils/time' + +export default { + namespaced: true, + state: { + playing: false, + volume: 0.5, + duration: 0, + currentTime: 0, + errored: false, + looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue + }, + mutations: { + volume (state, value) { + value = parseFloat(value) + value = Math.min(value, 1) + value = Math.max(value, 0) + state.volume = value + }, + incrementVolume (state, value) { + value = parseFloat(state.volume + value) + value = Math.min(value, 1) + value = Math.max(value, 0) + state.volume = value + }, + duration (state, value) { + state.duration = value + }, + errored (state, value) { + state.errored = value + }, + currentTime (state, value) { + state.currentTime = value + }, + looping (state, value) { + state.looping = value + }, + playing (state, value) { + state.playing = value + }, + toggleLooping (state) { + if (state.looping > 1) { + state.looping = 0 + } else { + state.looping += 1 + } + } + }, + getters: { + durationFormatted: state => { + return time.parse(Math.round(state.duration)) + }, + currentTimeFormatted: state => { + return time.parse(Math.round(state.currentTime)) + }, + progress: state => { + return Math.round(state.currentTime / state.duration * 100) + } + }, + actions: { + incrementVolume (context, value) { + context.commit('volume', context.state.volume + value) + }, + stop (context) { + }, + togglePlay ({commit, state}) { + commit('playing', !state.playing) + }, + trackListened ({commit}, track) { + let url = config.API_URL + 'history/listenings/' + let resource = Vue.resource(url) + resource.save({}, {'track': track.id}).then((response) => {}, (response) => { + logger.default.error('Could not record track in history') + }) + }, + trackEnded ({dispatch}, track) { + dispatch('trackListened', track) + dispatch('queue/next', null, {root: true}) + }, + trackErrored ({commit, dispatch}) { + commit('errored', true) + dispatch('queue/next', null, {root: true}) + }, + updateProgress ({commit}, t) { + commit('currentTime', t) + } + } +} diff --git a/front/src/store/queue.js b/front/src/store/queue.js new file mode 100644 index 0000000000000000000000000000000000000000..5dde19bd8e6f1665a826b522820e37ae3c14efaf --- /dev/null +++ b/front/src/store/queue.js @@ -0,0 +1,150 @@ +import logger from '@/logging' +import _ from 'lodash' + +export default { + namespaced: true, + state: { + tracks: [], + currentIndex: -1, + ended: true, + previousQueue: null + }, + mutations: { + currentIndex (state, value) { + state.currentIndex = value + }, + ended (state, value) { + state.ended = value + }, + splice (state, {start, size}) { + state.tracks.splice(start, size) + }, + tracks (state, value) { + state.tracks = value + }, + insert (state, {track, index}) { + state.tracks.splice(index, 0, track) + }, + reorder (state, {oldIndex, newIndex}) { + // called when the user uses drag / drop to reorder + // tracks in queue + if (oldIndex === state.currentIndex) { + state.currentIndex = newIndex + return + } + if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) { + // item before was moved after + state.currentIndex -= 1 + } + if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) { + // item after was moved before + state.currentIndex += 1 + } + } + + }, + getters: { + currentTrack: state => { + return state.tracks[state.currentIndex] + }, + hasNext: state => { + return state.currentIndex < state.tracks.length - 1 + }, + hasPrevious: state => { + return state.currentIndex > 0 + } + }, + actions: { + append (context, {track, index, skipPlay}) { + index = index || context.state.tracks.length + if (index > context.state.tracks.length - 1) { + // we simply push to the end + context.commit('insert', {track, index: context.state.tracks.length}) + } else { + // we insert the track at given position + context.commit('insert', {track, index}) + } + if (!skipPlay) { + context.dispatch('resume') + } + // this.cache() + }, + + appendMany (context, {tracks, index}) { + logger.default.info('Appending many tracks to the queue', tracks.map(e => { return e.title })) + if (context.state.tracks.length === 0) { + index = 0 + } else { + index = index || context.state.tracks.length + } + tracks.forEach((t) => { + context.dispatch('append', {track: t, index: index, skipPlay: true}) + index += 1 + }) + context.dispatch('resume') + }, + + cleanTrack ({state, dispatch, commit}, index) { + // are we removing current playin track + let current = index === state.currentIndex + if (current) { + dispatch('player/stop', null, {root: true}) + } + if (index < state.currentIndex) { + dispatch('currentIndex', state.currentIndex - 1) + } + commit('splice', {start: index, size: 1}) + if (current) { + // we play next track, which now have the same index + dispatch('currentIndex', index) + } + }, + + resume (context) { + if (context.state.ended | context.rootState.player.errored) { + context.dispatch('next') + } + }, + previous (context) { + if (context.state.currentIndex > 0) { + context.dispatch('currentIndex', context.state.currentIndex - 1) + } + }, + next ({state, dispatch, commit, rootState}) { + if (rootState.player.looping === 2 && state.currentIndex >= state.tracks.length - 1) { + logger.default.info('Going back to the beginning of the queue') + return dispatch('currentIndex', 0) + } else { + if (state.currentIndex < state.tracks.length - 1) { + logger.default.debug('Playing next track') + return dispatch('currentIndex', state.currentIndex + 1) + } else { + commit('ended', true) + } + } + }, + currentIndex ({commit, state, rootState, dispatch}, index) { + commit('ended', false) + commit('player/currentTime', 0, {root: true}) + commit('player/playing', true, {root: true}) + commit('player/errored', false, {root: true}) + commit('currentIndex', index) + if (state.tracks.length - index <= 2 && rootState.radios.running) { + dispatch('radios/populateQueue', null, {root: true}) + } + }, + clean ({dispatch, commit}) { + dispatch('player/stop', null, {root: true}) + // radios.stop() + commit('tracks', []) + dispatch('currentIndex', -1) + // so we replay automatically on next track append + commit('ended', true) + }, + shuffle ({dispatch, commit, state}) { + let shuffled = _.shuffle(state.tracks) + commit('tracks', []) + dispatch('appendMany', {tracks: shuffled}) + } + } +} diff --git a/front/src/store/radios.js b/front/src/store/radios.js new file mode 100644 index 0000000000000000000000000000000000000000..a9c429876a4ff974635de9f73062dd2597237209 --- /dev/null +++ b/front/src/store/radios.js @@ -0,0 +1,78 @@ +import Vue from 'vue' +import config from '@/config' +import logger from '@/logging' + +const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/' +const GET_TRACK_URL = config.API_URL + 'radios/tracks/' + +export default { + namespaced: true, + state: { + current: null, + running: false + }, + getters: { + types: state => { + return { + random: { + name: 'Random', + description: "Totally random picks, maybe you'll discover new things?" + }, + favorites: { + name: 'Favorites', + description: 'Play your favorites tunes in a never-ending happiness loop.' + }, + 'less-listened': { + name: 'Less listened', + description: "Listen to tracks you usually don't. It's time to restore some balance." + } + } + } + }, + mutations: { + current: (state, value) => { + state.current = value + }, + running: (state, value) => { + state.running = value + } + }, + actions: { + start ({commit, dispatch}, {type, objectId}) { + let resource = Vue.resource(CREATE_RADIO_URL) + var params = { + radio_type: type, + related_object_id: objectId + } + resource.save({}, params).then((response) => { + logger.default.info('Successfully started radio ', type) + commit('current', {type, objectId, session: response.data.id}) + commit('running', true) + dispatch('populateQueue') + }, (response) => { + logger.default.error('Error while starting radio', type) + }) + }, + stop ({commit}) { + commit('current', null) + commit('running', false) + }, + populateQueue ({state, dispatch}) { + if (!state.running) { + return + } + let resource = Vue.resource(GET_TRACK_URL) + var params = { + session: state.current.session + } + let promise = resource.save({}, params) + promise.then((response) => { + logger.default.info('Adding track to queue from radio') + dispatch('queue/append', {track: response.data.track}, {root: true}) + }, (response) => { + logger.default.error('Error while adding track to queue from radio') + }) + } + } + +}