Merge branch 'release/0.3'
This commit is contained in:
commit
c8a2ae4243
20
CHANGELOG
20
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)
|
||||
------------------
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
@ -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('.')])
|
||||
|
|
|
@ -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)
|
|
@ -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']))
|
|
@ -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
|
|
@ -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'
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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')
|
|
@ -8,5 +8,5 @@ class ArtistFilter(django_filters.FilterSet):
|
|||
class Meta:
|
||||
model = models.Artist
|
||||
fields = {
|
||||
'name': ['exact', 'iexact', 'startswith']
|
||||
'name': ['exact', 'iexact', 'startswith', 'icontains']
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
)
|
|
@ -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)
|
|
@ -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')
|
|
@ -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)
|
|
@ -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!')
|
|
@ -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()
|
||||
|
|
|
@ -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')))
|
|
@ -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)
|
|
@ -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'
|
|
@ -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])
|
|
@ -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])
|
|
@ -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])
|
|
@ -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)
|
|
@ -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
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
||||
)
|
|
@ -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)
|
|
@ -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)
|
|
@ -3,3 +3,4 @@ DJANGO_SETTINGS_MODULE=config.settings.test
|
|||
|
||||
# -- recommended but optional:
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
testpatsh = tests
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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+
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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!'
|
|
@ -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'))
|
|
@ -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
|
|
@ -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]
|
|
@ -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'])
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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]
|
|
@ -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
|
|
@ -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]
|
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
def test__str__(factories):
|
||||
user = factories['users.User'](username='hello')
|
||||
assert user.__str__() == 'hello'
|
|
@ -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
|
1
dev.yml
1
dev.yml
|
@ -13,7 +13,6 @@ services:
|
|||
- "8080:8080"
|
||||
volumes:
|
||||
- './front:/app'
|
||||
- /app/node_modules
|
||||
|
||||
postgres:
|
||||
env_file: .env.dev
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<button @click="favoriteTracks.set(track.id, !isFavorite)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']">
|
||||
<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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue