diff --git a/.env.dev b/.env.dev index 9923c31487bd305a1f9fa516ba93c4a4895ceb1e..2e883414365a1c83a982887c70bb272d04398760 100644 --- a/.env.dev +++ b/.env.dev @@ -1,3 +1,9 @@ API_AUTHENTICATION_REQUIRED=True RAVEN_ENABLED=false RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5 +DJANGO_ALLOWED_HOSTS=localhost,nginx +DJANGO_SETTINGS_MODULE=config.settings.local +DJANGO_SECRET_KEY=dev +C_FORCE_ROOT=true +FUNKWHALE_URL=http://localhost +PYTHONDONTWRITEBYTECODE=true diff --git a/.gitignore b/.gitignore index 1e1017c8d1309f6d805dd8dfef53def18929b1c8..8b511703444291a612645e398d5b568f6e717a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ front/selenium-debug.log docs/_build data/ +.env diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 38492f61454b3e4503bbe98b7b284c0182d0a1d0..94b40bed3b7ca63cd52ae3d492b19cd211fef6d6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,7 @@ stages: test_api: services: - postgres:9.4 + - redis:3 stage: test image: funkwhale/funkwhale:latest cache: @@ -24,6 +25,7 @@ test_api: DATABASE_URL: "postgresql://postgres@postgres/postgres" FUNKWHALE_URL: "https://funkwhale.ci" CACHEOPS_ENABLED: "false" + DJANGO_SETTINGS_MODULE: config.settings.local before_script: - cd api @@ -31,7 +33,7 @@ test_api: - pip install -r requirements/local.txt - pip install -r requirements/test.txt script: - - pytest + - pytest --cov=funkwhale_api tests/ tags: - docker diff --git a/CHANGELOG b/CHANGELOG index 0b91987235f620475b00f26af87787c43670913b..0f01b5825169c961c95a0b44ffd3787071ecec0f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,100 @@ Changelog .. towncrier +0.8 (2018-04-02) +---------------- + +Features: + +- Add a detail page for radios (#64) +- Implemented page title binding (#1) +- Previous Track button restart playback after 3 seconds (#146) + + +Enhancements: + +- Added credits to Francis Gading for the logotype (#101) +- API endpoint for fetching instance activity and updated timeline to use this + new endpoint (#141) +- Better error messages in case of missing environment variables (#140) +- Implemented a @test@yourfunkwhaledomain bot to ensure federation works + properly. Send it "/ping" and it will answer back :) +- Queue shuffle now apply only to tracks after the current one (#97) +- Removed player from queue tab and consistently show current track in queue + (#131) +- We now restrict some usernames from being used during signup (#139) + + +Bugfixes: + +- Better error handling during file import (#120) +- Better handling of utf-8 filenames during file import (#138) +- Converted favicon from .ico to .png (#130) +- Upgraded to Python 3.6 to fix weird but harmless weakref error on django task + (#121) + + +Documentation: + +- Documented the upgrade process (#127) + + +Preparing for federation +^^^^^^^^^^^^^^^^^^^^^^^^ + +Federation of music libraries is one of the most asked feature. +While there is still a lot of work to do, this version includes +the foundation that will enable funkwhale servers to communicate +between each others, and with other federated software, such as +Mastodon. + +Funkwhale will use ActivityPub as it's federation protocol. + +In order to prepare for federation (see #136 and #137), new API endpoints +have been added under /federation and /.well-known/webfinger. + +For these endpoints to work, you will need to update your nginx configuration, +and add the following snippets:: + + location /federation/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/federation/; + } + + location /.well-known/webfinger { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/webfinger; + } + +This will ensure federation endpoints will be reachable in the future. +You can of course skip this part if you know you will not federate your instance. + +A new ``FEDERATION_ENABLED`` env var have also been added to control wether +federation is enabled or not on the application side. This settings defaults +to True, which should have no consequencies at the moment, since actual +federation is not implemented and the only available endpoints are for +testing purposes. + +Add ``FEDERATION_ENABLED=false`` to your .env file to disable federation +on the application side. + +The last step involves generating RSA private and public keys for signing +your instance requests on the federation. This can be done via:: + + # on docker setups + docker-compose run --rm api python manage.py generate_keys --no-input + + # on non-docker setups + source /srv/funkwhale/virtualenv/bin/activate + source /srv/funkwhale/load_env + python manage.py generate_keys --no-input + +To test and troobleshoot federation, we've added a bot account. This bot is available at @test@yourinstancedomain, +and sending it "/ping", for example, via Mastodon, should trigger +a response. + + + 0.7 (2018-03-21) ---------------- diff --git a/README.rst b/README.rst index 93281d26fb6abd65d0590183a416236a585b68e9..2d5d2011d2f368332458bd567284e2121a4dfca8 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,19 @@ via the following command:: docker-compose -f dev.yml build +Creating your env file +^^^^^^^^^^^^^^^^^^^^^^ + +We provide a working .env.dev configuration file that is suitable for +development. However, to enable customization on your machine, you should +also create a .env file that will hold your personal environment +variables (those will not be commited to the project). + +Create it like this:: + + touch .env + + Database management ^^^^^^^^^^^^^^^^^^^ diff --git a/api/Dockerfile b/api/Dockerfile index 9296785eef25d9b5a14cd9e140b8461ba188eeff..6acdaac56a6fa024917f60ac0629217d93813dc4 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.5 +FROM python:3.6 ENV PYTHONUNBUFFERED 1 diff --git a/api/compose/django/dev-entrypoint.sh b/api/compose/django/dev-entrypoint.sh index 416207b43d66d6ec7d05b12dd508c33577b8afda..6deeebb0085ede8bd696d59fb78af1d6d778a41e 100755 --- a/api/compose/django/dev-entrypoint.sh +++ b/api/compose/django/dev-entrypoint.sh @@ -1,7 +1,3 @@ #!/bin/bash set -e -if [ $1 = "pytest" ]; then - # let pytest.ini handle it - unset DJANGO_SETTINGS_MODULE -fi exec "$@" diff --git a/api/config/api_urls.py b/api/config/api_urls.py index ff6db0d069395c316d207a640e0187ccf92b12df..cab6805b67e394838ec942ecf8b162edb08a88cf 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -1,5 +1,6 @@ from rest_framework import routers from django.conf.urls import include, url +from funkwhale_api.activity import views as activity_views from funkwhale_api.instance import views as instance_views from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views @@ -10,6 +11,7 @@ from dynamic_preferences.users.viewsets import UserPreferencesViewSet router = routers.SimpleRouter() router.register(r'settings', GlobalPreferencesViewSet, base_name='settings') +router.register(r'activity', activity_views.ActivityViewSet, 'activity') router.register(r'tags', views.TagViewSet, 'tags') router.register(r'tracks', views.TrackViewSet, 'tracks') router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles') diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 077566d1c6a82e329f334a7fe94764cafbd92a70..fbe3b7045e24c67d87c2ee90441103ccc27ac57a 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/ """ from __future__ import absolute_import, unicode_literals +from urllib.parse import urlsplit import os import environ from funkwhale_api import __version__ @@ -24,8 +25,13 @@ try: except FileNotFoundError: pass -ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') FUNKWHALE_URL = env('FUNKWHALE_URL') +FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc + +FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True) +FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME) + +ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') # APP CONFIGURATION # ------------------------------------------------------------------------------ @@ -89,6 +95,7 @@ LOCAL_APPS = ( 'funkwhale_api.music', 'funkwhale_api.requests', 'funkwhale_api.favorites', + 'funkwhale_api.federation', 'funkwhale_api.radios', 'funkwhale_api.history', 'funkwhale_api.playlists', @@ -231,6 +238,7 @@ STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR('staticfiles'))) # See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url STATIC_URL = env("STATIC_URL", default='/staticfiles/') +DEFAULT_FILE_STORAGE = 'funkwhale_api.common.storage.ASCIIFileSystemStorage' # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS STATICFILES_DIRS = ( @@ -336,7 +344,12 @@ REST_FRAMEWORK = { ), 'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination', 'PAGE_SIZE': 25, - + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser', + 'funkwhale_api.federation.parsers.ActivityParser', + ), 'DEFAULT_AUTHENTICATION_CLASSES': ( 'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS', 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', @@ -385,3 +398,16 @@ CSRF_USE_SESSIONS = True # Playlist settings PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250) + +ACCOUNT_USERNAME_BLACKLIST = [ + 'funkwhale', + 'library', + 'test', + 'status', + 'root', + 'admin', + 'owner', + 'superuser', + 'staff', + 'service', +] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[]) diff --git a/api/config/settings/local.py b/api/config/settings/local.py index 24ad871f75ae59f98f73a91a71fed87611afa942..dcbea66d26134664655d1ca0978e3121c5da5d96 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -72,6 +72,10 @@ LOGGING = { 'handlers':['console'], 'propagate': True, 'level':'DEBUG', - } + }, + '': { + 'level': 'DEBUG', + 'handlers': ['console'], + }, }, } diff --git a/api/config/settings/test.py b/api/config/settings/test.py deleted file mode 100644 index aff29c6571252d1f0d401c550cedf09998a5c512..0000000000000000000000000000000000000000 --- a/api/config/settings/test.py +++ /dev/null @@ -1,29 +0,0 @@ -from .common import * # noqa -SECRET_KEY = env("DJANGO_SECRET_KEY", default='test') - -# Mail settings -# ------------------------------------------------------------------------------ -EMAIL_HOST = 'localhost' -EMAIL_PORT = 1025 -EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', - default='django.core.mail.backends.console.EmailBackend') - -# CACHING -# ------------------------------------------------------------------------------ -CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '' - } -} - -CELERY_BROKER_URL = 'memory://' - -########## CELERY -# In development, all tasks will be executed locally by blocking until the task returns -CELERY_TASK_ALWAYS_EAGER = True -########## END CELERY - -# Your local stuff: Below this line define 3rd party library settings -API_AUTHENTICATION_REQUIRED = False -CACHEOPS_ENABLED = False diff --git a/api/config/urls.py b/api/config/urls.py index 8f7e37bc26ae56ba9967682f4ec3f19f04cc71f4..90598ea841f474e5b887fda7fe42f23975cd4c00 100644 --- a/api/config/urls.py +++ b/api/config/urls.py @@ -13,6 +13,9 @@ urlpatterns = [ url(settings.ADMIN_URL, admin.site.urls), url(r'^api/', include(("config.api_urls", 'api'), namespace="api")), + url(r'^', include( + ('funkwhale_api.federation.urls', 'federation'), + namespace="federation")), url(r'^api/v1/auth/', include('rest_auth.urls')), url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')), url(r'^accounts/', include('allauth.urls')), diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test index 0990efa512e6a1084249e331e4146f8a223332f5..963e3ab20e4f114a96a176b4a9a1cb5c91f6023c 100644 --- a/api/docker/Dockerfile.test +++ b/api/docker/Dockerfile.test @@ -1,4 +1,4 @@ -FROM python:3.5 +FROM python:3.6 ENV PYTHONUNBUFFERED 1 diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py index 325d1e820db5699abca69b57b3421b0e0ca1d68b..fd9b185cf9a6d3891f0356208f62b2bb54e8686f 100644 --- a/api/funkwhale_api/activity/serializers.py +++ b/api/funkwhale_api/activity/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from funkwhale_api.activity import record + class ModelSerializer(serializers.ModelSerializer): id = serializers.CharField(source='get_activity_url') @@ -8,3 +10,15 @@ class ModelSerializer(serializers.ModelSerializer): def get_url(self, obj): return self.get_id(obj) + + +class AutoSerializer(serializers.Serializer): + """ + A serializer that will automatically use registered activity serializers + to serialize an henerogeneous list of objects (favorites, listenings, etc.) + """ + def to_representation(self, instance): + serializer = record.registry[instance._meta.label]['serializer']( + instance + ) + return serializer.data diff --git a/api/funkwhale_api/activity/utils.py b/api/funkwhale_api/activity/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..46336930ef693e29d9d5696ee3ccf12446c2bdad --- /dev/null +++ b/api/funkwhale_api/activity/utils.py @@ -0,0 +1,64 @@ +from django.db import models + +from funkwhale_api.common import fields +from funkwhale_api.favorites.models import TrackFavorite +from funkwhale_api.history.models import Listening + + +def combined_recent(limit, **kwargs): + datetime_field = kwargs.pop('datetime_field', 'creation_date') + source_querysets = { + qs.model._meta.label: qs for qs in kwargs.pop('querysets') + } + querysets = { + k: qs.annotate( + __type=models.Value( + qs.model._meta.label, output_field=models.CharField() + ) + ).values('pk', datetime_field, '__type') + for k, qs in source_querysets.items() + } + _qs_list = list(querysets.values()) + union_qs = _qs_list[0].union(*_qs_list[1:]) + records = [] + for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]: + records.append({ + 'type': row['__type'], + 'when': row[datetime_field], + 'pk': row['pk'] + }) + # Now we bulk-load each object type in turn + to_load = {} + for record in records: + to_load.setdefault(record['type'], []).append(record['pk']) + fetched = {} + + for key, pks in to_load.items(): + for item in source_querysets[key].filter(pk__in=pks): + fetched[(key, item.pk)] = item + + # Annotate 'records' with loaded objects + for record in records: + record['object'] = fetched[(record['type'], record['pk'])] + return records + + +def get_activity(user, limit=20): + query = fields.privacy_level_query( + user, lookup_field='user__privacy_level') + querysets = [ + Listening.objects.filter(query).select_related( + 'track', + 'user', + 'track__artist', + 'track__album__artist', + ), + TrackFavorite.objects.filter(query).select_related( + 'track', + 'user', + 'track__artist', + 'track__album__artist', + ), + ] + records = combined_recent(limit=limit, querysets=querysets) + return [r['object'] for r in records] diff --git a/api/funkwhale_api/activity/views.py b/api/funkwhale_api/activity/views.py new file mode 100644 index 0000000000000000000000000000000000000000..e66de1ccfdc94f51cd823fa5c6b104488a4aad7f --- /dev/null +++ b/api/funkwhale_api/activity/views.py @@ -0,0 +1,20 @@ +from rest_framework import viewsets +from rest_framework.response import Response + +from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.favorites.models import TrackFavorite + +from . import serializers +from . import utils + + +class ActivityViewSet(viewsets.GenericViewSet): + + serializer_class = serializers.AutoSerializer + permission_classes = [ConditionalAuthentication] + queryset = TrackFavorite.objects.none() + + def list(self, request, *args, **kwargs): + activity = utils.get_activity(user=request.user) + serializer = self.serializer_class(activity, many=True) + return Response({'results': serializer.data}, status=200) diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index ef9f840dc763409c8a1555d693d1939030877fd5..1a18b5f27d1e1839ef32722499de6b4365d6ac55 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -22,6 +22,6 @@ def privacy_level_query(user, lookup_field='privacy_level'): return models.Q(**{ '{}__in'.format(lookup_field): [ - 'me', 'followers', 'instance', 'everyone' + 'followers', 'instance', 'everyone' ] }) diff --git a/api/funkwhale_api/common/storage.py b/api/funkwhale_api/common/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..658ce795a4bad7290b1aa8d07766207d2da5a2b3 --- /dev/null +++ b/api/funkwhale_api/common/storage.py @@ -0,0 +1,12 @@ +import unicodedata + +from django.core.files.storage import FileSystemStorage + + +class ASCIIFileSystemStorage(FileSystemStorage): + """ + Convert unicode characters in name to ASCII characters. + """ + def get_valid_name(self, name): + name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore') + return super().get_valid_name(name) diff --git a/api/funkwhale_api/federation/__init__.py b/api/funkwhale_api/federation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py new file mode 100644 index 0000000000000000000000000000000000000000..4eeb193b183007ad6c7c093cd20b881a3a06b89b --- /dev/null +++ b/api/funkwhale_api/federation/activity.py @@ -0,0 +1,85 @@ +import logging +import json +import requests +import requests_http_signature + +from . import signing + +logger = logging.getLogger(__name__) + +ACTIVITY_TYPES = [ + 'Accept', + 'Add', + 'Announce', + 'Arrive', + 'Block', + 'Create', + 'Delete', + 'Dislike', + 'Flag', + 'Follow', + 'Ignore', + 'Invite', + 'Join', + 'Leave', + 'Like', + 'Listen', + 'Move', + 'Offer', + 'Question', + 'Reject', + 'Read', + 'Remove', + 'TentativeReject', + 'TentativeAccept', + 'Travel', + 'Undo', + 'Update', + 'View', +] + + +OBJECT_TYPES = [ + 'Article', + 'Audio', + 'Document', + 'Event', + 'Image', + 'Note', + 'Page', + 'Place', + 'Profile', + 'Relationship', + 'Tombstone', + 'Video', +] + +def deliver(activity, on_behalf_of, to=[]): + from . import actors + logger.info('Preparing activity delivery to %s', to) + auth = requests_http_signature.HTTPSignatureAuth( + use_auth_header=False, + headers=[ + '(request-target)', + 'user-agent', + 'host', + 'date', + 'content-type',], + algorithm='rsa-sha256', + key=on_behalf_of.private_key.encode('utf-8'), + key_id=on_behalf_of.private_key_id, + ) + for url in to: + recipient_actor = actors.get_actor(url) + logger.debug('delivering to %s', recipient_actor.inbox_url) + logger.debug('activity content: %s', json.dumps(activity)) + response = requests.post( + auth=auth, + json=activity, + url=recipient_actor.inbox_url, + headers={ + 'Content-Type': 'application/activity+json' + } + ) + response.raise_for_status() + logger.debug('Remote answered with %s', response.status_code) diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py new file mode 100644 index 0000000000000000000000000000000000000000..69033f5ca890cb094f63d5dbc5bf7f39afe3de44 --- /dev/null +++ b/api/funkwhale_api/federation/actors.py @@ -0,0 +1,220 @@ +import logging +import requests +import xml + +from django.conf import settings +from django.urls import reverse +from django.utils import timezone + +from rest_framework.exceptions import PermissionDenied + +from dynamic_preferences.registries import global_preferences_registry + +from . import activity +from . import models +from . import serializers +from . import utils + +logger = logging.getLogger(__name__) + + +def remove_tags(text): + logger.debug('Removing tags from %s', text) + return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext()) + + +def get_actor_data(actor_url): + response = requests.get( + actor_url, + headers={ + 'Accept': 'application/activity+json', + } + ) + response.raise_for_status() + try: + return response.json() + except: + raise ValueError( + 'Invalid actor payload: {}'.format(response.text)) + +def get_actor(actor_url): + data = get_actor_data(actor_url) + serializer = serializers.ActorSerializer(data=data) + serializer.is_valid(raise_exception=True) + + return serializer.build() + + +class SystemActor(object): + additional_attributes = {} + + def get_actor_instance(self): + a = models.Actor( + **self.get_instance_argument( + self.id, + name=self.name, + summary=self.summary, + **self.additional_attributes + ) + ) + a.pk = self.id + return a + + def get_instance_argument(self, id, name, summary, **kwargs): + preferences = global_preferences_registry.manager() + p = { + 'preferred_username': id, + 'domain': settings.FEDERATION_HOSTNAME, + 'type': 'Person', + 'name': name.format(host=settings.FEDERATION_HOSTNAME), + 'manually_approves_followers': True, + 'url': utils.full_url( + reverse( + 'federation:instance-actors-detail', + kwargs={'actor': id})), + 'shared_inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': id})), + 'inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': id})), + 'outbox_url': utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': id})), + 'public_key': preferences['federation__public_key'], + 'private_key': preferences['federation__private_key'], + 'summary': summary.format(host=settings.FEDERATION_HOSTNAME) + } + p.update(kwargs) + return p + + def get_inbox(self, data, actor=None): + raise NotImplementedError + + def post_inbox(self, data, actor=None): + raise NotImplementedError + + def get_outbox(self, data, actor=None): + raise NotImplementedError + + def post_outbox(self, data, actor=None): + raise NotImplementedError + + +class LibraryActor(SystemActor): + id = 'library' + name = '{host}\'s library' + summary = 'Bot account to federate with {host}\'s library' + additional_attributes = { + 'manually_approves_followers': True + } + + +class TestActor(SystemActor): + id = 'test' + name = '{host}\'s test account' + summary = ( + 'Bot account to test federation with {host}. ' + 'Send me /ping and I\'ll answer you.' + ) + additional_attributes = { + 'manually_approves_followers': False + } + + def get_outbox(self, data, actor=None): + return { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {} + ], + "id": utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': self.id})), + "type": "OrderedCollection", + "totalItems": 0, + "orderedItems": [] + } + + def post_inbox(self, data, actor=None): + if actor is None: + raise PermissionDenied('Actor not authenticated') + + serializer = serializers.ActivitySerializer( + data=data, context={'actor': actor}) + serializer.is_valid(raise_exception=True) + + ac = serializer.validated_data + logger.info('Received activity on %s inbox', self.id) + if ac['type'] == 'Create' and ac['object']['type'] == 'Note': + # we received a toot \o/ + command = self.parse_command(ac['object']['content']) + logger.debug('Parsed command: %s', command) + if command == 'ping': + self.handle_ping(ac, actor) + + def parse_command(self, message): + """ + Remove any links or fancy markup to extract /command from + a note message. + """ + raw = remove_tags(message) + try: + return raw.split('/')[1] + except IndexError: + return + + def handle_ping(self, ac, sender): + now = timezone.now() + test_actor = self.get_actor_instance() + reply_url = 'https://{}/activities/note/{}'.format( + settings.FEDERATION_HOSTNAME, now.timestamp() + ) + reply_content = '{} Pong!'.format( + sender.mention_username + ) + reply_activity = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {} + ], + 'type': 'Create', + 'actor': test_actor.url, + 'id': '{}/activity'.format(reply_url), + 'published': now.isoformat(), + 'to': ac['actor'], + 'cc': [], + 'object': { + 'type': 'Note', + 'content': 'Pong!', + 'summary': None, + 'published': now.isoformat(), + 'id': reply_url, + 'inReplyTo': ac['object']['id'], + 'sensitive': False, + 'url': reply_url, + 'to': [ac['actor']], + 'attributedTo': test_actor.url, + 'cc': [], + 'attachment': [], + 'tag': [{ + "type": "Mention", + "href": ac['actor'], + "name": sender.mention_username + }] + } + } + activity.deliver( + reply_activity, + to=[ac['actor']], + on_behalf_of=test_actor) + +SYSTEM_ACTORS = { + 'library': LibraryActor(), + 'test': TestActor(), +} diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..e199ef134d03e0d7026ecffbbaaa1f38e8254e02 --- /dev/null +++ b/api/funkwhale_api/federation/authentication.py @@ -0,0 +1,52 @@ +import cryptography + +from django.contrib.auth.models import AnonymousUser + +from rest_framework import authentication +from rest_framework import exceptions + +from . import actors +from . import keys +from . import serializers +from . import signing +from . import utils + + +class SignatureAuthentication(authentication.BaseAuthentication): + def authenticate_actor(self, request): + headers = utils.clean_wsgi_headers(request.META) + try: + signature = headers['Signature'] + key_id = keys.get_key_id_from_signature_header(signature) + except KeyError: + return + except ValueError as e: + raise exceptions.AuthenticationFailed(str(e)) + + try: + actor_data = actors.get_actor_data(key_id) + except Exception as e: + raise exceptions.AuthenticationFailed(str(e)) + + try: + public_key = actor_data['publicKey']['publicKeyPem'] + except KeyError: + raise exceptions.AuthenticationFailed('No public key found') + + serializer = serializers.ActorSerializer(data=actor_data) + if not serializer.is_valid(): + raise exceptions.AuthenticationFailed('Invalid actor payload: {}'.format(serializer.errors)) + + try: + signing.verify_django(request, public_key.encode('utf-8')) + except cryptography.exceptions.InvalidSignature: + raise exceptions.AuthenticationFailed('Invalid signature') + + return serializer.build() + + def authenticate(self, request): + setattr(request, 'actor', None) + actor = self.authenticate_actor(request) + user = AnonymousUser() + setattr(request, 'actor', actor) + return (user, None) diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..83d0285be263d228ffbb564a9bfc9f898fe77dbf --- /dev/null +++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py @@ -0,0 +1,34 @@ +from django.forms import widgets + +from dynamic_preferences import types +from dynamic_preferences.registries import global_preferences_registry + +federation = types.Section('federation') + + +@global_preferences_registry.register +class FederationPrivateKey(types.StringPreference): + show_in_api = False + section = federation + name = 'private_key' + default = '' + help_text = ( + 'Instance private key, used for signing federation HTTP requests' + ) + verbose_name = ( + 'Instance private key (keep it secret, do not change it)' + ) + + +@global_preferences_registry.register +class FederationPublicKey(types.StringPreference): + show_in_api = False + section = federation + name = 'public_key' + default = '' + help_text = ( + 'Instance public key, used for signing federation HTTP requests' + ) + verbose_name = ( + 'Instance public key (do not change it)' + ) diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..31d864b36c065aacadd8b09ed02278e13bd46fcb --- /dev/null +++ b/api/funkwhale_api/federation/exceptions.py @@ -0,0 +1,8 @@ + + +class MalformedPayload(ValueError): + pass + + +class MissingSignature(KeyError): + pass diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..88c86f791937cd66fbcc72a42592b520078c8a7c --- /dev/null +++ b/api/funkwhale_api/federation/factories.py @@ -0,0 +1,91 @@ +import factory +import requests +import requests_http_signature + +from django.utils import timezone + +from funkwhale_api.factories import registry + +from . import keys +from . import models + + +registry.register(keys.get_key_pair, name='federation.KeyPair') + + +@registry.register(name='federation.SignatureAuth') +class SignatureAuthFactory(factory.Factory): + algorithm = 'rsa-sha256' + key = factory.LazyFunction(lambda: keys.get_key_pair()[0]) + key_id = factory.Faker('url') + use_auth_header = False + headers = [ + '(request-target)', + 'user-agent', + 'host', + 'date', + 'content-type',] + class Meta: + model = requests_http_signature.HTTPSignatureAuth + + +@registry.register(name='federation.SignedRequest') +class SignedRequestFactory(factory.Factory): + url = factory.Faker('url') + method = 'get' + auth = factory.SubFactory(SignatureAuthFactory) + + class Meta: + model = requests.Request + + @factory.post_generation + def headers(self, create, extracted, **kwargs): + default_headers = { + 'User-Agent': 'Test', + 'Host': 'test.host', + 'Date': 'Right now', + 'Content-Type': 'application/activity+json' + } + if extracted: + default_headers.update(extracted) + self.headers.update(default_headers) + + +@registry.register +class ActorFactory(factory.DjangoModelFactory): + + public_key = None + private_key = None + preferred_username = factory.Faker('user_name') + summary = factory.Faker('paragraph') + domain = factory.Faker('domain_name') + url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username)) + inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username)) + outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username)) + + class Meta: + model = models.Actor + + @classmethod + def _generate(cls, create, attrs): + has_public = attrs.get('public_key') is not None + has_private = attrs.get('private_key') is not None + if not has_public and not has_private: + private, public = keys.get_key_pair() + attrs['private_key'] = private.decode('utf-8') + attrs['public_key'] = public.decode('utf-8') + return super()._generate(create, attrs) + + +@registry.register(name='federation.Note') +class NoteFactory(factory.Factory): + type = 'Note' + id = factory.Faker('url') + published = factory.LazyFunction( + lambda: timezone.now().isoformat() + ) + inReplyTo = None + content = factory.Faker('sentence') + + class Meta: + model = dict diff --git a/api/funkwhale_api/federation/keys.py b/api/funkwhale_api/federation/keys.py new file mode 100644 index 0000000000000000000000000000000000000000..08d4034ea347a6a17bb5d5701217d54cc1c57fa0 --- /dev/null +++ b/api/funkwhale_api/federation/keys.py @@ -0,0 +1,49 @@ +from cryptography.hazmat.primitives import serialization as crypto_serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.backends import default_backend as crypto_default_backend + +import re +import requests +import urllib.parse + +from . import exceptions + +KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"') + + +def get_key_pair(size=2048): + key = rsa.generate_private_key( + backend=crypto_default_backend(), + public_exponent=65537, + key_size=size + ) + private_key = key.private_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PrivateFormat.PKCS8, + crypto_serialization.NoEncryption()) + public_key = key.public_key().public_bytes( + crypto_serialization.Encoding.PEM, + crypto_serialization.PublicFormat.PKCS1 + ) + + return private_key, public_key + + +def get_key_id_from_signature_header(header_string): + parts = header_string.split(',') + try: + raw_key_id = [p for p in parts if p.startswith('keyId="')][0] + except IndexError: + raise ValueError('Missing key id') + + match = KEY_ID_REGEX.match(raw_key_id) + if not match: + raise ValueError('Invalid key id') + + key_id = match.groups()[0] + url = urllib.parse.urlparse(key_id) + if not url.scheme or not url.netloc: + raise ValueError('Invalid url') + if url.scheme not in ['http', 'https']: + raise ValueError('Invalid shceme') + return key_id diff --git a/api/funkwhale_api/federation/management/__init__.py b/api/funkwhale_api/federation/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/management/commands/__init__.py b/api/funkwhale_api/federation/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/management/commands/generate_keys.py b/api/funkwhale_api/federation/management/commands/generate_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..eafe9aae3477753a7b61cbc854152e3d95e26e59 --- /dev/null +++ b/api/funkwhale_api/federation/management/commands/generate_keys.py @@ -0,0 +1,53 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from dynamic_preferences.registries import global_preferences_registry + +from funkwhale_api.federation import keys + + +class Command(BaseCommand): + help = ( + 'Generate a public/private key pair for your instance,' + ' for federation purposes. If a key pair already exists, does nothing.' + ) + + def add_arguments(self, parser): + parser.add_argument( + '--replace', + action='store_true', + dest='replace', + default=False, + help='Replace existing key pair, if any', + ) + parser.add_argument( + '--noinput', '--no-input', action='store_false', dest='interactive', + help="Do NOT prompt the user for input of any kind.", + ) + + @transaction.atomic + def handle(self, *args, **options): + preferences = global_preferences_registry.manager() + existing_public = preferences['federation__public_key'] + existing_private = preferences['federation__public_key'] + + if existing_public or existing_private and not options['replace']: + raise CommandError( + 'Keys are already present! ' + 'Replace them with --replace if you know what you are doing.') + + if options['interactive']: + message = ( + 'Are you sure you want to do this?\n\n' + "Type 'yes' to continue, or 'no' to cancel: " + ) + if input(''.join(message)) != 'yes': + raise CommandError("Operation cancelled.") + private, public = keys.get_key_pair() + preferences['federation__public_key'] = public.decode('utf-8') + preferences['federation__private_key'] = private.decode('utf-8') + + self.stdout.write( + 'Your new key pair was generated.' + 'Your public key is now:\n\n{}'.format(public.decode('utf-8')) + ) diff --git a/api/funkwhale_api/federation/migrations/0001_initial.py b/api/funkwhale_api/federation/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..a9157e57e3fa123c5e79b2d23176f7cd744ee926 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 2.0.3 on 2018-03-31 13:43 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Actor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(db_index=True, max_length=500, unique=True)), + ('outbox_url', models.URLField(max_length=500)), + ('inbox_url', models.URLField(max_length=500)), + ('following_url', models.URLField(blank=True, max_length=500, null=True)), + ('followers_url', models.URLField(blank=True, max_length=500, null=True)), + ('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)), + ('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)), + ('name', models.CharField(blank=True, max_length=200, null=True)), + ('domain', models.CharField(max_length=1000)), + ('summary', models.CharField(blank=True, max_length=500, null=True)), + ('preferred_username', models.CharField(blank=True, max_length=200, null=True)), + ('public_key', models.CharField(blank=True, max_length=5000, null=True)), + ('private_key', models.CharField(blank=True, max_length=5000, null=True)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)), + ('manually_approves_followers', models.NullBooleanField(default=None)), + ], + ), + ] diff --git a/api/funkwhale_api/federation/migrations/__init__.py b/api/funkwhale_api/federation/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py new file mode 100644 index 0000000000000000000000000000000000000000..d76ad173be80c0d37dc063a7b0d829952f9e16db --- /dev/null +++ b/api/funkwhale_api/federation/models.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone + +TYPE_CHOICES = [ + ('Person', 'Person'), + ('Application', 'Application'), + ('Group', 'Group'), + ('Organization', 'Organization'), + ('Service', 'Service'), +] + + +class Actor(models.Model): + url = models.URLField(unique=True, max_length=500, db_index=True) + outbox_url = models.URLField(max_length=500) + inbox_url = models.URLField(max_length=500) + following_url = models.URLField(max_length=500, null=True, blank=True) + followers_url = models.URLField(max_length=500, null=True, blank=True) + shared_inbox_url = models.URLField(max_length=500, null=True, blank=True) + type = models.CharField( + choices=TYPE_CHOICES, default='Person', max_length=25) + name = models.CharField(max_length=200, null=True, blank=True) + domain = models.CharField(max_length=1000) + summary = models.CharField(max_length=500, null=True, blank=True) + preferred_username = models.CharField( + max_length=200, null=True, blank=True) + public_key = models.CharField(max_length=5000, null=True, blank=True) + private_key = models.CharField(max_length=5000, null=True, blank=True) + creation_date = models.DateTimeField(default=timezone.now) + last_fetch_date = models.DateTimeField( + default=timezone.now) + manually_approves_followers = models.NullBooleanField(default=None) + + @property + def webfinger_subject(self): + return '{}@{}'.format( + self.preferred_username, + settings.FEDERATION_HOSTNAME, + ) + + @property + def private_key_id(self): + return '{}#main-key'.format(self.url) + + @property + def mention_username(self): + return '@{}@{}'.format(self.preferred_username, self.domain) + + def save(self, **kwargs): + lowercase_fields = [ + 'domain', + ] + for field in lowercase_fields: + v = getattr(self, field, None) + if v: + setattr(self, field, v.lower()) + + super().save(**kwargs) diff --git a/api/funkwhale_api/federation/parsers.py b/api/funkwhale_api/federation/parsers.py new file mode 100644 index 0000000000000000000000000000000000000000..874d808f973dfcdf99372332b3991136c44d8605 --- /dev/null +++ b/api/funkwhale_api/federation/parsers.py @@ -0,0 +1,5 @@ +from rest_framework import parsers + + +class ActivityParser(parsers.JSONParser): + media_type = 'application/activity+json' diff --git a/api/funkwhale_api/federation/renderers.py b/api/funkwhale_api/federation/renderers.py new file mode 100644 index 0000000000000000000000000000000000000000..642b634628f2787044eae9b6a96b9e7004b05244 --- /dev/null +++ b/api/funkwhale_api/federation/renderers.py @@ -0,0 +1,9 @@ +from rest_framework.renderers import JSONRenderer + + +class ActivityPubRenderer(JSONRenderer): + media_type = 'application/activity+json' + + +class WebfingerRenderer(JSONRenderer): + media_type = 'application/jrd+json' diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..2137e8d910373e0c8d23b3e38c0d9952e4a93787 --- /dev/null +++ b/api/funkwhale_api/federation/serializers.py @@ -0,0 +1,175 @@ +import urllib.parse + +from django.urls import reverse +from django.conf import settings + +from rest_framework import serializers +from dynamic_preferences.registries import global_preferences_registry + +from . import activity +from . import models +from . import utils + + +class ActorSerializer(serializers.ModelSerializer): + # left maps to activitypub fields, right to our internal models + id = serializers.URLField(source='url') + outbox = serializers.URLField(source='outbox_url') + inbox = serializers.URLField(source='inbox_url') + following = serializers.URLField(source='following_url', required=False) + followers = serializers.URLField(source='followers_url', required=False) + preferredUsername = serializers.CharField( + source='preferred_username', required=False) + publicKey = serializers.JSONField(source='public_key', required=False) + manuallyApprovesFollowers = serializers.NullBooleanField( + source='manually_approves_followers', required=False) + summary = serializers.CharField(max_length=None, required=False) + + class Meta: + model = models.Actor + fields = [ + 'id', + 'type', + 'name', + 'summary', + 'preferredUsername', + 'publicKey', + 'inbox', + 'outbox', + 'following', + 'followers', + 'manuallyApprovesFollowers', + ] + + def to_representation(self, instance): + ret = super().to_representation(instance) + ret['@context'] = [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ] + if instance.public_key: + ret['publicKey'] = { + 'owner': instance.url, + 'publicKeyPem': instance.public_key, + 'id': '{}#main-key'.format(instance.url) + } + ret['endpoints'] = {} + if instance.shared_inbox_url: + ret['endpoints']['sharedInbox'] = instance.shared_inbox_url + return ret + + def prepare_missing_fields(self): + kwargs = {} + domain = urllib.parse.urlparse(self.validated_data['url']).netloc + kwargs['domain'] = domain + for endpoint, url in self.initial_data.get('endpoints', {}).items(): + if endpoint == 'sharedInbox': + kwargs['shared_inbox_url'] = url + break + try: + kwargs['public_key'] = self.initial_data['publicKey']['publicKeyPem'] + except KeyError: + pass + return kwargs + + def build(self): + d = self.validated_data.copy() + d.update(self.prepare_missing_fields()) + return self.Meta.model(**d) + + def save(self, **kwargs): + kwargs.update(self.prepare_missing_fields()) + return super().save(**kwargs) + + def validate_summary(self, value): + if value: + return value[:500] + + +class ActorWebfingerSerializer(serializers.ModelSerializer): + class Meta: + model = models.Actor + fields = ['url'] + + def to_representation(self, instance): + data = {} + data['subject'] = 'acct:{}'.format(instance.webfinger_subject) + data['links'] = [ + { + 'rel': 'self', + 'href': instance.url, + 'type': 'application/activity+json' + } + ] + data['aliases'] = [ + instance.url + ] + return data + + +class ActivitySerializer(serializers.Serializer): + actor = serializers.URLField() + id = serializers.URLField() + type = serializers.ChoiceField( + choices=[(c, c) for c in activity.ACTIVITY_TYPES]) + object = serializers.JSONField() + + def validate_object(self, value): + try: + type = value['type'] + except KeyError: + raise serializers.ValidationError('Missing object type') + + try: + object_serializer = OBJECT_SERIALIZERS[type] + except KeyError: + raise serializers.ValidationError( + 'Unsupported type {}'.format(type)) + + serializer = object_serializer(data=value) + serializer.is_valid(raise_exception=True) + return serializer.data + + def validate_actor(self, value): + request_actor = self.context.get('actor') + if request_actor and request_actor.url != value: + raise serializers.ValidationError( + 'The actor making the request do not match' + ' the activity actor' + ) + return value + + +class ObjectSerializer(serializers.Serializer): + id = serializers.URLField() + url = serializers.URLField(required=False, allow_null=True) + type = serializers.ChoiceField( + choices=[(c, c) for c in activity.OBJECT_TYPES]) + content = serializers.CharField( + required=False, allow_null=True) + summary = serializers.CharField( + required=False, allow_null=True) + name = serializers.CharField( + required=False, allow_null=True) + published = serializers.DateTimeField( + required=False, allow_null=True) + updated = serializers.DateTimeField( + required=False, allow_null=True) + to = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + cc = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + bto = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + bcc = serializers.ListField( + child=serializers.URLField(), + required=False, allow_null=True) + +OBJECT_SERIALIZERS = { + t: ObjectSerializer + for t in activity.OBJECT_TYPES +} diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py new file mode 100644 index 0000000000000000000000000000000000000000..7e4d2aa5ae08748ea5b6975aa7345d33f993ab1c --- /dev/null +++ b/api/funkwhale_api/federation/signing.py @@ -0,0 +1,55 @@ +import logging +import requests +import requests_http_signature + +from . import exceptions +from . import utils + +logger = logging.getLogger(__name__) + + +def verify(request, public_key): + return requests_http_signature.HTTPSignatureAuth.verify( + request, + key_resolver=lambda **kwargs: public_key, + use_auth_header=False, + ) + + +def verify_django(django_request, public_key): + """ + Given a django WSGI request, create an underlying requests.PreparedRequest + instance we can verify + """ + headers = utils.clean_wsgi_headers(django_request.META) + for h, v in list(headers.items()): + # we include lower-cased version of the headers for compatibility + # with requests_http_signature + headers[h.lower()] = v + try: + signature = headers['Signature'] + except KeyError: + raise exceptions.MissingSignature + url = 'http://noop{}'.format(django_request.path) + query = django_request.META['QUERY_STRING'] + if query: + url += '?{}'.format(query) + signature_headers = signature.split('headers="')[1].split('",')[0] + expected = signature_headers.split(' ') + logger.debug('Signature expected headers: %s', expected) + for header in expected: + try: + headers[header] + except KeyError: + logger.debug('Missing header: %s', header) + request = requests.Request( + method=django_request.method, + url=url, + data=django_request.body, + headers=headers) + for h in request.headers.keys(): + v = request.headers[h] + if v: + request.headers[h] = str(v) + prepared_request = request.prepare() + return verify(request, public_key) diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..f2c6f4c78c61973436b3d92aacebdec3506156fd --- /dev/null +++ b/api/funkwhale_api/federation/urls.py @@ -0,0 +1,15 @@ +from rest_framework import routers + +from . import views + +router = routers.SimpleRouter(trailing_slash=False) +router.register( + r'federation/instance/actors', + views.InstanceActorViewSet, + 'instance-actors') +router.register( + r'.well-known', + views.WellKnownViewSet, + 'well-known') + +urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..df093add8f934bbaabae8f6e9e68712f3762c53d --- /dev/null +++ b/api/funkwhale_api/federation/utils.py @@ -0,0 +1,35 @@ +from django.conf import settings + + +def full_url(path): + """ + Given a relative path, return a full url usable for federation purpose + """ + root = settings.FUNKWHALE_URL + if path.startswith('/') and root.endswith('/'): + return root + path[1:] + elif not path.startswith('/') and not root.endswith('/'): + return root + '/' + path + else: + return root + path + + +def clean_wsgi_headers(raw_headers): + """ + Convert WSGI headers from CONTENT_TYPE to Content-Type notation + """ + cleaned = {} + non_prefixed = [ + 'content_type', + 'content_length', + ] + for raw_header, value in raw_headers.items(): + h = raw_header.lower() + if not h.startswith('http_') and h not in non_prefixed: + continue + + words = h.replace('http_', '', 1).split('_') + cleaned_header = '-'.join([w.capitalize() for w in words]) + cleaned[cleaned_header] = value + + return cleaned diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py new file mode 100644 index 0000000000000000000000000000000000000000..2e3feb8d082ebdca00917e59e13d5f3cc601eb33 --- /dev/null +++ b/api/funkwhale_api/federation/views.py @@ -0,0 +1,103 @@ +from django import forms +from django.conf import settings +from django.http import HttpResponse + +from rest_framework import viewsets +from rest_framework import views +from rest_framework import response +from rest_framework.decorators import list_route, detail_route + +from . import actors +from . import authentication +from . import renderers +from . import serializers +from . import webfinger + + +class FederationMixin(object): + def dispatch(self, request, *args, **kwargs): + if not settings.FEDERATION_ENABLED: + return HttpResponse(status=405) + return super().dispatch(request, *args, **kwargs) + + +class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): + lookup_field = 'actor' + lookup_value_regex = '[a-z]*' + authentication_classes = [ + authentication.SignatureAuthentication] + permission_classes = [] + renderer_classes = [renderers.ActivityPubRenderer] + + def get_object(self): + try: + return actors.SYSTEM_ACTORS[self.kwargs['actor']] + except KeyError: + raise Http404 + + def retrieve(self, request, *args, **kwargs): + system_actor = self.get_object() + actor = system_actor.get_actor_instance() + serializer = serializers.ActorSerializer(actor) + return response.Response(serializer.data, status=200) + + @detail_route(methods=['get', 'post']) + def inbox(self, request, *args, **kwargs): + system_actor = self.get_object() + handler = getattr(system_actor, '{}_inbox'.format( + request.method.lower() + )) + + try: + data = handler(request.data, actor=request.actor) + except NotImplementedError: + return response.Response(status=405) + return response.Response(data, status=200) + + @detail_route(methods=['get', 'post']) + def outbox(self, request, *args, **kwargs): + system_actor = self.get_object() + handler = getattr(system_actor, '{}_outbox'.format( + request.method.lower() + )) + try: + data = handler(request.data, actor=request.actor) + except NotImplementedError: + return response.Response(status=405) + return response.Response(data, status=200) + + +class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet): + authentication_classes = [] + permission_classes = [] + renderer_classes = [renderers.WebfingerRenderer] + + @list_route(methods=['get']) + def webfinger(self, request, *args, **kwargs): + try: + resource_type, resource = webfinger.clean_resource( + request.GET['resource']) + cleaner = getattr(webfinger, 'clean_{}'.format(resource_type)) + result = cleaner(resource) + except forms.ValidationError as e: + return response.Response({ + 'errors': { + 'resource': e.message + } + }, status=400) + except KeyError: + return response.Response({ + 'errors': { + 'resource': 'This field is required', + } + }, status=400) + + handler = getattr(self, 'handler_{}'.format(resource_type)) + data = handler(result) + + return response.Response(data) + + def handler_acct(self, clean_result): + username, hostname = clean_result + actor = actors.SYSTEM_ACTORS[username].get_actor_instance() + return serializers.ActorWebfingerSerializer(actor).data diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py new file mode 100644 index 0000000000000000000000000000000000000000..4e97533852421210aead271aa510c40d29478e6e --- /dev/null +++ b/api/funkwhale_api/federation/webfinger.py @@ -0,0 +1,39 @@ +from django import forms +from django.conf import settings +from django.urls import reverse + +from . import actors +from . import utils + +VALID_RESOURCE_TYPES = ['acct'] + + +def clean_resource(resource_string): + if not resource_string: + raise forms.ValidationError('Invalid resource string') + + try: + resource_type, resource = resource_string.split(':', 1) + except ValueError: + raise forms.ValidationError('Missing webfinger resource type') + + if resource_type not in VALID_RESOURCE_TYPES: + raise forms.ValidationError('Invalid webfinger resource type') + + return resource_type, resource + + +def clean_acct(acct_string): + try: + username, hostname = acct_string.split('@') + except ValueError: + raise forms.ValidationError('Invalid format') + + if hostname.lower() != settings.FEDERATION_HOSTNAME: + raise forms.ValidationError( + 'Invalid hostname {}'.format(hostname)) + + if username not in actors.SYSTEM_ACTORS: + raise forms.ValidationError('Invalid username') + + return username, hostname diff --git a/api/funkwhale_api/history/admin.py b/api/funkwhale_api/history/admin.py index 6d0480e73b4209629499c7ae26b8c5efa8348999..5ddfb899848f389d776632eaf9e3b6d389cd7f58 100644 --- a/api/funkwhale_api/history/admin.py +++ b/api/funkwhale_api/history/admin.py @@ -4,7 +4,7 @@ from . import models @admin.register(models.Listening) class ListeningAdmin(admin.ModelAdmin): - list_display = ['track', 'end_date', 'user', 'session_key'] + list_display = ['track', 'creation_date', 'user', 'session_key'] search_fields = ['track__name', 'user__username'] list_select_related = [ 'user', diff --git a/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py new file mode 100644 index 0000000000000000000000000000000000000000..d83dbb0a466b668279619e53406b8ae977ab5dc7 --- /dev/null +++ b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py @@ -0,0 +1,22 @@ +# Generated by Django 2.0.3 on 2018-03-25 14:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='listening', + options={'ordering': ('-creation_date',)}, + ), + migrations.RenameField( + model_name='listening', + old_name='end_date', + new_name='creation_date', + ), + ] diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py index 56310ddc0d2546784bed02952fe6142a2d139858..762d5bf7b2cf66bdd9a96325c630db65a53ddaae 100644 --- a/api/funkwhale_api/history/models.py +++ b/api/funkwhale_api/history/models.py @@ -6,7 +6,8 @@ from funkwhale_api.music.models import Track class Listening(models.Model): - end_date = models.DateTimeField(default=timezone.now, null=True, blank=True) + creation_date = models.DateTimeField( + default=timezone.now, null=True, blank=True) track = models.ForeignKey( Track, related_name="listenings", on_delete=models.CASCADE) user = models.ForeignKey( @@ -18,7 +19,7 @@ class Listening(models.Model): session_key = models.CharField(max_length=100, null=True, blank=True) class Meta: - ordering = ('-end_date',) + ordering = ('-creation_date',) def save(self, **kwargs): if not self.user and not self.session_key: diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py index 7a2280cea2a236357982f309b7218c3e0d073299..8fe6fa6e01f07a395f2c337ea45591bd315a03d3 100644 --- a/api/funkwhale_api/history/serializers.py +++ b/api/funkwhale_api/history/serializers.py @@ -12,7 +12,7 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() object = TrackActivitySerializer(source='track') actor = UserActivitySerializer(source='user') - published = serializers.DateTimeField(source='end_date') + published = serializers.DateTimeField(source='creation_date') class Meta: model = models.Listening @@ -36,7 +36,7 @@ class ListeningSerializer(serializers.ModelSerializer): class Meta: model = models.Listening - fields = ('id', 'user', 'session_key', 'track', 'end_date') + fields = ('id', 'user', 'session_key', 'track', 'creation_date') def create(self, validated_data): diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 31d13d4957f6882b647b99388f334a1b03f092bb..3748d55730faf8188d56828bda3afd67dd219393 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -121,7 +121,13 @@ class Metadata(object): def __init__(self, path): self._file = mutagen.File(path) - self._conf = CONF[self.get_file_type(self._file)] + if self._file is None: + raise ValueError('Cannot parse metadata from {}'.format(path)) + ft = self.get_file_type(self._file) + try: + self._conf = CONF[ft] + except KeyError: + raise ValueError('Unsupported format {}'.format(ft)) def get_file_type(self, f): return f.__class__.__name__ diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 0d33855a6b09060eb5094f8b56aaac9aac641a89..5ac3143f9e647feee128a07bbed29b18e9766aa7 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -328,7 +328,7 @@ class SubmitViewSet(viewsets.ViewSet): job = models.ImportJob.objects.create(mbid=request.POST['mbid'], batch=batch, source=request.POST['import_url']) tasks.import_job_run.delay(import_job_id=job.pk) serializer = serializers.ImportBatchSerializer(batch) - return Response(serializer.data) + return Response(serializer.data, status=201) def get_import_request(self, data): try: diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py index 2fa5e464cb6c650c3250f68888b0dfe599b25546..dbc01289f1206c18a15a691d2fb8025695155b17 100644 --- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py +++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py @@ -34,6 +34,13 @@ class Command(BaseCommand): default=False, help='Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI', ) + parser.add_argument( + '--exit', '-x', + action='store_true', + dest='exit_on_failure', + default=False, + help='use this flag to disable error catching', + ) parser.add_argument( '--no-acoustid', action='store_true', @@ -106,20 +113,27 @@ class Command(BaseCommand): async = options['async'] import_handler = tasks.import_job_run.delay if async else tasks.import_job_run for path in matching: - job = batch.jobs.create( - source='file://' + path, - ) - name = os.path.basename(path) - with open(path, 'rb') as f: - job.audio_file.save(name, File(f)) - - job.save() try: - utils.on_commit( - import_handler, - import_job_id=job.pk, - use_acoustid=not options['no_acoustid']) + self.stdout.write(message.format(path)) + self.import_file(path, batch, import_handler, options) except Exception as e: - self.stdout.write('Error: {}'.format(e)) - + if options['exit_on_failure']: + raise + m = 'Error while importing {}: {} {}'.format( + path, e.__class__.__name__, e) + self.stderr.write(m) return batch + + def import_file(self, path, batch, import_handler, options): + job = batch.jobs.create( + source='file://' + path, + ) + name = os.path.basename(path) + with open(path, 'rb') as f: + job.audio_file.save(name, File(f)) + + job.save() + utils.on_commit( + import_handler, + import_job_id=job.pk, + use_acoustid=not options['no_acoustid']) diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py index 520e98652f4238de071ae49f419525f745e97785..2e7e6a409fb4e4102f1d713faaeb94ca3b7e759a 100644 --- a/api/funkwhale_api/radios/serializers.py +++ b/api/funkwhale_api/radios/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from funkwhale_api.music.serializers import TrackSerializerNested +from funkwhale_api.users.serializers import UserBasicSerializer from . import filters from . import models @@ -15,6 +16,8 @@ class FilterSerializer(serializers.Serializer): class RadioSerializer(serializers.ModelSerializer): + user = UserBasicSerializer(read_only=True) + class Meta: model = models.Radio fields = ( diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py index 42652644224446ccade4d43b36c995581bb15783..ffd1d16593ddc2648ec2748dc3b4174afe41f7ed 100644 --- a/api/funkwhale_api/radios/views.py +++ b/api/funkwhale_api/radios/views.py @@ -20,6 +20,7 @@ class RadioViewSet( mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, + mixins.DestroyModelMixin, viewsets.GenericViewSet): serializer_class = serializers.RadioSerializer @@ -40,6 +41,16 @@ class RadioViewSet( raise Http404 return serializer.save(user=self.request.user) + @detail_route(methods=['get']) + def tracks(self, request, *args, **kwargs): + radio = self.get_object() + tracks = radio.get_candidates().for_nested_serialization() + + page = self.paginate_queryset(tracks) + if page is not None: + serializer = TrackSerializerNested(page, many=True) + return self.get_paginated_response(serializer.data) + @list_route(methods=['get']) def filters(self, request, *args, **kwargs): serializer = serializers.FilterSerializer( diff --git a/api/manage.py b/api/manage.py index d99574ebe2ac8b7f8381d768b4651cb002561e1f..c8db95ede2a7c2a36c4f2368ab77d1d600be3972 100755 --- a/api/manage.py +++ b/api/manage.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +import django import os import sys @@ -7,6 +8,12 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__))) if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + # we're doing this here since otherwise, missing environment + # files in settings result in AttributeError being raised, generating + # a cryptic django.core.exceptions.AppRegistryNotReady error. + # To prevent that, we explicitely load settings here before anything + # else, so we fail fast with a relevant error. See #140 for more details. + django.setup() from django.core.management import execute_from_command_line diff --git a/api/requirements.txt b/api/requirements.txt index d1197135eeb072ccaca11906b49b2fe160bd010f..00be27c5356b417fc5da953c6bceab608e2f382c 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,3 +1,4 @@ # This file is here because many Platforms as a Service look for # requirements.txt in the root directory of a project. +-r requirements/base.txt -r requirements/production.txt diff --git a/api/requirements/base.txt b/api/requirements/base.txt index efcc4eea40e66fadd74b25dc8cfa1c89f4e8abe7..b66e297a9942524b02df71fdcc67c81225e62c1e 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -14,7 +14,7 @@ django-allauth>=0.34,<0.35 # Python-PostgreSQL Database Adapter -psycopg2>=2.7,<=2.8 +psycopg2-binary>=2.7,<=2.8 # Time zones support pytz==2017.3 @@ -60,3 +60,7 @@ channels_redis>=2.1,<2.2 django-cacheops>=4,<4.1 daphne==2.0.4 +cryptography>=2,<3 +# requests-http-signature==0.0.3 +# clone until the branch is merged and released upstream +git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support diff --git a/api/requirements/test.txt b/api/requirements/test.txt index c12b44827ebbf9440e180de7d686b8f2e190118c..20a14abea1685387764012ea5ac3e01d60a09a47 100644 --- a/api/requirements/test.txt +++ b/api/requirements/test.txt @@ -9,3 +9,6 @@ git+https://github.com/pytest-dev/pytest-django.git@d3d9bb3ef6f0377cb5356eb36899 pytest-mock pytest-sugar pytest-xdist +pytest-cov +pytest-env +requests-mock diff --git a/api/setup.cfg b/api/setup.cfg index 34daa8c6834452229971467c7876400b842b64c1..a2b8b92c682696a0ad4569cb6c9f9e25c01f9b9f 100644 --- a/api/setup.cfg +++ b/api/setup.cfg @@ -7,6 +7,12 @@ max-line-length = 120 exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules [tool:pytest] -DJANGO_SETTINGS_MODULE=config.settings.test python_files = tests.py test_*.py *_tests.py testpaths = tests +env = + SECRET_KEY=test + DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend + CELERY_BROKER_URL=memory:// + CELERY_TASK_ALWAYS_EAGER=True + CACHEOPS_ENABLED=False + FEDERATION_HOSTNAME=test.federation diff --git a/api/tests/activity/__init__.py b/api/tests/activity/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/activity/test_serializers.py b/api/tests/activity/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..792fa74b9cbb3ed778c5e84bd746fb210e738acf --- /dev/null +++ b/api/tests/activity/test_serializers.py @@ -0,0 +1,17 @@ +from funkwhale_api.activity import serializers +from funkwhale_api.favorites.serializers import TrackFavoriteActivitySerializer +from funkwhale_api.history.serializers import \ + ListeningActivitySerializer + + +def test_autoserializer(factories): + favorite = factories['favorites.TrackFavorite']() + listening = factories['history.Listening']() + objects = [favorite, listening] + serializer = serializers.AutoSerializer(objects, many=True) + expected = [ + TrackFavoriteActivitySerializer(favorite).data, + ListeningActivitySerializer(listening).data, + ] + + assert serializer.data == expected diff --git a/api/tests/activity/test_utils.py b/api/tests/activity/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..43bb45df84931ccd3ba2a56e555b991627c3a62c --- /dev/null +++ b/api/tests/activity/test_utils.py @@ -0,0 +1,21 @@ +from funkwhale_api.activity import utils + + +def test_get_activity(factories): + user = factories['users.User']() + listening = factories['history.Listening']() + favorite = factories['favorites.TrackFavorite']() + + objects = list(utils.get_activity(user)) + assert objects == [favorite, listening] + + +def test_get_activity_honors_privacy_level(factories, anonymous_user): + listening = factories['history.Listening'](user__privacy_level='me') + favorite1 = factories['favorites.TrackFavorite']( + user__privacy_level='everyone') + favorite2 = factories['favorites.TrackFavorite']( + user__privacy_level='instance') + + objects = list(utils.get_activity(anonymous_user)) + assert objects == [favorite1] diff --git a/api/tests/activity/test_views.py b/api/tests/activity/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..bdc3c6339ffe91981621c8f8272788347a01cc8e --- /dev/null +++ b/api/tests/activity/test_views.py @@ -0,0 +1,18 @@ +from django.urls import reverse + +from funkwhale_api.activity import serializers +from funkwhale_api.activity import utils + + +def test_activity_view(factories, api_client, settings, anonymous_user): + settings.API_AUTHENTICATION_REQUIRED = False + favorite = factories['favorites.TrackFavorite']( + user__privacy_level='everyone') + listening = factories['history.Listening']() + url = reverse('api:v1:activity-list') + objects = utils.get_activity(anonymous_user) + serializer = serializers.AutoSerializer(objects, many=True) + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data['results'] == serializer.data diff --git a/api/tests/channels/__init__.py b/api/tests/channels/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/common/__init__.py b/api/tests/common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py index 7c63431a38ae4e5eaf41c5285966afd80a2dd6e2..29a8fb05c4ead7d9be810de7b64ba430a17f7fee 100644 --- a/api/tests/common/test_fields.py +++ b/api/tests/common/test_fields.py @@ -10,7 +10,7 @@ from funkwhale_api.users.factories import UserFactory @pytest.mark.parametrize('user,expected', [ (AnonymousUser(), Q(privacy_level='everyone')), (UserFactory.build(pk=1), - Q(privacy_level__in=['me', 'followers', 'instance', 'everyone'])), + Q(privacy_level__in=['followers', 'instance', 'everyone'])), ]) def test_privacy_level_query(user,expected): query = fields.privacy_level_query(user) diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py index b5c5160f8accdf6e0bbeb29f9ee4d464962dff5b..f04f12e0b0e19a75992ad364eee4f05219d0e3f7 100644 --- a/api/tests/common/test_permissions.py +++ b/api/tests/common/test_permissions.py @@ -2,7 +2,6 @@ import pytest from rest_framework.views import APIView -from django.contrib.auth.models import AnonymousUser from django.http import Http404 from funkwhale_api.common import permissions @@ -19,24 +18,26 @@ def test_owner_permission_owner_field_ok(nodb_factories, api_request): assert check is True -def test_owner_permission_owner_field_not_ok(nodb_factories, api_request): +def test_owner_permission_owner_field_not_ok( + anonymous_user, nodb_factories, api_request): playlist = nodb_factories['playlists.Playlist']() view = APIView.as_view() permission = permissions.OwnerPermission() request = api_request.get('/') - setattr(request, 'user', AnonymousUser()) + setattr(request, 'user', anonymous_user) with pytest.raises(Http404): permission.has_object_permission(request, view, playlist) -def test_owner_permission_read_only(nodb_factories, api_request): +def test_owner_permission_read_only( + anonymous_user, nodb_factories, api_request): playlist = nodb_factories['playlists.Playlist']() view = APIView.as_view() setattr(view, 'owner_checks', ['write']) permission = permissions.OwnerPermission() request = api_request.get('/') - setattr(request, 'user', AnonymousUser()) + setattr(request, 'user', anonymous_user) check = permission.has_object_permission(request, view, playlist) assert check is True diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 62bc5ada676327aa1d5044c7bd31eaea45904dea..d5bb565651c4b1282920fa455356db4bf6704c35 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,9 +1,13 @@ import factory -import tempfile -import shutil import pytest +import requests_mock +import shutil +import tempfile +from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache +from django.test import client + from dynamic_preferences.registries import global_preferences_registry from rest_framework.test import APIClient @@ -31,7 +35,11 @@ def cache(): def factories(db): from funkwhale_api import factories for v in factories.registry.values(): - v._meta.strategy = factory.CREATE_STRATEGY + try: + v._meta.strategy = factory.CREATE_STRATEGY + except AttributeError: + # probably not a class based factory + pass yield factories.registry @@ -39,12 +47,16 @@ def factories(db): def nodb_factories(): from funkwhale_api import factories for v in factories.registry.values(): - v._meta.strategy = factory.BUILD_STRATEGY + try: + v._meta.strategy = factory.BUILD_STRATEGY + except AttributeError: + # probably not a class based factory + pass yield factories.registry @pytest.fixture -def preferences(db): +def preferences(db, cache): manager = global_preferences_registry.manager() manager.all() yield manager @@ -66,6 +78,11 @@ def logged_in_client(db, factories, client): delattr(client, 'user') +@pytest.fixture +def anonymous_user(): + return AnonymousUser() + + @pytest.fixture def api_client(client): return APIClient() @@ -103,6 +120,11 @@ def api_request(): return APIRequestFactory() +@pytest.fixture +def fake_request(): + return client.RequestFactory() + + @pytest.fixture def activity_registry(): r = record.registry @@ -126,3 +148,17 @@ def activity_registry(): @pytest.fixture def activity_muted(activity_registry, mocker): yield mocker.patch.object(record, 'send') + + +@pytest.fixture(autouse=True) +def media_root(settings): + tmp_dir = tempfile.mkdtemp() + settings.MEDIA_ROOT = tmp_dir + yield settings.MEDIA_ROOT + shutil.rmtree(tmp_dir) + + +@pytest.fixture +def r_mock(): + with requests_mock.mock() as m: + yield m diff --git a/api/tests/favorites/__init__.py b/api/tests/favorites/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/federation/__init__.py b/api/tests/federation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/federation/conftest.py b/api/tests/federation/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..c5831914bef6a59ddc80e88731c29127ec7b38b3 --- /dev/null +++ b/api/tests/federation/conftest.py @@ -0,0 +1,10 @@ +import pytest + + +@pytest.fixture +def authenticated_actor(nodb_factories, mocker): + actor = nodb_factories['federation.Actor']() + mocker.patch( + 'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor', + return_value=actor) + yield actor diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py new file mode 100644 index 0000000000000000000000000000000000000000..a6e1d28aa23623251a1ab26661d2814c14704f00 --- /dev/null +++ b/api/tests/federation/test_activity.py @@ -0,0 +1,32 @@ +from funkwhale_api.federation import activity + +def test_deliver(nodb_factories, r_mock, mocker): + to = nodb_factories['federation.Actor']() + mocker.patch( + 'funkwhale_api.federation.actors.get_actor', + return_value=to) + sender = nodb_factories['federation.Actor']() + ac = { + 'id': 'http://test.federation/activity', + 'type': 'Create', + 'actor': sender.url, + 'object': { + 'id': 'http://test.federation/note', + 'type': 'Note', + 'content': 'Hello', + } + } + + r_mock.post(to.inbox_url) + + activity.deliver( + ac, + to=[to.url], + on_behalf_of=sender, + ) + request = r_mock.request_history[0] + + assert r_mock.called is True + assert r_mock.call_count == 1 + assert request.url == to.inbox_url + assert request.headers['content-type'] == 'application/activity+json' diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py new file mode 100644 index 0000000000000000000000000000000000000000..b3b0f8df0bb7fc3b8295bb48b6b8bd6ad967c01f --- /dev/null +++ b/api/tests/federation/test_actors.py @@ -0,0 +1,190 @@ +import pytest + +from django.urls import reverse +from django.utils import timezone + +from rest_framework import exceptions + +from funkwhale_api.federation import actors +from funkwhale_api.federation import serializers +from funkwhale_api.federation import utils + + +def test_actor_fetching(r_mock): + payload = { + 'id': 'https://actor.mock/users/actor#main-key', + 'owner': 'test', + 'publicKeyPem': 'test_pem', + } + actor_url = 'https://actor.mock/' + r_mock.get(actor_url, json=payload) + r = actors.get_actor_data(actor_url) + + assert r == payload + + +def test_get_library(settings, preferences): + preferences['federation__public_key'] = 'public_key' + expected = { + 'preferred_username': 'library', + 'domain': settings.FEDERATION_HOSTNAME, + 'type': 'Person', + 'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME), + 'manually_approves_followers': True, + 'url': utils.full_url( + reverse( + 'federation:instance-actors-detail', + kwargs={'actor': 'library'})), + 'shared_inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': 'library'})), + 'inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': 'library'})), + 'outbox_url': utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': 'library'})), + 'public_key': 'public_key', + 'summary': 'Bot account to federate with {}\'s library'.format( + settings.FEDERATION_HOSTNAME), + } + actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + for key, value in expected.items(): + assert getattr(actor, key) == value + + +def test_get_test(settings, preferences): + preferences['federation__public_key'] = 'public_key' + expected = { + 'preferred_username': 'test', + 'domain': settings.FEDERATION_HOSTNAME, + 'type': 'Person', + 'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME), + 'manually_approves_followers': False, + 'url': utils.full_url( + reverse( + 'federation:instance-actors-detail', + kwargs={'actor': 'test'})), + 'shared_inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': 'test'})), + 'inbox_url': utils.full_url( + reverse( + 'federation:instance-actors-inbox', + kwargs={'actor': 'test'})), + 'outbox_url': utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': 'test'})), + 'public_key': 'public_key', + 'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format( + settings.FEDERATION_HOSTNAME), + } + actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() + for key, value in expected.items(): + assert getattr(actor, key) == value + + +def test_test_get_outbox(): + expected = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {} + ], + "id": utils.full_url( + reverse( + 'federation:instance-actors-outbox', + kwargs={'actor': 'test'})), + "type": "OrderedCollection", + "totalItems": 0, + "orderedItems": [] + } + + data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None) + + assert data == expected + + +def test_test_post_inbox_requires_authenticated_actor(): + with pytest.raises(exceptions.PermissionDenied): + actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None) + + +def test_test_post_outbox_validates_actor(nodb_factories): + actor = nodb_factories['federation.Actor']() + data = { + 'actor': 'noop' + } + with pytest.raises(exceptions.ValidationError) as exc_info: + actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) + msg = 'The actor making the request do not match' + assert msg in exc_info.value + + +def test_test_post_outbox_handles_create_note( + settings, mocker, factories): + deliver = mocker.patch( + 'funkwhale_api.federation.activity.deliver') + actor = factories['federation.Actor']() + now = timezone.now() + mocker.patch('django.utils.timezone.now', return_value=now) + data = { + 'actor': actor.url, + 'type': 'Create', + 'id': 'http://test.federation/activity', + 'object': { + 'type': 'Note', + 'id': 'http://test.federation/object', + 'content': '<p><a>@mention</a> /ping</p>' + } + } + test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() + expected_note = factories['federation.Note']( + id='https://test.federation/activities/note/{}'.format( + now.timestamp() + ), + content='Pong!', + published=now.isoformat(), + inReplyTo=data['object']['id'], + cc=[], + summary=None, + sensitive=False, + attributedTo=test_actor.url, + attachment=[], + to=[actor.url], + url='https://{}/activities/note/{}'.format( + settings.FEDERATION_HOSTNAME, now.timestamp() + ), + tag=[{ + 'href': actor.url, + 'name': actor.mention_username, + 'type': 'Mention', + }] + ) + expected_activity = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {} + ], + 'actor': test_actor.url, + 'id': 'https://{}/activities/note/{}/activity'.format( + settings.FEDERATION_HOSTNAME, now.timestamp() + ), + 'to': actor.url, + 'type': 'Create', + 'published': now.isoformat(), + 'object': expected_note, + 'cc': [], + } + actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) + deliver.assert_called_once_with( + expected_activity, + to=[actor.url], + on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance() + ) diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..1837b3950f471ba549d8f45077d5b773cc010da3 --- /dev/null +++ b/api/tests/federation/test_authentication.py @@ -0,0 +1,42 @@ +from funkwhale_api.federation import authentication +from funkwhale_api.federation import keys +from funkwhale_api.federation import signing + + +def test_authenticate(nodb_factories, mocker, api_request): + private, public = keys.get_key_pair() + actor_url = 'https://test.federation/actor' + mocker.patch( + 'funkwhale_api.federation.actors.get_actor_data', + return_value={ + 'id': actor_url, + 'outbox': 'https://test.com', + 'inbox': 'https://test.com', + 'publicKey': { + 'publicKeyPem': public.decode('utf-8'), + 'owner': actor_url, + 'id': actor_url + '#main-key', + } + }) + signed_request = nodb_factories['federation.SignedRequest']( + auth__key=private, + auth__key_id=actor_url + '#main-key', + auth__headers=[ + 'date', + ] + ) + prepared = signed_request.prepare() + django_request = api_request.get( + '/', + **{ + 'HTTP_DATE': prepared.headers['date'], + 'HTTP_SIGNATURE': prepared.headers['signature'], + } + ) + authenticator = authentication.SignatureAuthentication() + user, _ = authenticator.authenticate(django_request) + actor = django_request.actor + + assert user.is_anonymous is True + assert actor.public_key == public.decode('utf-8') + assert actor.url == actor_url diff --git a/api/tests/federation/test_commands.py b/api/tests/federation/test_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..7c533306821a24a664c260089c0f15201c5a9870 --- /dev/null +++ b/api/tests/federation/test_commands.py @@ -0,0 +1,14 @@ +from django.core.management import call_command + + +def test_generate_instance_key_pair(preferences, mocker): + mocker.patch( + 'funkwhale_api.federation.keys.get_key_pair', + return_value=(b'private', b'public')) + assert preferences['federation__public_key'] == '' + assert preferences['federation__private_key'] == '' + + call_command('generate_keys', interactive=False) + + assert preferences['federation__private_key'] == 'private' + assert preferences['federation__public_key'] == 'public' diff --git a/api/tests/federation/test_keys.py b/api/tests/federation/test_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..9dd71be092bc39dcfb09f9b0e931c51a9ea37f5c --- /dev/null +++ b/api/tests/federation/test_keys.py @@ -0,0 +1,25 @@ +import pytest + +from funkwhale_api.federation import keys + + +@pytest.mark.parametrize('raw, expected', [ + ('algorithm="test",keyId="https://test.com"', 'https://test.com'), + ('keyId="https://test.com",algorithm="test"', 'https://test.com'), +]) +def test_get_key_from_header(raw, expected): + r = keys.get_key_id_from_signature_header(raw) + assert r == expected + + +@pytest.mark.parametrize('raw', [ + 'algorithm="test",keyid="badCase"', + 'algorithm="test",wrong="wrong"', + 'keyId = "wrong"', + 'keyId=\'wrong\'', + 'keyId="notanurl"', + 'keyId="wrong://test.com"', +]) +def test_get_key_from_header_invalid(raw): + with pytest.raises(ValueError): + keys.get_key_id_from_signature_header(raw) diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..efa92b16a26dcdf72287282895872c10e5d10070 --- /dev/null +++ b/api/tests/federation/test_serializers.py @@ -0,0 +1,146 @@ +from django.urls import reverse + +from funkwhale_api.federation import keys +from funkwhale_api.federation import models +from funkwhale_api.federation import serializers + + +def test_actor_serializer_from_ap(db): + payload = { + 'id': 'https://test.federation/user', + 'type': 'Person', + 'following': 'https://test.federation/user/following', + 'followers': 'https://test.federation/user/followers', + 'inbox': 'https://test.federation/user/inbox', + 'outbox': 'https://test.federation/user/outbox', + 'preferredUsername': 'user', + 'name': 'Real User', + 'summary': 'Hello world', + 'url': 'https://test.federation/@user', + 'manuallyApprovesFollowers': False, + 'publicKey': { + 'id': 'https://test.federation/user#main-key', + 'owner': 'https://test.federation/user', + 'publicKeyPem': 'yolo' + }, + 'endpoints': { + 'sharedInbox': 'https://test.federation/inbox' + }, + } + + serializer = serializers.ActorSerializer(data=payload) + assert serializer.is_valid() + + actor = serializer.build() + + assert actor.url == payload['id'] + assert actor.inbox_url == payload['inbox'] + assert actor.outbox_url == payload['outbox'] + assert actor.shared_inbox_url == payload['endpoints']['sharedInbox'] + assert actor.followers_url == payload['followers'] + assert actor.following_url == payload['following'] + assert actor.public_key == payload['publicKey']['publicKeyPem'] + assert actor.preferred_username == payload['preferredUsername'] + assert actor.name == payload['name'] + assert actor.domain == 'test.federation' + assert actor.summary == payload['summary'] + assert actor.type == 'Person' + assert actor.manually_approves_followers == payload['manuallyApprovesFollowers'] + + +def test_actor_serializer_only_mandatory_field_from_ap(db): + payload = { + 'id': 'https://test.federation/user', + 'type': 'Person', + 'following': 'https://test.federation/user/following', + 'followers': 'https://test.federation/user/followers', + 'inbox': 'https://test.federation/user/inbox', + 'outbox': 'https://test.federation/user/outbox', + 'preferredUsername': 'user', + } + + serializer = serializers.ActorSerializer(data=payload) + assert serializer.is_valid() + + actor = serializer.build() + + assert actor.url == payload['id'] + assert actor.inbox_url == payload['inbox'] + assert actor.outbox_url == payload['outbox'] + assert actor.followers_url == payload['followers'] + assert actor.following_url == payload['following'] + assert actor.preferred_username == payload['preferredUsername'] + assert actor.domain == 'test.federation' + assert actor.type == 'Person' + assert actor.manually_approves_followers is None + + +def test_actor_serializer_to_ap(): + expected = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': 'https://test.federation/user', + 'type': 'Person', + 'following': 'https://test.federation/user/following', + 'followers': 'https://test.federation/user/followers', + 'inbox': 'https://test.federation/user/inbox', + 'outbox': 'https://test.federation/user/outbox', + 'preferredUsername': 'user', + 'name': 'Real User', + 'summary': 'Hello world', + 'manuallyApprovesFollowers': False, + 'publicKey': { + 'id': 'https://test.federation/user#main-key', + 'owner': 'https://test.federation/user', + 'publicKeyPem': 'yolo' + }, + 'endpoints': { + 'sharedInbox': 'https://test.federation/inbox' + }, + } + ac = models.Actor( + url=expected['id'], + inbox_url=expected['inbox'], + outbox_url=expected['outbox'], + shared_inbox_url=expected['endpoints']['sharedInbox'], + followers_url=expected['followers'], + following_url=expected['following'], + public_key=expected['publicKey']['publicKeyPem'], + preferred_username=expected['preferredUsername'], + name=expected['name'], + domain='test.federation', + summary=expected['summary'], + type='Person', + manually_approves_followers=False, + + ) + serializer = serializers.ActorSerializer(ac) + + assert serializer.data == expected + + +def test_webfinger_serializer(): + expected = { + 'subject': 'acct:service@test.federation', + 'links': [ + { + 'rel': 'self', + 'href': 'https://test.federation/federation/instance/actor', + 'type': 'application/activity+json', + } + ], + 'aliases': [ + 'https://test.federation/federation/instance/actor', + ] + } + actor = models.Actor( + url=expected['links'][0]['href'], + preferred_username='service', + domain='test.federation', + ) + serializer = serializers.ActorWebfingerSerializer(actor) + + assert serializer.data == expected diff --git a/api/tests/federation/test_signing.py b/api/tests/federation/test_signing.py new file mode 100644 index 0000000000000000000000000000000000000000..0c1ec2e0ba1dcd16722c2615eeeb48a5f01d7e87 --- /dev/null +++ b/api/tests/federation/test_signing.py @@ -0,0 +1,132 @@ +import cryptography.exceptions +import io +import pytest +import requests_http_signature + +from funkwhale_api.federation import signing +from funkwhale_api.federation import keys + + +def test_can_sign_and_verify_request(nodb_factories): + private, public = nodb_factories['federation.KeyPair']() + auth = nodb_factories['federation.SignatureAuth'](key=private) + request = nodb_factories['federation.SignedRequest']( + auth=auth + ) + prepared_request = request.prepare() + assert 'date' in prepared_request.headers + assert 'signature' in prepared_request.headers + assert signing.verify( + prepared_request, public) is None + + +def test_can_sign_and_verify_request_digest(nodb_factories): + private, public = nodb_factories['federation.KeyPair']() + auth = nodb_factories['federation.SignatureAuth'](key=private) + request = nodb_factories['federation.SignedRequest']( + auth=auth, + method='post', + data=b'hello=world' + ) + prepared_request = request.prepare() + assert 'date' in prepared_request.headers + assert 'digest' in prepared_request.headers + assert 'signature' in prepared_request.headers + assert signing.verify(prepared_request, public) is None + + +def test_verify_fails_with_wrong_key(nodb_factories): + wrong_private, wrong_public = nodb_factories['federation.KeyPair']() + request = nodb_factories['federation.SignedRequest']() + prepared_request = request.prepare() + + with pytest.raises(cryptography.exceptions.InvalidSignature): + signing.verify(prepared_request, wrong_public) + + +def test_can_verify_django_request(factories, fake_request): + private_key, public_key = keys.get_key_pair() + signed_request = factories['federation.SignedRequest']( + auth__key=private_key, + auth__headers=[ + 'date', + ] + ) + prepared = signed_request.prepare() + django_request = fake_request.get( + '/', + **{ + 'HTTP_DATE': prepared.headers['date'], + 'HTTP_SIGNATURE': prepared.headers['signature'], + } + ) + assert signing.verify_django(django_request, public_key) is None + + +def test_can_verify_django_request_digest(factories, fake_request): + private_key, public_key = keys.get_key_pair() + signed_request = factories['federation.SignedRequest']( + auth__key=private_key, + method='post', + data=b'hello=world', + auth__headers=[ + 'date', + 'digest', + ] + ) + prepared = signed_request.prepare() + django_request = fake_request.post( + '/', + **{ + 'HTTP_DATE': prepared.headers['date'], + 'HTTP_DIGEST': prepared.headers['digest'], + 'HTTP_SIGNATURE': prepared.headers['signature'], + } + ) + + assert signing.verify_django(django_request, public_key) is None + + +def test_can_verify_django_request_digest_failure(factories, fake_request): + private_key, public_key = keys.get_key_pair() + signed_request = factories['federation.SignedRequest']( + auth__key=private_key, + method='post', + data=b'hello=world', + auth__headers=[ + 'date', + 'digest', + ] + ) + prepared = signed_request.prepare() + django_request = fake_request.post( + '/', + **{ + 'HTTP_DATE': prepared.headers['date'], + 'HTTP_DIGEST': prepared.headers['digest'] + 'noop', + 'HTTP_SIGNATURE': prepared.headers['signature'], + } + ) + + with pytest.raises(cryptography.exceptions.InvalidSignature): + signing.verify_django(django_request, public_key) + + +def test_can_verify_django_request_failure(factories, fake_request): + private_key, public_key = keys.get_key_pair() + signed_request = factories['federation.SignedRequest']( + auth__key=private_key, + auth__headers=[ + 'date', + ] + ) + prepared = signed_request.prepare() + django_request = fake_request.get( + '/', + **{ + 'HTTP_DATE': 'Wrong', + 'HTTP_SIGNATURE': prepared.headers['signature'], + } + ) + with pytest.raises(cryptography.exceptions.InvalidSignature): + signing.verify_django(django_request, public_key) diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..dc371ad9ed976a5d1179a14d9dc58e831e2873e8 --- /dev/null +++ b/api/tests/federation/test_utils.py @@ -0,0 +1,48 @@ +import pytest + +from funkwhale_api.federation import utils + + +@pytest.mark.parametrize('url,path,expected', [ + ('http://test.com', '/hello', 'http://test.com/hello'), + ('http://test.com/', 'hello', 'http://test.com/hello'), + ('http://test.com/', '/hello', 'http://test.com/hello'), + ('http://test.com', 'hello', 'http://test.com/hello'), +]) +def test_full_url(settings, url, path, expected): + settings.FUNKWHALE_URL = url + assert utils.full_url(path) == expected + + +def test_extract_headers_from_meta(): + wsgi_headers = { + 'HTTP_HOST': 'nginx', + 'HTTP_X_REAL_IP': '172.20.0.4', + 'HTTP_X_FORWARDED_FOR': '188.165.228.227, 172.20.0.4', + 'HTTP_X_FORWARDED_PROTO': 'http', + 'HTTP_X_FORWARDED_HOST': 'localhost:80', + 'HTTP_X_FORWARDED_PORT': '80', + 'HTTP_CONNECTION': 'close', + 'CONTENT_LENGTH': '1155', + 'CONTENT_TYPE': 'txt/application', + 'HTTP_SIGNATURE': 'Hello', + 'HTTP_DATE': 'Sat, 31 Mar 2018 13:53:55 GMT', + 'HTTP_USER_AGENT': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'} + + cleaned_headers = utils.clean_wsgi_headers(wsgi_headers) + + expected = { + 'Host': 'nginx', + 'X-Real-Ip': '172.20.0.4', + 'X-Forwarded-For': '188.165.228.227, 172.20.0.4', + 'X-Forwarded-Proto': 'http', + 'X-Forwarded-Host': 'localhost:80', + 'X-Forwarded-Port': '80', + 'Connection': 'close', + 'Content-Length': '1155', + 'Content-Type': 'txt/application', + 'Signature': 'Hello', + 'Date': 'Sat, 31 Mar 2018 13:53:55 GMT', + 'User-Agent': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)' + } + assert cleaned_headers == expected diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..0d2ac882fb25ecac154a8426ffc2949ca7f81435 --- /dev/null +++ b/api/tests/federation/test_views.py @@ -0,0 +1,64 @@ +from django.urls import reverse + +import pytest + +from funkwhale_api.federation import actors +from funkwhale_api.federation import serializers +from funkwhale_api.federation import webfinger + + + +@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys()) +def test_instance_actors(system_actor, db, settings, api_client): + actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() + url = reverse( + 'federation:instance-actors-detail', + kwargs={'actor': system_actor}) + response = api_client.get(url) + serializer = serializers.ActorSerializer(actor) + + assert response.status_code == 200 + assert response.data == serializer.data + + +@pytest.mark.parametrize('route,kwargs', [ + ('instance-actors-outbox', {'actor': 'library'}), + ('instance-actors-inbox', {'actor': 'library'}), + ('instance-actors-detail', {'actor': 'library'}), + ('well-known-webfinger', {}), +]) +def test_instance_endpoints_405_if_federation_disabled( + authenticated_actor, db, settings, api_client, route, kwargs): + settings.FEDERATION_ENABLED = False + url = reverse('federation:{}'.format(route), kwargs=kwargs) + response = api_client.get(url) + + assert response.status_code == 405 + + +def test_wellknown_webfinger_validates_resource( + db, api_client, settings, mocker): + clean = mocker.spy(webfinger, 'clean_resource') + url = reverse('federation:well-known-webfinger') + response = api_client.get(url, data={'resource': 'something'}) + + clean.assert_called_once_with('something') + assert url == '/.well-known/webfinger' + assert response.status_code == 400 + assert response.data['errors']['resource'] == ( + 'Missing webfinger resource type' + ) + + +@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys()) +def test_wellknown_webfinger_system( + system_actor, db, api_client, settings, mocker): + actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() + url = reverse('federation:well-known-webfinger') + response = api_client.get( + url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)}) + serializer = serializers.ActorWebfingerSerializer(actor) + + assert response.status_code == 200 + assert response['Content-Type'] == 'application/jrd+json' + assert response.data == serializer.data diff --git a/api/tests/federation/test_webfinger.py b/api/tests/federation/test_webfinger.py new file mode 100644 index 0000000000000000000000000000000000000000..96258455ae6fe1f60e330d7768a58d6e94fc91fa --- /dev/null +++ b/api/tests/federation/test_webfinger.py @@ -0,0 +1,42 @@ +import pytest + +from django import forms +from django.urls import reverse + +from funkwhale_api.federation import webfinger + + +def test_webfinger_clean_resource(): + t, r = webfinger.clean_resource('acct:service@test.federation') + assert t == 'acct' + assert r == 'service@test.federation' + + +@pytest.mark.parametrize('resource,message', [ + ('', 'Invalid resource string'), + ('service@test.com', 'Missing webfinger resource type'), + ('noop:service@test.com', 'Invalid webfinger resource type'), +]) +def test_webfinger_clean_resource_errors(resource, message): + with pytest.raises(forms.ValidationError) as excinfo: + webfinger.clean_resource(resource) + + assert message == str(excinfo) + + +def test_webfinger_clean_acct(settings): + username, hostname = webfinger.clean_acct('library@test.federation') + assert username == 'library' + assert hostname == 'test.federation' + + +@pytest.mark.parametrize('resource,message', [ + ('service', 'Invalid format'), + ('service@test.com', 'Invalid hostname test.com'), + ('noop@test.federation', 'Invalid account'), +]) +def test_webfinger_clean_acct_errors(resource, message, settings): + with pytest.raises(forms.ValidationError) as excinfo: + webfinger.clean_resource(resource) + + assert message == str(excinfo) diff --git "a/api/tests/files/utf8-\303\251\303\240\342\227\214.ogg" "b/api/tests/files/utf8-\303\251\303\240\342\227\214.ogg" new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py index b5ab07b8235f12045f5ee1b9fba3fce0c3da57d5..04000604b264394ab7c3c1425e49a1f2b59bff71 100644 --- a/api/tests/history/test_activity.py +++ b/api/tests/history/test_activity.py @@ -23,7 +23,7 @@ def test_activity_listening_serializer(factories): "id": listening.get_activity_url(), "actor": actor, "object": TrackActivitySerializer(listening.track).data, - "published": field.to_representation(listening.end_date), + "published": field.to_representation(listening.creation_date), } data = serializers.ListeningActivitySerializer(listening).data diff --git a/api/tests/instance/__init__.py b/api/tests/instance/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/music/conftest.py b/api/tests/music/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..1d0fa4e38627ad7f0711082a0edd2c726f02b2e6 --- /dev/null +++ b/api/tests/music/conftest.py @@ -0,0 +1,566 @@ +import pytest + + +_artists = {'search': {}, 'get': {}} + +_artists['search']['adhesive_wombat'] = { + 'artist-list': [ + { + 'type': 'Person', + 'ext:score': '100', + 'id': '62c3befb-6366-4585-b256-809472333801', + 'disambiguation': 'George Shaw', + 'gender': 'male', + 'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'}, + 'sort-name': 'Wombat, Adhesive', + 'life-span': {'ended': 'false'}, + 'name': 'Adhesive Wombat' + }, + { + 'country': 'SE', + 'type': 'Group', + 'ext:score': '42', + 'id': '61b34e69-7573-4208-bc89-7061bca5a8fc', + 'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'}, + 'sort-name': 'Adhesive', + 'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'}, + 'name': 'Adhesive', + 'begin-area': { + 'sort-name': 'Katrineholm', + 'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f', + 'name': 'Katrineholm' + }, + }, + ] +} +_artists['get']['adhesive_wombat'] = {'artist': _artists['search']['adhesive_wombat']['artist-list'][0]} + +_artists['get']['soad'] = { + 'artist': { + 'country': 'US', + 'isni-list': ['0000000121055332'], + 'type': 'Group', + 'area': { + 'iso-3166-1-code-list': ['US'], + 'sort-name': 'United States', + 'id': '489ce91b-6658-3307-9877-795b68554c98', + 'name': 'United States' + }, + 'begin-area': { + 'sort-name': 'Glendale', + 'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373', + 'name': 'Glendale' + }, + 'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6', + 'life-span': {'begin': '1994'}, + 'sort-name': 'System of a Down', + 'name': 'System of a Down' + } +} + +_albums = {'search': {}, 'get': {}, 'get_with_includes': {}} +_albums['search']['hypnotize'] = { + 'release-list': [ + { + "artist-credit": [ + { + "artist": { + "alias-list": [ + { + "alias": "SoaD", + "sort-name": "SoaD", + "type": "Search hint" + }, + { + "alias": "S.O.A.D.", + "sort-name": "S.O.A.D.", + "type": "Search hint" + }, + { + "alias": "System Of Down", + "sort-name": "System Of Down", + "type": "Search hint" + } + ], + "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", + "name": "System of a Down", + "sort-name": "System of a Down" + } + } + ], + "artist-credit-phrase": "System of a Down", + "barcode": "", + "country": "US", + "date": "2005", + "ext:score": "100", + "id": "47ae093f-1607-49a3-be11-a15d335ccc94", + "label-info-list": [ + { + "catalog-number": "8-2796-93871-2", + "label": { + "id": "f5be9cfe-e1af-405c-a074-caeaed6797c0", + "name": "American Recordings" + } + }, + { + "catalog-number": "D162990", + "label": { + "id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f", + "name": "BMG Direct Marketing, Inc." + } + } + ], + "medium-count": 1, + "medium-list": [ + { + "disc-count": 1, + "disc-list": [], + "format": "CD", + "track-count": 12, + "track-list": [] + } + ], + "medium-track-count": 12, + "packaging": "Digipak", + "release-event-list": [ + { + "area": { + "id": "489ce91b-6658-3307-9877-795b68554c98", + "iso-3166-1-code-list": [ + "US" + ], + "name": "United States", + "sort-name": "United States" + }, + "date": "2005" + } + ], + "release-group": { + "id": "72035143-d6ec-308b-8ee5-070b8703902a", + "primary-type": "Album", + "type": "Album" + }, + "status": "Official", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "title": "Hypnotize" + }, + { + "artist-credit": [ + { + "artist": { + "alias-list": [ + { + "alias": "SoaD", + "sort-name": "SoaD", + "type": "Search hint" + }, + { + "alias": "S.O.A.D.", + "sort-name": "S.O.A.D.", + "type": "Search hint" + }, + { + "alias": "System Of Down", + "sort-name": "System Of Down", + "type": "Search hint" + } + ], + "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", + "name": "System of a Down", + "sort-name": "System of a Down" + } + } + ], + "artist-credit-phrase": "System of a Down", + "asin": "B000C6NRY8", + "barcode": "827969387115", + "country": "US", + "date": "2005-12-20", + "ext:score": "100", + "id": "8a4034a9-7834-3b7e-a6f0-d0791e3731fb", + "medium-count": 1, + "medium-list": [ + { + "disc-count": 0, + "disc-list": [], + "format": "Vinyl", + "track-count": 12, + "track-list": [] + } + ], + "medium-track-count": 12, + "release-event-list": [ + { + "area": { + "id": "489ce91b-6658-3307-9877-795b68554c98", + "iso-3166-1-code-list": [ + "US" + ], + "name": "United States", + "sort-name": "United States" + }, + "date": "2005-12-20" + } + ], + "release-group": { + "id": "72035143-d6ec-308b-8ee5-070b8703902a", + "primary-type": "Album", + "type": "Album" + }, + "status": "Official", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "title": "Hypnotize" + }, + ] +} +_albums['get']['hypnotize'] = {'release': _albums['search']['hypnotize']['release-list'][0]} +_albums['get_with_includes']['hypnotize'] = { + 'release': { + 'artist-credit': [ + {'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6', + 'name': 'System of a Down', + 'sort-name': 'System of a Down'}}], + 'artist-credit-phrase': 'System of a Down', + 'barcode': '', + 'country': 'US', + 'cover-art-archive': {'artwork': 'true', + 'back': 'false', + 'count': '1', + 'front': 'true'}, + 'date': '2005', + 'id': '47ae093f-1607-49a3-be11-a15d335ccc94', + 'medium-count': 1, + 'medium-list': [{'format': 'CD', + 'position': '1', + 'track-count': 12, + 'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3', + 'length': '186000', + 'number': '1', + 'position': '1', + 'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68', + 'length': '186000', + 'title': 'Attack'}, + 'track_or_recording_length': '186000'}, + {'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608', + 'length': '239000', + 'number': '2', + 'position': '2', + 'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a', + 'length': '239000', + 'title': 'Dreaming'}, + 'track_or_recording_length': '239000'}, + {'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f', + 'length': '147000', + 'number': '3', + 'position': '3', + 'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344', + 'length': '147000', + 'title': 'Kill Rock ’n Roll'}, + 'track_or_recording_length': '147000'}, + {'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25', + 'length': '189000', + 'number': '4', + 'position': '4', + 'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605', + 'length': '189000', + 'title': 'Hypnotize'}, + 'track_or_recording_length': '189000'}, + {'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32', + 'length': '178000', + 'number': '5', + 'position': '5', + 'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2', + 'length': '178000', + 'title': 'Stealing Society'}, + 'track_or_recording_length': '178000'}, + {'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2', + 'length': '216000', + 'number': '6', + 'position': '6', + 'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5', + 'length': '216000', + 'title': 'Tentative'}, + 'track_or_recording_length': '216000'}, + {'id': '265718ba-787f-3193-947b-3b6fa69ffe96', + 'length': '175000', + 'number': '7', + 'position': '7', + 'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120', + 'length': '175000', + 'title': 'Uâ€Fig'}, + 'title': 'U-Fig', + 'track_or_recording_length': '175000'}, + {'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a', + 'length': '328000', + 'number': '8', + 'position': '8', + 'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79', + 'length': '328000', + 'title': 'Holy Mountains'}, + 'track_or_recording_length': '328000'}, + {'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df', + 'length': '171000', + 'number': '9', + 'position': '9', + 'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa', + 'length': '171000', + 'title': 'Vicinity of Obscenity'}, + 'track_or_recording_length': '171000'}, + {'id': 'cdd45914-6741-353e-bbb5-d281048ff24f', + 'length': '164000', + 'number': '10', + 'position': '10', + 'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8', + 'length': '164000', + 'title': 'She’s Like Heroin'}, + 'track_or_recording_length': '164000'}, + {'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d', + 'length': '167000', + 'number': '11', + 'position': '11', + 'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378', + 'length': '167000', + 'title': 'Lonely Day'}, + 'track_or_recording_length': '167000'}, + {'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f', + 'length': '220000', + 'number': '12', + 'position': '12', + 'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88', + 'length': '220000', + 'title': 'Soldier Side'}, + 'track_or_recording_length': '220000'}]}], + 'packaging': 'Digipak', + 'quality': 'normal', + 'release-event-count': 1, + 'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98', + 'iso-3166-1-code-list': ['US'], + 'name': 'United States', + 'sort-name': 'United States'}, + 'date': '2005'}], + 'status': 'Official', + 'text-representation': {'language': 'eng', 'script': 'Latn'}, + 'title': 'Hypnotize'}} + +_albums['get']['marsupial'] = { + 'release': { + "artist-credit": [ + { + "artist": { + "disambiguation": "George Shaw", + "id": "62c3befb-6366-4585-b256-809472333801", + "name": "Adhesive Wombat", + "sort-name": "Wombat, Adhesive" + } + } + ], + "artist-credit-phrase": "Adhesive Wombat", + "country": "XW", + "cover-art-archive": { + "artwork": "true", + "back": "false", + "count": "1", + "front": "true" + }, + "date": "2013-06-05", + "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e", + "packaging": "None", + "quality": "normal", + "release-event-count": 1, + "release-event-list": [ + { + "area": { + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "iso-3166-1-code-list": [ + "XW" + ], + "name": "[Worldwide]", + "sort-name": "[Worldwide]" + }, + "date": "2013-06-05" + } + ], + "status": "Official", + "text-representation": { + "language": "eng", + "script": "Latn" + }, + "title": "Marsupial Madness" + } +} + +_tracks = {'search': {}, 'get': {}} + +_tracks['search']['8bitadventures'] = { + 'recording-list': [ + { + "artist-credit": [ + { + "artist": { + "disambiguation": "George Shaw", + "id": "62c3befb-6366-4585-b256-809472333801", + "name": "Adhesive Wombat", + "sort-name": "Wombat, Adhesive" + } + } + ], + "artist-credit-phrase": "Adhesive Wombat", + "ext:score": "100", + "id": "9968a9d6-8d92-4051-8f76-674e157b6eed", + "length": "271000", + "release-list": [ + { + "country": "XW", + "date": "2013-06-05", + "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e", + "medium-list": [ + { + "format": "Digital Media", + "position": "1", + "track-count": 11, + "track-list": [ + { + "id": "64d43604-c1ee-4f45-a02c-030672d2fe27", + "length": "271000", + "number": "1", + "title": "8-Bit Adventure", + "track_or_recording_length": "271000" + } + ] + } + ], + "medium-track-count": 11, + "release-event-list": [ + { + "area": { + "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", + "iso-3166-1-code-list": [ + "XW" + ], + "name": "[Worldwide]", + "sort-name": "[Worldwide]" + }, + "date": "2013-06-05" + } + ], + "release-group": { + "id": "447b4979-2178-405c-bfe6-46bf0b09e6c7", + "primary-type": "Album", + "type": "Album" + }, + "status": "Official", + "title": "Marsupial Madness" + } + ], + "title": "8-Bit Adventure", + "tag-list": [ + { + "count": "2", + "name": "techno" + }, + { + "count": "2", + "name": "good-music" + }, + ], + }, + ] +} + +_tracks['get']['8bitadventures'] = {'recording': _tracks['search']['8bitadventures']['recording-list'][0]} +_tracks['get']['chop_suey'] = { + 'recording': { + 'id': '46c7368a-013a-47b6-97cc-e55e7ab25213', + 'length': '210240', + 'title': 'Chop Suey!', + 'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', + 'type': 'performance', + 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0', + 'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', + 'language': 'eng', + 'title': 'Chop Suey!'}}]}} + +_works = {'search': {}, 'get': {}} +_works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', + 'language': 'eng', + 'recording-relation-list': [{'direction': 'backward', + 'recording': {'disambiguation': 'edit', + 'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448', + 'length': '170893', + 'title': 'Chop Suey!'}, + 'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448', + 'type': 'performance', + 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'}, + ], + 'title': 'Chop Suey!', + 'type': 'Song', + 'url-relation-list': [{'direction': 'backward', + 'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!', + 'type': 'lyrics', + 'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}} + + +@pytest.fixture() +def artists(): + return _artists + + +@pytest.fixture() +def albums(): + return _albums + + +@pytest.fixture() +def tracks(): + return _tracks + + +@pytest.fixture() +def works(): + return _works + + +@pytest.fixture() +def lyricswiki_content(): + return """<!doctype html> +<html lang="en" dir="ltr"> +<head> + +<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"> +<meta name="generator" content="MediaWiki 1.19.24" /> +<meta name="keywords" content="Chop Suey! lyrics,System Of A Down Chop Suey! lyrics,Chop Suey! by System Of A Down lyrics,lyrics,LyricWiki,LyricWikia,lyricwiki,System Of A Down:Chop Suey!,System Of A Down,System Of A Down:Toxicity (2001),Enter Shikari,Enter Shikari:Chop Suey!,"Weird Al" Yankovic,"Weird Al" Yankovic:Angry White Boy Polka,Renard,Renard:Physicality,System Of A Down:Chop Suey!/pt,Daron Malakian" /> +<meta name="description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." /> +<meta name="twitter:card" content="summary" /> +<meta name="twitter:site" content="@Wikia" /> +<meta name="twitter:url" content="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" /> +<meta name="twitter:title" content="System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia" /> +<meta name="twitter:description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." /> +<link rel="canonical" href="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" /> +<link rel="alternate" type="application/x-wiki" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" /> +<link rel="edit" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" /> +<link rel="apple-touch-icon" href="http://img4.wikia.nocookie.net/__cb22/lyricwiki/images/b/bc/Wiki.png" /> +<link rel="shortcut icon" href="http://slot1.images.wikia.nocookie.net/__cb1474018633/common/skins/common/images/favicon.ico" /> +<link rel="search" type="application/opensearchdescription+xml" href="/opensearch_desc.php" title="LyricWikia (en)" /> +<link rel="EditURI" type="application/rsd+xml" href="http://lyrics.wikia.com/api.php?action=rsd" /> +<link rel="copyright" href="/wiki/LyricWiki:Copyrights" /> +<link rel="alternate" type="application/atom+xml" title="LyricWikia Atom feed" href="/wiki/Special:RecentChanges?feed=atom" /> +<title>System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia</title> + +<body> +<div class='lyricbox'> +<i>We're rolling "Suicide".</i><br /><br />Wake up <i>(wake up)</i><br />Grab a brush and put on a little makeup<br />Hide the scars to fade away the shakeup <i>(hide the scars to fade away the)</i><br />Why'd you leave the keys upon the table?<br />Here you go, create another fable<br /><br />You wanted to<br />Grab a brush and put a little makeup<br />You wanted to<br />Hide the scars to fade away the shakeup<br />You wanted to<br />Why'd you leave the keys upon the table?<br />You wanted to<br /><br />I don't think you trust<br />In my self-righteous suicide<br />I cry when angels deserve to die<br /><br />Wake up <i>(wake up)</i><br />Grab a brush and put on a little makeup<br />Hide the scars to fade away the <i>(hide the scars to fade away the)</i><br />Why'd you leave the keys upon the table?<br />Here you go, create another fable<br /><br />You wanted to<br />Grab a brush and put a little makeup<br />You wanted to<br />Hide the scars to fade away the shakeup<br />You wanted to<br />Why'd you leave the keys upon the table?<br />You wanted to<br /><br />I don't think you trust<br />In my self-righteous suicide<br />I cry when angels deserve to die<br />In my self-righteous suicide<br />I cry when angels deserve to die<br /><br />Father <i>(father)</i><br />Father <i>(father)</i><br />Father <i>(father)</i><br />Father <i>(father)</i><br />Father, into your hands I commit my spirit<br />Father, into your hands<br /><br />Why have you forsaken me?<br />In your eyes forsaken me<br />In your thoughts forsaken me<br />In your heart forsaken me, oh<br /><br />Trust in my self-righteous suicide<br />I cry when angels deserve to die<br />In my self-righteous suicide<br />I cry when angels deserve to die +</div> +</body> +</html>""" + + +@pytest.fixture() +def binary_cover(): + return b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xed\x08\xaePhotoshop 3.0\x008BIM\x03\xe9\x00\x00\x00\x00\x00x\x00\x03\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\xff\xe1\xff\xe2\x02\xf9\x02F\x03G\x05(\x03\xfc\x00\x02\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\x00\x01\x00\x00\x00d\x00\x00\x00\x01\x00\x03\x03\x03\x00\x00\x00\x01\'\x0f\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x08\x00\x19\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x008BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00H\x00\x00\x00\x01\x00\x01\x00H\x00\x00\x00\x01\x00\x018BIM\x03\xf3\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x008BIM\x04\n\x00\x00\x00\x00\x00\x01\x00\x008BIM\'\x10\x00\x00\x00\x00\x00\n\x00\x01\x00\x00\x00\x00\x00\x00\x00\x028BIM\x03\xf5\x00\x00\x00\x00\x00H\x00/ff\x00\x01\x00lff\x00\x06\x00\x00\x00\x00\x00\x01\x00/ff\x00\x01\x00\xa1\x99\x9a\x00\x06\x00\x00\x00\x00\x00\x01\x002\x00\x00\x00\x01\x00Z\x00\x00\x00\x06\x00\x00\x00\x00\x00\x01\x005\x00\x00\x00\x01\x00-\x00\x00\x00\x06\x00\x00\x00\x00\x00\x018BIM\x03\xf8\x00\x00\x00\x00\x00p\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x008BIM\x04\x00\x00\x00\x00\x00\x00\x02\x00\x018BIM\x04\x02\x00\x00\x00\x00\x00\x04\x00\x00\x00\x008BIM\x04\x08\x00\x00\x00\x00\x00\x10\x00\x00\x00\x01\x00\x00\x02@\x00\x00\x02@\x00\x00\x00\x008BIM\x04\t\x00\x00\x00\x00\x06\x9b\x00\x00\x00\x01\x00\x00\x00\x80\x00\x00\x00\x80\x00\x00\x01\x80\x00\x00\xc0\x00\x00\x00\x06\x7f\x00\x18\x00\x01\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x80\x00\x00\x00\x01\xff\xdb\x00\x84\x00\x0c\x08\x08\x08\t\x08\x0c\t\t\x0c\x11\x0b\n\x0b\x11\x15\x0f\x0c\x0c\x0f\x15\x18\x13\x13\x15\x13\x13\x18\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\r\x0b\x0b\r\x0e\r\x10\x0e\x0e\x10\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\x80\x00\x80\x03\x01"\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x08\xff\xc4\x01?\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x01\x02\x04\x05\x06\x07\x08\t\n\x0b\x01\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x01\x04\x01\x03\x02\x04\x02\x05\x07\x06\x08\x05\x03\x0c3\x01\x00\x02\x11\x03\x04!\x121\x05AQa\x13"q\x812\x06\x14\x91\xa1\xb1B#$\x15R\xc1b34r\x82\xd1C\x07%\x92S\xf0\xe1\xf1cs5\x16\xa2\xb2\x83&D\x93TdE\xc2\xa3t6\x17\xd2U\xe2e\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\'\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf7\x11\x00\x02\x02\x01\x02\x04\x04\x03\x04\x05\x06\x07\x07\x06\x055\x01\x00\x02\x11\x03!1\x12\x04AQaq"\x13\x052\x81\x91\x14\xa1\xb1B#\xc1R\xd1\xf03$b\xe1r\x82\x92CS\x15cs4\xf1%\x06\x16\xa2\xb2\x83\x07&5\xc2\xd2D\x93T\xa3\x17dEU6te\xe2\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6\'7GWgw\x87\x97\xa7\xb7\xc7\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd0\xf5T\x92I%)$\x92IJI%\xe7\xff\x00Z\x7f\xc6\xbf\xfc\xde\xeb\xb9]\x1f\xf6_\xda~\xcd\xe9\xfe\x9b\xed\x1e\x9e\xefR\xba\xef\xfeo\xec\xf6\xed\xdb\xea\xec\xfeq%>\x80\x92\xf2\xaf\xfc}?\xf3I\xff\x00\xb3_\xfb\xe8\x97\xfe>\x9f\xf9\xa4\xff\x00\xd9\xaf\xfd\xf4IO\xaa\xa4\xbc\xab\xff\x00\x1fO\xfc\xd2\x7f\xec\xd7\xfe\xfa%\xff\x00\x8f\xa7\xfei?\xf6k\xff\x00}\x12S\xea\xa9.+\xeaW\xf8\xc8\xff\x00\x9d}V\xde\x9d\xfb;\xec~\x96;\xb2=O[\xd5\x9d\xaf\xaa\xad\x9b=\n\x7f\xd3}-\xeb\xb5IJI$\x92R\x92I$\x94\xff\x00\xff\xd1\xf5T\x92I%)$\x97\x9f\xff\x00\x8d\x7f\xad=w\xea\xf7\xec\xbf\xd8\xf9_f\xfbO\xda=o\xd1\xd7f\xefO\xec\xfe\x9f\xf3\xf5\xdb\xb7o\xabg\xd0IO\xa0/\x9f\xff\x00\xc6\x97\xfe.\xfa\x9f\xfdc\xff\x00m\xf1\xd2\xff\x00\xc7K\xeb\xdf\xfeY\xff\x00\xe0\x18\xff\x00\xfb\xce\xb9\xfe\xa9\xd53\xfa\xbe}\xbdG\xa8\xdb\xeb\xe5\xdf\xb7\xd4\xb3kY;\x1a\xda\x99\xec\xa9\xac\xaf\xf9\xb63\xf3\x12SU$\x92IJI$\x92S\xdf\xff\x00\x89O\xfcUe\x7f\xe1\x0b?\xf3\xf6*\xf6\xb5\xf3/D\xeb\xfd[\xa0\xe5?3\xa4\xdf\xf6l\x8b+59\xfb\x18\xf9a-\xb1\xcd\xdb{-g\xd3\xa9\x8bk\xff\x00\x1d/\xaf\x7f\xf9g\xff\x00\x80c\xff\x00\xef:J~\x80Iq\xff\x00\xe2\xbf\xaf\xf5n\xbd\xd023:\xb5\xff\x00i\xc8\xaf-\xf55\xfb\x18\xc8`\xae\x8b\x1a\xdd\xb42\xa6};^\xbb\x04\x94\xa4\x92I%?\xff\xd2\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd3\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd4\xf5T\x92I%)q_\xe3#\xeaWU\xfa\xd7\xfb;\xf6u\xb8\xf5}\x8f\xd6\xf5>\xd0\xe7\xb6}_Cf\xcfJ\xab\xbf\xd0\xbfr\xedRIO\x8a\x7f\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*\xe4:\xff\x00D\xca\xe8=Z\xfe\x93\x98\xfa\xec\xc8\xc6\xd9\xbd\xd5\x12Xw\xb1\x97\xb7k\xacmO\xfa\x16\xfe\xe2\xfai|\xff\x00\xfe4\xbf\xf1w\xd4\xff\x00\xeb\x1f\xfbo\x8e\x92\x9eU$\x92IJI$\x92S\xb1\xf5_\xea\xbfP\xfa\xd1\xd4,\xc0\xc0\xb2\x9a\xad\xaa\x93{\x9dys[\xb5\xae\xae\xa2\x01\xaa\xbb\x9d\xbfu\xcd\xfc\xd5\xd3\xff\x00\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*_\xe2S\xff\x00\x15Y_\xf8B\xcf\xfc\xfd\x8a\xbd\xad%<\xbf\xf8\xbc\xfa\xaf\xd4>\xab\xf4[\xb03\xec\xa6\xdbm\xc9u\xedu\x05\xcen\xd7WM@\x13mt\xbb~\xea]\xf9\xab\xa8I$\x94\xa4\x92I%?\xff\xd5\xf5T\x92I%)$\x92IJ\\\x7f_\xff\x00\x15\xfd\x03\xafuk\xfa\xb6fF]y\x19;7\xb6\xa7\xd6\x1861\x947kl\xa2\xd7\xfd\n\xbf}v\t$\xa7\xcf\xff\x00\xf1\x94\xfa\xab\xff\x00r\xb3\xff\x00\xed\xca\x7f\xf7\x95/\xfce>\xaa\xff\x00\xdc\xac\xff\x00\xfbr\x9f\xfd\xe5^\x80\x92J|\xff\x00\xff\x00\x19O\xaa\xbf\xf7+?\xfe\xdc\xa7\xff\x00yR\xff\x00\xc6S\xea\xaf\xfd\xca\xcf\xff\x00\xb7)\xff\x00\xdeU\xe8\t$\xa7\x97\xfa\xaf\xfe/:/\xd5~\xa1f~\x05\xd96\xdbm&\x876\xf7V\xe6\xeds\xab\xb4\x90*\xa6\x97o\xddK\x7f9u\t$\x92\x94\x92I$\xa5$\x92I)\xff\xd6\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd9\x008BIM\x04\x06\x00\x00\x00\x00\x00\x07\x00\x03\x00\x00\x00\x01\x01\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x00\x00\x00\x00\x01\xff\xdb\x00\x84\x00\n\x07\x07\x07\x08\x07\n\x08\x08\n\x0f\n\x08\n\x0f\x12\r\n\n\r\x12\x14\x10\x10\x12\x10\x10\x14\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\x0b\x0c\x0c\x15\x13\x15"\x18\x18"\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\t\x00\t\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x02\xff\xc4\x01\xa2\x00\x00\x00\x07\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x03\x02\x06\x01\x00\x07\x08\t\n\x0b\x01\x00\x02\x02\x03\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x02\x01\x03\x03\x02\x04\x02\x06\x07\x03\x04\x02\x06\x02s\x01\x02\x03\x11\x04\x00\x05!\x121AQ\x06\x13a"q\x81\x142\x91\xa1\x07\x15\xb1B#\xc1R\xd1\xe13\x16b\xf0$r\x82\xf1%C4S\x92\xa2\xb2cs\xc25D\'\x93\xa3\xb36\x17Tdt\xc3\xd2\xe2\x08&\x83\t\n\x18\x19\x84\x94EF\xa4\xb4V\xd3U(\x1a\xf2\xe3\xf3\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5fv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf8)9IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\x11\x00\x02\x02\x01\x02\x03\x05\x05\x04\x05\x06\x04\x08\x03\x03m\x01\x00\x02\x11\x03\x04!\x121A\x05Q\x13a"\x06q\x81\x912\xa1\xb1\xf0\x14\xc1\xd1\xe1#B\x15Rbr\xf13$4C\x82\x16\x92S%\xa2c\xb2\xc2\x07s\xd25\xe2D\x83\x17T\x93\x08\t\n\x18\x19&6E\x1a\'dtU7\xf2\xa3\xb3\xc3()\xd3\xe3\xf3\x84\x94\xa4\xb4\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5FVfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf89IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x91\xea\xfa\xbf\xe6D_\x99\x16\x96\x16\x16\x8c\xdeWf\x84;\x88U\xa1hY\x7f\xd3\'\x9e\xf3\xedCq\x0bz\xfe\x94^\xbc?\xdc\xdb\xff\x00\xa3\xcd\xeb\x7f\xa4\xaa\xf4<U\xff\xd0\xec\xd8\xab\xb1W\xff\xd9' diff --git a/api/tests/music/cover.py b/api/tests/music/cover.py deleted file mode 100644 index 401bc105227acc0fd2f2265ef899604b138475a3..0000000000000000000000000000000000000000 --- a/api/tests/music/cover.py +++ /dev/null @@ -1 +0,0 @@ -binary_data = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xed\x08\xaePhotoshop 3.0\x008BIM\x03\xe9\x00\x00\x00\x00\x00x\x00\x03\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\xff\xe1\xff\xe2\x02\xf9\x02F\x03G\x05(\x03\xfc\x00\x02\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\x00\x01\x00\x00\x00d\x00\x00\x00\x01\x00\x03\x03\x03\x00\x00\x00\x01\'\x0f\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x08\x00\x19\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x008BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00H\x00\x00\x00\x01\x00\x01\x00H\x00\x00\x00\x01\x00\x018BIM\x03\xf3\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x008BIM\x04\n\x00\x00\x00\x00\x00\x01\x00\x008BIM\'\x10\x00\x00\x00\x00\x00\n\x00\x01\x00\x00\x00\x00\x00\x00\x00\x028BIM\x03\xf5\x00\x00\x00\x00\x00H\x00/ff\x00\x01\x00lff\x00\x06\x00\x00\x00\x00\x00\x01\x00/ff\x00\x01\x00\xa1\x99\x9a\x00\x06\x00\x00\x00\x00\x00\x01\x002\x00\x00\x00\x01\x00Z\x00\x00\x00\x06\x00\x00\x00\x00\x00\x01\x005\x00\x00\x00\x01\x00-\x00\x00\x00\x06\x00\x00\x00\x00\x00\x018BIM\x03\xf8\x00\x00\x00\x00\x00p\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x008BIM\x04\x00\x00\x00\x00\x00\x00\x02\x00\x018BIM\x04\x02\x00\x00\x00\x00\x00\x04\x00\x00\x00\x008BIM\x04\x08\x00\x00\x00\x00\x00\x10\x00\x00\x00\x01\x00\x00\x02@\x00\x00\x02@\x00\x00\x00\x008BIM\x04\t\x00\x00\x00\x00\x06\x9b\x00\x00\x00\x01\x00\x00\x00\x80\x00\x00\x00\x80\x00\x00\x01\x80\x00\x00\xc0\x00\x00\x00\x06\x7f\x00\x18\x00\x01\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x80\x00\x00\x00\x01\xff\xdb\x00\x84\x00\x0c\x08\x08\x08\t\x08\x0c\t\t\x0c\x11\x0b\n\x0b\x11\x15\x0f\x0c\x0c\x0f\x15\x18\x13\x13\x15\x13\x13\x18\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\r\x0b\x0b\r\x0e\r\x10\x0e\x0e\x10\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\x80\x00\x80\x03\x01"\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x08\xff\xc4\x01?\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x01\x02\x04\x05\x06\x07\x08\t\n\x0b\x01\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x01\x04\x01\x03\x02\x04\x02\x05\x07\x06\x08\x05\x03\x0c3\x01\x00\x02\x11\x03\x04!\x121\x05AQa\x13"q\x812\x06\x14\x91\xa1\xb1B#$\x15R\xc1b34r\x82\xd1C\x07%\x92S\xf0\xe1\xf1cs5\x16\xa2\xb2\x83&D\x93TdE\xc2\xa3t6\x17\xd2U\xe2e\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\'\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf7\x11\x00\x02\x02\x01\x02\x04\x04\x03\x04\x05\x06\x07\x07\x06\x055\x01\x00\x02\x11\x03!1\x12\x04AQaq"\x13\x052\x81\x91\x14\xa1\xb1B#\xc1R\xd1\xf03$b\xe1r\x82\x92CS\x15cs4\xf1%\x06\x16\xa2\xb2\x83\x07&5\xc2\xd2D\x93T\xa3\x17dEU6te\xe2\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6\'7GWgw\x87\x97\xa7\xb7\xc7\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd0\xf5T\x92I%)$\x92IJI%\xe7\xff\x00Z\x7f\xc6\xbf\xfc\xde\xeb\xb9]\x1f\xf6_\xda~\xcd\xe9\xfe\x9b\xed\x1e\x9e\xefR\xba\xef\xfeo\xec\xf6\xed\xdb\xea\xec\xfeq%>\x80\x92\xf2\xaf\xfc}?\xf3I\xff\x00\xb3_\xfb\xe8\x97\xfe>\x9f\xf9\xa4\xff\x00\xd9\xaf\xfd\xf4IO\xaa\xa4\xbc\xab\xff\x00\x1fO\xfc\xd2\x7f\xec\xd7\xfe\xfa%\xff\x00\x8f\xa7\xfei?\xf6k\xff\x00}\x12S\xea\xa9.+\xeaW\xf8\xc8\xff\x00\x9d}V\xde\x9d\xfb;\xec~\x96;\xb2=O[\xd5\x9d\xaf\xaa\xad\x9b=\n\x7f\xd3}-\xeb\xb5IJI$\x92R\x92I$\x94\xff\x00\xff\xd1\xf5T\x92I%)$\x97\x9f\xff\x00\x8d\x7f\xad=w\xea\xf7\xec\xbf\xd8\xf9_f\xfbO\xda=o\xd1\xd7f\xefO\xec\xfe\x9f\xf3\xf5\xdb\xb7o\xabg\xd0IO\xa0/\x9f\xff\x00\xc6\x97\xfe.\xfa\x9f\xfdc\xff\x00m\xf1\xd2\xff\x00\xc7K\xeb\xdf\xfeY\xff\x00\xe0\x18\xff\x00\xfb\xce\xb9\xfe\xa9\xd53\xfa\xbe}\xbdG\xa8\xdb\xeb\xe5\xdf\xb7\xd4\xb3kY;\x1a\xda\x99\xec\xa9\xac\xaf\xf9\xb63\xf3\x12SU$\x92IJI$\x92S\xdf\xff\x00\x89O\xfcUe\x7f\xe1\x0b?\xf3\xf6*\xf6\xb5\xf3/D\xeb\xfd[\xa0\xe5?3\xa4\xdf\xf6l\x8b+59\xfb\x18\xf9a-\xb1\xcd\xdb{-g\xd3\xa9\x8bk\xff\x00\x1d/\xaf\x7f\xf9g\xff\x00\x80c\xff\x00\xef:J~\x80Iq\xff\x00\xe2\xbf\xaf\xf5n\xbd\xd023:\xb5\xff\x00i\xc8\xaf-\xf55\xfb\x18\xc8`\xae\x8b\x1a\xdd\xb42\xa6};^\xbb\x04\x94\xa4\x92I%?\xff\xd2\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd3\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd4\xf5T\x92I%)q_\xe3#\xeaWU\xfa\xd7\xfb;\xf6u\xb8\xf5}\x8f\xd6\xf5>\xd0\xe7\xb6}_Cf\xcfJ\xab\xbf\xd0\xbfr\xedRIO\x8a\x7f\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*\xe4:\xff\x00D\xca\xe8=Z\xfe\x93\x98\xfa\xec\xc8\xc6\xd9\xbd\xd5\x12Xw\xb1\x97\xb7k\xacmO\xfa\x16\xfe\xe2\xfai|\xff\x00\xfe4\xbf\xf1w\xd4\xff\x00\xeb\x1f\xfbo\x8e\x92\x9eU$\x92IJI$\x92S\xb1\xf5_\xea\xbfP\xfa\xd1\xd4,\xc0\xc0\xb2\x9a\xad\xaa\x93{\x9dys[\xb5\xae\xae\xa2\x01\xaa\xbb\x9d\xbfu\xcd\xfc\xd5\xd3\xff\x00\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*_\xe2S\xff\x00\x15Y_\xf8B\xcf\xfc\xfd\x8a\xbd\xad%<\xbf\xf8\xbc\xfa\xaf\xd4>\xab\xf4[\xb03\xec\xa6\xdbm\xc9u\xedu\x05\xcen\xd7WM@\x13mt\xbb~\xea]\xf9\xab\xa8I$\x94\xa4\x92I%?\xff\xd5\xf5T\x92I%)$\x92IJ\\\x7f_\xff\x00\x15\xfd\x03\xafuk\xfa\xb6fF]y\x19;7\xb6\xa7\xd6\x1861\x947kl\xa2\xd7\xfd\n\xbf}v\t$\xa7\xcf\xff\x00\xf1\x94\xfa\xab\xff\x00r\xb3\xff\x00\xed\xca\x7f\xf7\x95/\xfce>\xaa\xff\x00\xdc\xac\xff\x00\xfbr\x9f\xfd\xe5^\x80\x92J|\xff\x00\xff\x00\x19O\xaa\xbf\xf7+?\xfe\xdc\xa7\xff\x00yR\xff\x00\xc6S\xea\xaf\xfd\xca\xcf\xff\x00\xb7)\xff\x00\xdeU\xe8\t$\xa7\x97\xfa\xaf\xfe/:/\xd5~\xa1f~\x05\xd96\xdbm&\x876\xf7V\xe6\xeds\xab\xb4\x90*\xa6\x97o\xddK\x7f9u\t$\x92\x94\x92I$\xa5$\x92I)\xff\xd6\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd9\x008BIM\x04\x06\x00\x00\x00\x00\x00\x07\x00\x03\x00\x00\x00\x01\x01\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x00\x00\x00\x00\x01\xff\xdb\x00\x84\x00\n\x07\x07\x07\x08\x07\n\x08\x08\n\x0f\n\x08\n\x0f\x12\r\n\n\r\x12\x14\x10\x10\x12\x10\x10\x14\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\x0b\x0c\x0c\x15\x13\x15"\x18\x18"\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\t\x00\t\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x02\xff\xc4\x01\xa2\x00\x00\x00\x07\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x03\x02\x06\x01\x00\x07\x08\t\n\x0b\x01\x00\x02\x02\x03\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x02\x01\x03\x03\x02\x04\x02\x06\x07\x03\x04\x02\x06\x02s\x01\x02\x03\x11\x04\x00\x05!\x121AQ\x06\x13a"q\x81\x142\x91\xa1\x07\x15\xb1B#\xc1R\xd1\xe13\x16b\xf0$r\x82\xf1%C4S\x92\xa2\xb2cs\xc25D\'\x93\xa3\xb36\x17Tdt\xc3\xd2\xe2\x08&\x83\t\n\x18\x19\x84\x94EF\xa4\xb4V\xd3U(\x1a\xf2\xe3\xf3\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5fv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf8)9IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\x11\x00\x02\x02\x01\x02\x03\x05\x05\x04\x05\x06\x04\x08\x03\x03m\x01\x00\x02\x11\x03\x04!\x121A\x05Q\x13a"\x06q\x81\x912\xa1\xb1\xf0\x14\xc1\xd1\xe1#B\x15Rbr\xf13$4C\x82\x16\x92S%\xa2c\xb2\xc2\x07s\xd25\xe2D\x83\x17T\x93\x08\t\n\x18\x19&6E\x1a\'dtU7\xf2\xa3\xb3\xc3()\xd3\xe3\xf3\x84\x94\xa4\xb4\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5FVfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf89IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x91\xea\xfa\xbf\xe6D_\x99\x16\x96\x16\x16\x8c\xdeWf\x84;\x88U\xa1hY\x7f\xd3\'\x9e\xf3\xedCq\x0bz\xfe\x94^\xbc?\xdc\xdb\xff\x00\xa3\xcd\xeb\x7f\xa4\xaa\xf4<U\xff\xd0\xec\xd8\xab\xb1W\xff\xd9' diff --git a/api/tests/music/data.py b/api/tests/music/data.py deleted file mode 100644 index 54da6bc846190992dd2bd24fde94aaeced0b1a63..0000000000000000000000000000000000000000 --- a/api/tests/music/data.py +++ /dev/null @@ -1,502 +0,0 @@ -artists = {'search': {}, 'get': {}} -artists['search']['adhesive_wombat'] = { - 'artist-list': [ - { - 'type': 'Person', - 'ext:score': '100', - 'id': '62c3befb-6366-4585-b256-809472333801', - 'disambiguation': 'George Shaw', - 'gender': 'male', - 'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'}, - 'sort-name': 'Wombat, Adhesive', - 'life-span': {'ended': 'false'}, - 'name': 'Adhesive Wombat' - }, - { - 'country': 'SE', - 'type': 'Group', - 'ext:score': '42', - 'id': '61b34e69-7573-4208-bc89-7061bca5a8fc', - 'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'}, - 'sort-name': 'Adhesive', - 'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'}, - 'name': 'Adhesive', - 'begin-area': { - 'sort-name': 'Katrineholm', - 'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f', - 'name': 'Katrineholm' - }, - }, - ] -} -artists['get']['adhesive_wombat'] = {'artist': artists['search']['adhesive_wombat']['artist-list'][0]} - -artists['get']['soad'] = { - 'artist': { - 'country': 'US', - 'isni-list': ['0000000121055332'], - 'type': 'Group', - 'area': { - 'iso-3166-1-code-list': ['US'], - 'sort-name': 'United States', - 'id': '489ce91b-6658-3307-9877-795b68554c98', - 'name': 'United States' - }, - 'begin-area': { - 'sort-name': 'Glendale', - 'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373', - 'name': 'Glendale' - }, - 'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6', - 'life-span': {'begin': '1994'}, - 'sort-name': 'System of a Down', - 'name': 'System of a Down' - } -} - -albums = {'search': {}, 'get': {}, 'get_with_includes': {}} -albums['search']['hypnotize'] = { - 'release-list': [ - { - "artist-credit": [ - { - "artist": { - "alias-list": [ - { - "alias": "SoaD", - "sort-name": "SoaD", - "type": "Search hint" - }, - { - "alias": "S.O.A.D.", - "sort-name": "S.O.A.D.", - "type": "Search hint" - }, - { - "alias": "System Of Down", - "sort-name": "System Of Down", - "type": "Search hint" - } - ], - "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", - "name": "System of a Down", - "sort-name": "System of a Down" - } - } - ], - "artist-credit-phrase": "System of a Down", - "barcode": "", - "country": "US", - "date": "2005", - "ext:score": "100", - "id": "47ae093f-1607-49a3-be11-a15d335ccc94", - "label-info-list": [ - { - "catalog-number": "8-2796-93871-2", - "label": { - "id": "f5be9cfe-e1af-405c-a074-caeaed6797c0", - "name": "American Recordings" - } - }, - { - "catalog-number": "D162990", - "label": { - "id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f", - "name": "BMG Direct Marketing, Inc." - } - } - ], - "medium-count": 1, - "medium-list": [ - { - "disc-count": 1, - "disc-list": [], - "format": "CD", - "track-count": 12, - "track-list": [] - } - ], - "medium-track-count": 12, - "packaging": "Digipak", - "release-event-list": [ - { - "area": { - "id": "489ce91b-6658-3307-9877-795b68554c98", - "iso-3166-1-code-list": [ - "US" - ], - "name": "United States", - "sort-name": "United States" - }, - "date": "2005" - } - ], - "release-group": { - "id": "72035143-d6ec-308b-8ee5-070b8703902a", - "primary-type": "Album", - "type": "Album" - }, - "status": "Official", - "text-representation": { - "language": "eng", - "script": "Latn" - }, - "title": "Hypnotize" - }, - { - "artist-credit": [ - { - "artist": { - "alias-list": [ - { - "alias": "SoaD", - "sort-name": "SoaD", - "type": "Search hint" - }, - { - "alias": "S.O.A.D.", - "sort-name": "S.O.A.D.", - "type": "Search hint" - }, - { - "alias": "System Of Down", - "sort-name": "System Of Down", - "type": "Search hint" - } - ], - "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", - "name": "System of a Down", - "sort-name": "System of a Down" - } - } - ], - "artist-credit-phrase": "System of a Down", - "asin": "B000C6NRY8", - "barcode": "827969387115", - "country": "US", - "date": "2005-12-20", - "ext:score": "100", - "id": "8a4034a9-7834-3b7e-a6f0-d0791e3731fb", - "medium-count": 1, - "medium-list": [ - { - "disc-count": 0, - "disc-list": [], - "format": "Vinyl", - "track-count": 12, - "track-list": [] - } - ], - "medium-track-count": 12, - "release-event-list": [ - { - "area": { - "id": "489ce91b-6658-3307-9877-795b68554c98", - "iso-3166-1-code-list": [ - "US" - ], - "name": "United States", - "sort-name": "United States" - }, - "date": "2005-12-20" - } - ], - "release-group": { - "id": "72035143-d6ec-308b-8ee5-070b8703902a", - "primary-type": "Album", - "type": "Album" - }, - "status": "Official", - "text-representation": { - "language": "eng", - "script": "Latn" - }, - "title": "Hypnotize" - }, - ] -} -albums['get']['hypnotize'] = {'release': albums['search']['hypnotize']['release-list'][0]} -albums['get_with_includes']['hypnotize'] = { - 'release': { - 'artist-credit': [ - {'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6', - 'name': 'System of a Down', - 'sort-name': 'System of a Down'}}], - 'artist-credit-phrase': 'System of a Down', - 'barcode': '', - 'country': 'US', - 'cover-art-archive': {'artwork': 'true', - 'back': 'false', - 'count': '1', - 'front': 'true'}, - 'date': '2005', - 'id': '47ae093f-1607-49a3-be11-a15d335ccc94', - 'medium-count': 1, - 'medium-list': [{'format': 'CD', - 'position': '1', - 'track-count': 12, - 'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3', - 'length': '186000', - 'number': '1', - 'position': '1', - 'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68', - 'length': '186000', - 'title': 'Attack'}, - 'track_or_recording_length': '186000'}, - {'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608', - 'length': '239000', - 'number': '2', - 'position': '2', - 'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a', - 'length': '239000', - 'title': 'Dreaming'}, - 'track_or_recording_length': '239000'}, - {'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f', - 'length': '147000', - 'number': '3', - 'position': '3', - 'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344', - 'length': '147000', - 'title': 'Kill Rock ’n Roll'}, - 'track_or_recording_length': '147000'}, - {'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25', - 'length': '189000', - 'number': '4', - 'position': '4', - 'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605', - 'length': '189000', - 'title': 'Hypnotize'}, - 'track_or_recording_length': '189000'}, - {'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32', - 'length': '178000', - 'number': '5', - 'position': '5', - 'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2', - 'length': '178000', - 'title': 'Stealing Society'}, - 'track_or_recording_length': '178000'}, - {'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2', - 'length': '216000', - 'number': '6', - 'position': '6', - 'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5', - 'length': '216000', - 'title': 'Tentative'}, - 'track_or_recording_length': '216000'}, - {'id': '265718ba-787f-3193-947b-3b6fa69ffe96', - 'length': '175000', - 'number': '7', - 'position': '7', - 'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120', - 'length': '175000', - 'title': 'Uâ€Fig'}, - 'title': 'U-Fig', - 'track_or_recording_length': '175000'}, - {'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a', - 'length': '328000', - 'number': '8', - 'position': '8', - 'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79', - 'length': '328000', - 'title': 'Holy Mountains'}, - 'track_or_recording_length': '328000'}, - {'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df', - 'length': '171000', - 'number': '9', - 'position': '9', - 'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa', - 'length': '171000', - 'title': 'Vicinity of Obscenity'}, - 'track_or_recording_length': '171000'}, - {'id': 'cdd45914-6741-353e-bbb5-d281048ff24f', - 'length': '164000', - 'number': '10', - 'position': '10', - 'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8', - 'length': '164000', - 'title': 'She’s Like Heroin'}, - 'track_or_recording_length': '164000'}, - {'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d', - 'length': '167000', - 'number': '11', - 'position': '11', - 'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378', - 'length': '167000', - 'title': 'Lonely Day'}, - 'track_or_recording_length': '167000'}, - {'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f', - 'length': '220000', - 'number': '12', - 'position': '12', - 'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88', - 'length': '220000', - 'title': 'Soldier Side'}, - 'track_or_recording_length': '220000'}]}], - 'packaging': 'Digipak', - 'quality': 'normal', - 'release-event-count': 1, - 'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98', - 'iso-3166-1-code-list': ['US'], - 'name': 'United States', - 'sort-name': 'United States'}, - 'date': '2005'}], - 'status': 'Official', - 'text-representation': {'language': 'eng', 'script': 'Latn'}, - 'title': 'Hypnotize'}} - -albums['get']['marsupial'] = { - 'release': { - "artist-credit": [ - { - "artist": { - "disambiguation": "George Shaw", - "id": "62c3befb-6366-4585-b256-809472333801", - "name": "Adhesive Wombat", - "sort-name": "Wombat, Adhesive" - } - } - ], - "artist-credit-phrase": "Adhesive Wombat", - "country": "XW", - "cover-art-archive": { - "artwork": "true", - "back": "false", - "count": "1", - "front": "true" - }, - "date": "2013-06-05", - "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e", - "packaging": "None", - "quality": "normal", - "release-event-count": 1, - "release-event-list": [ - { - "area": { - "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", - "iso-3166-1-code-list": [ - "XW" - ], - "name": "[Worldwide]", - "sort-name": "[Worldwide]" - }, - "date": "2013-06-05" - } - ], - "status": "Official", - "text-representation": { - "language": "eng", - "script": "Latn" - }, - "title": "Marsupial Madness" - } -} - -tracks = {'search': {}, 'get': {}} - -tracks['search']['8bitadventures'] = { - 'recording-list': [ - { - "artist-credit": [ - { - "artist": { - "disambiguation": "George Shaw", - "id": "62c3befb-6366-4585-b256-809472333801", - "name": "Adhesive Wombat", - "sort-name": "Wombat, Adhesive" - } - } - ], - "artist-credit-phrase": "Adhesive Wombat", - "ext:score": "100", - "id": "9968a9d6-8d92-4051-8f76-674e157b6eed", - "length": "271000", - "release-list": [ - { - "country": "XW", - "date": "2013-06-05", - "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e", - "medium-list": [ - { - "format": "Digital Media", - "position": "1", - "track-count": 11, - "track-list": [ - { - "id": "64d43604-c1ee-4f45-a02c-030672d2fe27", - "length": "271000", - "number": "1", - "title": "8-Bit Adventure", - "track_or_recording_length": "271000" - } - ] - } - ], - "medium-track-count": 11, - "release-event-list": [ - { - "area": { - "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", - "iso-3166-1-code-list": [ - "XW" - ], - "name": "[Worldwide]", - "sort-name": "[Worldwide]" - }, - "date": "2013-06-05" - } - ], - "release-group": { - "id": "447b4979-2178-405c-bfe6-46bf0b09e6c7", - "primary-type": "Album", - "type": "Album" - }, - "status": "Official", - "title": "Marsupial Madness" - } - ], - "title": "8-Bit Adventure", - "tag-list": [ - { - "count": "2", - "name": "techno" - }, - { - "count": "2", - "name": "good-music" - }, - ], - }, - ] -} - -tracks['get']['8bitadventures'] = {'recording': tracks['search']['8bitadventures']['recording-list'][0]} -tracks['get']['chop_suey'] = { - 'recording': { - 'id': '46c7368a-013a-47b6-97cc-e55e7ab25213', - 'length': '210240', - 'title': 'Chop Suey!', - 'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', - 'type': 'performance', - 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0', - 'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', - 'language': 'eng', - 'title': 'Chop Suey!'}}]}} - -works = {'search': {}, 'get': {}} -works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', - 'language': 'eng', - 'recording-relation-list': [{'direction': 'backward', - 'recording': {'disambiguation': 'edit', - 'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448', - 'length': '170893', - 'title': 'Chop Suey!'}, - 'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448', - 'type': 'performance', - 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'}, - ], - 'title': 'Chop Suey!', - 'type': 'Song', - 'url-relation-list': [{'direction': 'backward', - 'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!', - 'type': 'lyrics', - 'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}} diff --git a/api/tests/music/mocking/lyricswiki.py b/api/tests/music/mocking/lyricswiki.py deleted file mode 100644 index 360a7174f0740ff33ab9aa85dedc622e1dd176ef..0000000000000000000000000000000000000000 --- a/api/tests/music/mocking/lyricswiki.py +++ /dev/null @@ -1,32 +0,0 @@ -content = """<!doctype html> -<html lang="en" dir="ltr"> -<head> - -<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"> -<meta name="generator" content="MediaWiki 1.19.24" /> -<meta name="keywords" content="Chop Suey! lyrics,System Of A Down Chop Suey! lyrics,Chop Suey! by System Of A Down lyrics,lyrics,LyricWiki,LyricWikia,lyricwiki,System Of A Down:Chop Suey!,System Of A Down,System Of A Down:Toxicity (2001),Enter Shikari,Enter Shikari:Chop Suey!,"Weird Al" Yankovic,"Weird Al" Yankovic:Angry White Boy Polka,Renard,Renard:Physicality,System Of A Down:Chop Suey!/pt,Daron Malakian" /> -<meta name="description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." /> -<meta name="twitter:card" content="summary" /> -<meta name="twitter:site" content="@Wikia" /> -<meta name="twitter:url" content="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" /> -<meta name="twitter:title" content="System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia" /> -<meta name="twitter:description" content="Chop Suey! This song is by System of a Down and appears on the album Toxicity (2001)." /> -<link rel="canonical" href="http://lyrics.wikia.com/wiki/System_Of_A_Down:Chop_Suey!" /> -<link rel="alternate" type="application/x-wiki" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" /> -<link rel="edit" title="Edit" href="/wiki/System_Of_A_Down:Chop_Suey!?action=edit" /> -<link rel="apple-touch-icon" href="http://img4.wikia.nocookie.net/__cb22/lyricwiki/images/b/bc/Wiki.png" /> -<link rel="shortcut icon" href="http://slot1.images.wikia.nocookie.net/__cb1474018633/common/skins/common/images/favicon.ico" /> -<link rel="search" type="application/opensearchdescription+xml" href="/opensearch_desc.php" title="LyricWikia (en)" /> -<link rel="EditURI" type="application/rsd+xml" href="http://lyrics.wikia.com/api.php?action=rsd" /> -<link rel="copyright" href="/wiki/LyricWiki:Copyrights" /> -<link rel="alternate" type="application/atom+xml" title="LyricWikia Atom feed" href="/wiki/Special:RecentChanges?feed=atom" /> -<title>System Of A Down:Chop Suey! Lyrics - LyricWikia - Wikia</title> - -<body> -<div class='lyricbox'> -<i>We're rolling "Suicide".</i><br /><br />Wake up <i>(wake up)</i><br />Grab a brush and put on a little makeup<br />Hide the scars to fade away the shakeup <i>(hide the scars to fade away the)</i><br />Why'd you leave the keys upon the table?<br />Here you go, create another fable<br /><br />You wanted to<br />Grab a brush and put a little makeup<br />You wanted to<br />Hide the scars to fade away the shakeup<br />You wanted to<br />Why'd you leave the keys upon the table?<br />You wanted to<br /><br />I don't think you trust<br />In my self-righteous suicide<br />I cry when angels deserve to die<br /><br />Wake up <i>(wake up)</i><br />Grab a brush and put on a little makeup<br />Hide the scars to fade away the <i>(hide the scars to fade away the)</i><br />Why'd you leave the keys upon the table?<br />Here you go, create another fable<br /><br />You wanted to<br />Grab a brush and put a little makeup<br />You wanted to<br />Hide the scars to fade away the shakeup<br />You wanted to<br />Why'd you leave the keys upon the table?<br />You wanted to<br /><br />I don't think you trust<br />In my self-righteous suicide<br />I cry when angels deserve to die<br />In my self-righteous suicide<br />I cry when angels deserve to die<br /><br />Father <i>(father)</i><br />Father <i>(father)</i><br />Father <i>(father)</i><br />Father <i>(father)</i><br />Father, into your hands I commit my spirit<br />Father, into your hands<br /><br />Why have you forsaken me?<br />In your eyes forsaken me<br />In your thoughts forsaken me<br />In your heart forsaken me, oh<br /><br />Trust in my self-righteous suicide<br />I cry when angels deserve to die<br />In my self-righteous suicide<br />I cry when angels deserve to die -</div> -</body> -</html> -""" diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py index 8196d3c092e62b4d85f5da5c1b7490edc6f2def2..606720e133928ecda0d2bf22e9440e6b1c9eeb07 100644 --- a/api/tests/music/test_api.py +++ b/api/tests/music/test_api.py @@ -8,34 +8,40 @@ from funkwhale_api.musicbrainz import api from funkwhale_api.music import serializers from funkwhale_api.music import tasks -from . import data as api_data DATA_DIR = os.path.dirname(os.path.abspath(__file__)) -def test_can_submit_youtube_url_for_track_import(mocker, superuser_client): +def test_can_submit_youtube_url_for_track_import( + settings, artists, albums, tracks, mocker, superuser_client): + mocker.patch('funkwhale_api.music.tasks.import_job_run.delay') mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['adhesive_wombat']) + return_value=artists['get']['adhesive_wombat']) mocker.patch( 'funkwhale_api.musicbrainz.api.releases.get', - return_value=api_data.albums['get']['marsupial']) + return_value=albums['get']['marsupial']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.get', - return_value=api_data.tracks['get']['8bitadventures']) + return_value=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') + video_url = 'https://www.youtube.com/watch?v={0}'.format(video_id) response = superuser_client.post( url, - {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), + {'import_url': video_url, 'mbid': mbid}) - track = models.Track.objects.get(mbid=mbid) - assert track.artist.name == 'Adhesive Wombat' - assert track.album.title == 'Marsupial Madness' + + assert response.status_code == 201 + batch = superuser_client.user.imports.latest('id') + job = batch.jobs.latest('id') + assert job.status == 'pending' + assert str(job.mbid) == mbid + assert job.source == video_url def test_import_creates_an_import_with_correct_data(mocker, superuser_client): @@ -58,17 +64,18 @@ def test_import_creates_an_import_with_correct_data(mocker, superuser_client): assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id) -def test_can_import_whole_album(mocker, superuser_client): +def test_can_import_whole_album( + artists, albums, mocker, superuser_client): mocker.patch('funkwhale_api.music.tasks.import_job_run') mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['soad']) + return_value=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']) + return_value=albums['get_with_includes']['hypnotize']) payload = { 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94', 'tracks': [ @@ -97,7 +104,7 @@ def test_can_import_whole_album(mocker, superuser_client): 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] + medium_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']: @@ -113,17 +120,18 @@ def test_can_import_whole_album(mocker, superuser_client): assert job.source == row['source'] -def test_can_import_whole_artist(mocker, superuser_client): +def test_can_import_whole_artist( + artists, albums, mocker, superuser_client): mocker.patch('funkwhale_api.music.tasks.import_job_run') mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['soad']) + return_value=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']) + return_value=albums['get_with_includes']['hypnotize']) payload = { 'artistId': 'mbid', 'albums': [ @@ -157,7 +165,7 @@ def test_can_import_whole_artist(mocker, superuser_client): 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] + medium_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']: @@ -173,55 +181,57 @@ def test_can_import_whole_artist(mocker, superuser_client): 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) +def test_user_can_query_api_for_his_own_batches( + superuser_api_client, factories): + factories['music.ImportJob']() + job = factories['music.ImportJob']( + batch__submitted_by=superuser_api_client.user) 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')) + response = superuser_api_client.get(url) + results = response.data assert results['count'] == 1 assert results['results'][0]['jobs'][0]['mbid'] == job.mbid -def test_user_can_create_an_empty_batch(client, factories): - user = factories['users.SuperUser']() +def test_user_cannnot_access_other_batches( + superuser_api_client, factories): + factories['music.ImportJob']() + job = factories['music.ImportJob']() url = reverse('api:v1:import-batches-list') - client.login(username=user.username, password='test') - response = client.post(url) + + response = superuser_api_client.get(url) + results = response.data + assert results['count'] == 0 + + +def test_user_can_create_an_empty_batch(superuser_api_client, factories): + url = reverse('api:v1:import-batches-list') + response = superuser_api_client.post(url) assert response.status_code == 201 - batch = user.imports.latest('id') + batch = superuser_api_client.user.imports.latest('id') - assert batch.submitted_by == user + assert batch.submitted_by == superuser_api_client.user assert batch.source == 'api' -def test_user_can_create_import_job_with_file(client, factories, mocker): +def test_user_can_create_import_job_with_file( + superuser_api_client, factories, mocker): path = os.path.join(DATA_DIR, 'test.ogg') m = mocker.patch('funkwhale_api.common.utils.on_commit') - user = factories['users.SuperUser']() - batch = factories['music.ImportBatch'](submitted_by=user) + batch = factories['music.ImportBatch']( + submitted_by=superuser_api_client.user) url = reverse('api:v1:import-jobs-list') - client.login(username=user.username, password='test') with open(path, 'rb') as f: content = f.read() f.seek(0) - response = client.post(url, { + response = superuser_api_client.post(url, { 'batch': batch.pk, 'audio_file': f, 'source': 'file://' - }, format='multipart') + }) assert response.status_code == 201 @@ -237,16 +247,16 @@ def test_user_can_create_import_job_with_file(client, factories, mocker): import_job_id=job.pk) -def test_can_search_artist(factories, client): +def test_can_search_artist(factories, logged_in_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 + response = logged_in_client.get(url, {'query': artist1.name}) + assert response.data == expected -def test_can_search_artist_by_name_start(factories, client): +def test_can_search_artist_by_name_start(factories, logged_in_client): artist1 = factories['music.Artist'](name='alpha') artist2 = factories['music.Artist'](name='beta') expected = { @@ -256,20 +266,20 @@ def test_can_search_artist_by_name_start(factories, client): 'results': [serializers.ArtistSerializerNested(artist1).data] } url = reverse('api:v1:artists-list') - response = client.get(url, {'name__startswith': 'a'}) + response = logged_in_client.get(url, {'name__startswith': 'a'}) - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data -def test_can_search_tracks(factories, client): +def test_can_search_tracks(factories, logged_in_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}) + response = logged_in_client.get(url, {'query': query}) - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data @pytest.mark.parametrize('route,method', [ @@ -278,24 +288,31 @@ def test_can_search_tracks(factories, client): ('api:v1:artists-list', 'get'), ('api:v1:albums-list', 'get'), ]) -def test_can_restrict_api_views_to_authenticated_users(db, route, method, settings, client): +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): +def test_track_file_url_is_restricted_to_authenticated_users( + api_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) + response = api_client.get(url) assert response.status_code == 401 - user = factories['users.SuperUser']() - client.login(username=user.username, password='test') - response = client.get(url) + +def test_track_file_url_is_accessible_to_authenticated_users( + logged_in_api_client, factories, settings): + settings.API_AUTHENTICATION_REQUIRED = True + f = factories['music.TrackFile']() + assert f.audio_file is not None + url = f.path + response = logged_in_api_client.get(url) assert response.status_code == 200 assert response['X-Accel-Redirect'] == '/_protected{}'.format(f.audio_file.url) diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py index f2ca1abbd04a562764194f09653e17c4724f3cc4..0f709e81f508fcb0e4e2ee06e92991a2f907cdff 100644 --- a/api/tests/music/test_import.py +++ b/api/tests/music/test_import.py @@ -2,23 +2,21 @@ import json from django.urls import reverse -from . import data as api_data - def test_create_import_can_bind_to_request( - mocker, factories, superuser_api_client): + artists, albums, mocker, factories, superuser_api_client): request = factories['requests.ImportRequest']() mocker.patch('funkwhale_api.music.tasks.import_job_run') mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['soad']) + return_value=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']) + return_value=albums['get_with_includes']['hypnotize']) payload = { 'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94', 'importRequest': request.pk, diff --git a/api/tests/music/test_lyrics.py b/api/tests/music/test_lyrics.py index d10d113d7741d1e53e64d9af856a4d466bab6d35..3aee368c0e9c98a0da626b05f427191b4f8372d4 100644 --- a/api/tests/music/test_lyrics.py +++ b/api/tests/music/test_lyrics.py @@ -7,15 +7,12 @@ from funkwhale_api.music import serializers from funkwhale_api.music import tasks 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): +def test_works_import_lyrics_if_any( + lyricswiki_content, mocker, factories): mocker.patch( 'funkwhale_api.music.lyrics._get_html', - return_value=lyricswiki.content) + return_value=lyricswiki_content) lyrics = factories['music.Lyrics']( url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!') @@ -48,16 +45,22 @@ Is it me you're looking for?""" assert expected == l.content_rendered -def test_works_import_lyrics_if_any(mocker, factories, logged_in_client): +def test_works_import_lyrics_if_any( + lyricswiki_content, + works, + tracks, + mocker, + factories, + logged_in_client): mocker.patch( 'funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) + return_value=works['get']['chop_suey']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.get', - return_value=api_data.tracks['get']['chop_suey']) + return_value=tracks['get']['chop_suey']) mocker.patch( 'funkwhale_api.music.lyrics._get_html', - return_value=lyricswiki.content) + return_value=lyricswiki_content) track = factories['music.Track']( work=None, mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py index 076ad2bd05cb714c6436592666dffeeae61396ba..4162912e4fdee2e13d192ce14ed2a1fc4dcd2a23 100644 --- a/api/tests/music/test_music.py +++ b/api/tests/music/test_music.py @@ -2,14 +2,11 @@ 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): +def test_can_create_artist_from_api(artists, mocker, db): mocker.patch( 'musicbrainzngs.search_artists', - return_value=api_data.artists['search']['adhesive_wombat']) + return_value=artists['search']['adhesive_wombat']) artist = models.Artist.create_from_api(query="Adhesive wombat") data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0] @@ -19,13 +16,13 @@ def test_can_create_artist_from_api(mocker, db): assert artist.name, 'Adhesive Wombat' -def test_can_create_album_from_api(mocker, db): +def test_can_create_album_from_api(artists, albums, mocker, db): mocker.patch( 'funkwhale_api.musicbrainz.api.releases.search', - return_value=api_data.albums['search']['hypnotize']) + return_value=albums['search']['hypnotize']) mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['soad']) + return_value=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] @@ -38,16 +35,16 @@ def test_can_create_album_from_api(mocker, db): assert album.artist.mbid, data['artist-credit'][0]['artist']['id'] -def test_can_create_track_from_api(mocker, db): +def test_can_create_track_from_api(artists, albums, tracks, mocker, db): mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['adhesive_wombat']) + return_value=artists['get']['adhesive_wombat']) mocker.patch( 'funkwhale_api.musicbrainz.api.releases.get', - return_value=api_data.albums['get']['marsupial']) + return_value=albums['get']['marsupial']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=api_data.tracks['search']['8bitadventures']) + return_value=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 @@ -60,16 +57,17 @@ def test_can_create_track_from_api(mocker, db): assert track.album.title == 'Marsupial Madness' -def test_can_create_track_from_api_with_corresponding_tags(mocker, db): +def test_can_create_track_from_api_with_corresponding_tags( + artists, albums, tracks, mocker, db): mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['adhesive_wombat']) + return_value=artists['get']['adhesive_wombat']) mocker.patch( 'funkwhale_api.musicbrainz.api.releases.get', - return_value=api_data.albums['get']['marsupial']) + return_value=albums['get']['marsupial']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.get', - return_value=api_data.tracks['get']['8bitadventures']) + return_value=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()] @@ -77,16 +75,17 @@ def test_can_create_track_from_api_with_corresponding_tags(mocker, db): assert tag in track_tags -def test_can_get_or_create_track_from_api(mocker, db): +def test_can_get_or_create_track_from_api( + artists, albums, tracks, mocker, db): mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['adhesive_wombat']) + return_value=artists['get']['adhesive_wombat']) mocker.patch( 'funkwhale_api.musicbrainz.api.releases.get', - return_value=api_data.albums['get']['marsupial']) + return_value=albums['get']['marsupial']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=api_data.tracks['search']['8bitadventures']) + return_value=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 @@ -126,13 +125,13 @@ def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_querie assert tag in artist.tags -def test_can_download_image_file_for_album(mocker, factories): +def test_can_download_image_file_for_album(binary_cover, mocker, factories): mocker.patch( 'funkwhale_api.musicbrainz.api.images.get_front', - return_value=binary_data) + return_value=binary_cover) # 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 + assert album.cover.file.read() == binary_cover diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 5ecf9b9e46310c5f3779fa4ced90daa0809c5aff..ddbc4ba9a2c7407bd067dc2799f499654cbb004c 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -4,8 +4,6 @@ import pytest from funkwhale_api.providers.acoustid import get_acoustid_client from funkwhale_api.music import tasks -from . import data as api_data - DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -50,7 +48,7 @@ def test_set_acoustid_on_track_file_required_high_score(factories, mocker): def test_import_job_can_run_with_file_and_acoustid( - preferences, factories, mocker): + artists, albums, tracks, preferences, factories, mocker): preferences['providers_acoustid__api_key'] = 'test' path = os.path.join(DATA_DIR, 'test.ogg') mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' @@ -66,13 +64,13 @@ def test_import_job_can_run_with_file_and_acoustid( } mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['adhesive_wombat']) + return_value=artists['get']['adhesive_wombat']) mocker.patch( 'funkwhale_api.musicbrainz.api.releases.get', - return_value=api_data.albums['get']['marsupial']) + return_value=albums['get']['marsupial']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=api_data.tracks['search']['8bitadventures']) + return_value=tracks['search']['8bitadventures']) mocker.patch('acoustid.match', return_value=acoustid_payload) job = factories['music.FileImportJob'](audio_file__path=path) @@ -129,7 +127,8 @@ def test__do_import_skipping_accoustid_if_no_key( m.assert_called_once_with(p) -def test_import_job_can_be_skipped(factories, mocker, preferences): +def test_import_job_can_be_skipped( + artists, albums, tracks, factories, mocker, preferences): preferences['providers_acoustid__api_key'] = 'test' path = os.path.join(DATA_DIR, 'test.ogg') mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' @@ -146,13 +145,13 @@ def test_import_job_can_be_skipped(factories, mocker, preferences): } mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['adhesive_wombat']) + return_value=artists['get']['adhesive_wombat']) mocker.patch( 'funkwhale_api.musicbrainz.api.releases.get', - return_value=api_data.albums['get']['marsupial']) + return_value=albums['get']['marsupial']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=api_data.tracks['search']['8bitadventures']) + return_value=tracks['search']['8bitadventures']) mocker.patch('acoustid.match', return_value=acoustid_payload) job = factories['music.FileImportJob'](audio_file__path=path) diff --git a/api/tests/music/test_works.py b/api/tests/music/test_works.py index 9b72768ad07bf05adae848eef73784866de83c7d..13f6447bec60f54797f890f441412494f557b7c7 100644 --- a/api/tests/music/test_works.py +++ b/api/tests/music/test_works.py @@ -5,13 +5,11 @@ 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): +def test_can_import_work(factories, mocker, works): mocker.patch( 'funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) + return_value=works['get']['chop_suey']) recording = factories['music.Track']( mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' @@ -28,13 +26,13 @@ def test_can_import_work(factories, mocker): assert recording.work == work -def test_can_get_work_from_recording(factories, mocker): +def test_can_get_work_from_recording(factories, mocker, works, tracks): mocker.patch( 'funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) + return_value=works['get']['chop_suey']) mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.get', - return_value=api_data.tracks['get']['chop_suey']) + return_value=tracks['get']['chop_suey']) recording = factories['music.Track']( work=None, mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') @@ -53,10 +51,10 @@ def test_can_get_work_from_recording(factories, mocker): assert recording.work == work -def test_works_import_lyrics_if_any(db, mocker): +def test_works_import_lyrics_if_any(db, mocker, works): mocker.patch( 'funkwhale_api.musicbrainz.api.works.get', - return_value=api_data.works['get']['chop_suey']) + return_value=works['get']['chop_suey']) mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' work = models.Work.create_from_api(id=mbid) diff --git a/api/tests/musicbrainz/data.py b/api/tests/musicbrainz/conftest.py similarity index 96% rename from api/tests/musicbrainz/data.py rename to api/tests/musicbrainz/conftest.py index 1d7b9a3defa3c9927a649485e89b487eed7b9123..505d6e5537ab367090a4ead483e56105f561080c 100644 --- a/api/tests/musicbrainz/data.py +++ b/api/tests/musicbrainz/conftest.py @@ -1,5 +1,7 @@ -artists = {'search': {}, 'get': {}} -artists['search']['lost fingers'] = { +import pytest + +_artists = {'search': {}, 'get': {}} +_artists['search']['lost fingers'] = { 'artist-count': 696, 'artist-list': [ { @@ -21,7 +23,7 @@ artists['search']['lost fingers'] = { }, ] } -artists['get']['lost fingers'] = { +_artists['get']['lost fingers'] = { "artist": { "life-span": { "begin": "2008" @@ -102,8 +104,8 @@ artists['get']['lost fingers'] = { } -release_groups = {'browse': {}} -release_groups['browse']["lost fingers"] = { +_release_groups = {'browse': {}} +_release_groups['browse']["lost fingers"] = { "release-group-list": [ { "first-release-date": "2010", @@ -165,8 +167,8 @@ release_groups['browse']["lost fingers"] = { "release-group-count": 8 } -recordings = {'search': {}, 'get': {}} -recordings['search']['brontide matador'] = { +_recordings = {'search': {}, 'get': {}} +_recordings['search']['brontide matador'] = { "recording-count": 1044, "recording-list": [ { @@ -217,8 +219,8 @@ recordings['search']['brontide matador'] = { ] } -releases = {'search': {}, 'get': {}, 'browse': {}} -releases['search']['brontide matador'] = { +_releases = {'search': {}, 'get': {}, 'browse': {}} +_releases['search']['brontide matador'] = { "release-count": 116, "release-list": [ { "ext:score": "100", @@ -283,7 +285,7 @@ releases['search']['brontide matador'] = { ] } -releases['browse']['Lost in the 80s'] = { +_releases['browse']['Lost in the 80s'] = { "release-count": 3, "release-list": [ { @@ -476,3 +478,23 @@ releases['browse']['Lost in the 80s'] = { }, ] } + + +@pytest.fixture() +def releases(): + return _releases + + +@pytest.fixture() +def release_groups(): + return _release_groups + + +@pytest.fixture() +def artists(): + return _artists + + +@pytest.fixture() +def recordings(): + return _recordings diff --git a/api/tests/musicbrainz/test_api.py b/api/tests/musicbrainz/test_api.py index bbade340060dae4cbc4f2a64d296eb86e6c59e79..fdd1dbdb03b74769588ef6c66c23a49cc1053b29 100644 --- a/api/tests/musicbrainz/test_api.py +++ b/api/tests/musicbrainz/test_api.py @@ -2,64 +2,65 @@ 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): +def test_can_search_recording_in_musicbrainz_api( + recordings, db, mocker, logged_in_api_client): mocker.patch( 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=api_data.recordings['search']['brontide matador']) + return_value=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}) + expected = recordings['search']['brontide matador'] + response = logged_in_api_client.get(url, data={'query': query}) - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data -def test_can_search_release_in_musicbrainz_api(db, mocker, client): +def test_can_search_release_in_musicbrainz_api(releases, db, mocker, logged_in_api_client): mocker.patch( 'funkwhale_api.musicbrainz.api.releases.search', - return_value=api_data.releases['search']['brontide matador']) + return_value=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}) + expected = releases['search']['brontide matador'] + response = logged_in_api_client.get(url, data={'query': query}) - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data -def test_can_search_artists_in_musicbrainz_api(db, mocker, client): +def test_can_search_artists_in_musicbrainz_api(artists, db, mocker, logged_in_api_client): mocker.patch( 'funkwhale_api.musicbrainz.api.artists.search', - return_value=api_data.artists['search']['lost fingers']) + return_value=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}) + expected = artists['search']['lost fingers'] + response = logged_in_api_client.get(url, data={'query': query}) - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data -def test_can_get_artist_in_musicbrainz_api(db, mocker, client): +def test_can_get_artist_in_musicbrainz_api(artists, db, mocker, logged_in_api_client): mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', - return_value=api_data.artists['get']['lost fingers']) + return_value=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'] + response = logged_in_api_client.get(url) + expected = artists['get']['lost fingers'] - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data -def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client): +def test_can_broswe_release_group_using_musicbrainz_api( + release_groups, db, mocker, logged_in_api_client): mocker.patch( 'funkwhale_api.musicbrainz.api.release_groups.browse', - return_value=api_data.release_groups['browse']['lost fingers']) + return_value=release_groups['browse']['lost fingers']) uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9' url = reverse( 'api:v1:providers:musicbrainz:release-group-browse', @@ -67,16 +68,17 @@ def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client): 'artist_uuid': uuid, } ) - response = client.get(url) - expected = api_data.release_groups['browse']['lost fingers'] + response = logged_in_api_client.get(url) + expected = release_groups['browse']['lost fingers'] - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data -def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client): +def test_can_broswe_releases_using_musicbrainz_api( + releases, db, mocker, logged_in_api_client): mocker.patch( 'funkwhale_api.musicbrainz.api.releases.browse', - return_value=api_data.releases['browse']['Lost in the 80s']) + return_value=releases['browse']['Lost in the 80s']) uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1' url = reverse( 'api:v1:providers:musicbrainz:release-browse', @@ -84,7 +86,7 @@ def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client): 'release_group_uuid': uuid, } ) - response = client.get(url) - expected = api_data.releases['browse']['Lost in the 80s'] + response = logged_in_api_client.get(url) + expected = releases['browse']['Lost in the 80s'] - assert expected == json.loads(response.content.decode('utf-8')) + assert expected == response.data diff --git a/api/tests/playlists/__init__.py b/api/tests/playlists/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 5bf83488859177aaf4e756a5f25668f568641b35..f0fb6d0fdc19286b2ebbbc0b2ef5336d3bd642f5 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -106,7 +106,9 @@ def test_deleting_plt_updates_indexes( @pytest.mark.parametrize('level', ['instance', 'me', 'followers']) -def test_playlist_privacy_respected_in_list_anon(level, factories, api_client): +def test_playlist_privacy_respected_in_list_anon( + settings, level, factories, api_client): + settings.API_AUTHENTICATION_REQUIRED = False factories['playlists.Playlist'](privacy_level=level) url = reverse('api:v1:playlists-list') response = api_client.get(url) @@ -115,26 +117,28 @@ def test_playlist_privacy_respected_in_list_anon(level, factories, api_client): @pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE']) -def test_only_owner_can_edit_playlist(method, factories, api_client): +def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client): playlist = factories['playlists.Playlist']() url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk}) - response = api_client.get(url) + response = getattr(logged_in_api_client, method.lower())(url) assert response.status_code == 404 @pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE']) -def test_only_owner_can_edit_playlist_track(method, factories, api_client): +def test_only_owner_can_edit_playlist_track( + method, factories, logged_in_api_client): plt = factories['playlists.PlaylistTrack']() url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk}) - response = api_client.get(url) + response = getattr(logged_in_api_client, method.lower())(url) assert response.status_code == 404 @pytest.mark.parametrize('level', ['instance', 'me', 'followers']) def test_playlist_track_privacy_respected_in_list_anon( - level, factories, api_client): + level, factories, api_client, settings): + settings.API_AUTHENTICATION_REQUIRED = False factories['playlists.PlaylistTrack'](playlist__privacy_level=level) url = reverse('api:v1:playlist-tracks-list') response = api_client.get(url) diff --git a/api/tests/radios/__init__.py b/api/tests/radios/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py index b731e3024b039bd5006023bb80276405565edd8e..c8038a4dbadcb4283d073492beb715e7092bab20 100644 --- a/api/tests/radios/test_radios.py +++ b/api/tests/radios/test_radios.py @@ -151,14 +151,18 @@ def test_can_start_radio_for_logged_in_user(logged_in_client): assert session.user == logged_in_client.user -def test_can_start_radio_for_anonymous_user(client, db): +def test_can_start_radio_for_anonymous_user(api_client, db, settings): + settings.API_AUTHENTICATION_REQUIRED = False url = reverse('api:v1:radios:sessions-list') - response = client.post(url, {'radio_type': 'random'}) + response = api_client.post(url, {'radio_type': 'random'}) + + assert response.status_code == 201 + session = models.RadioSession.objects.latest('id') assert session.radio_type == 'random' assert session.user is None - assert session.session_key == client.session.session_key + assert session.session_key == api_client.session.session_key def test_can_get_track_for_session_from_api(factories, logged_in_client): @@ -228,13 +232,18 @@ def test_can_start_tag_radio(factories): assert radio.pick() in good_tracks -def test_can_start_artist_radio_from_api(client, factories): +def test_can_start_artist_radio_from_api(api_client, settings, factories): + settings.API_AUTHENTICATION_REQUIRED = False artist = factories['music.Artist']() url = reverse('api:v1:radios:sessions-list') - response = client.post( + response = api_client.post( url, {'radio_type': 'artist', 'related_object_id': artist.id}) + + assert response.status_code == 201 + session = models.RadioSession.objects.latest('id') + assert session.radio_type, 'artist' assert session.related_object, artist diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index 4f3de27db47df47250c69a844e4f15a37f092c21..67263e66d0c56309bb21f344274277b7d4464165 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -98,3 +98,27 @@ def test_import_files_skip_acoustid(factories, mocker): music_tasks.import_job_run.delay, import_job_id=job.pk, use_acoustid=False) + + +def test_import_files_works_with_utf8_file_name(factories, mocker): + m = mocker.patch('funkwhale_api.common.utils.on_commit') + user = factories['users.User'](username='me') + path = os.path.join(DATA_DIR, 'utf8-éà ◌.ogg') + call_command( + 'import_files', + path, + username='me', + async=True, + no_acoustid=True, + interactive=False) + batch = user.imports.latest('id') + job = batch.jobs.first() + m.assert_called_once_with( + music_tasks.import_job_run.delay, + import_job_id=job.pk, + use_acoustid=False) + + +def test_storage_rename_utf_8_files(factories): + tf = factories['music.TrackFile'](audio_file__filename='été.ogg') + assert tf.audio_file.name.endswith('ete.ogg') diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py index 017d742ef834562f33425cbc84046fdf7d580bb4..441179095d98398697c60fb3fabc922dafbeab6e 100644 --- a/api/tests/test_youtube.py +++ b/api/tests/test_youtube.py @@ -17,13 +17,15 @@ def test_can_get_search_results_from_youtube(mocker): assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo' -def test_can_get_search_results_from_funkwhale(mocker, client, db): +def test_can_get_search_results_from_funkwhale( + settings, mocker, api_client, db): + settings.API_AUTHENTICATION_REQUIRED = False 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}) + response = api_client.get(url, {'query': query}) # we should cast the youtube result to something more generic expected = { "id": "0HxZn6CzOIo", @@ -37,7 +39,7 @@ def test_can_get_search_results_from_funkwhale(mocker, client, db): "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" } - assert json.loads(response.content.decode('utf-8'))[0] == expected + assert response.data[0] == expected def test_can_send_multiple_queries_at_once(mocker): @@ -67,7 +69,9 @@ def test_can_send_multiple_queries_at_once(mocker): 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): +def test_can_send_multiple_queries_at_once_from_funwkhale( + settings, mocker, db, api_client): + settings.API_AUTHENTICATION_REQUIRED = False mocker.patch( 'funkwhale_api.providers.youtube.client._do_search', return_value=api_data.search['8 bit adventure']) @@ -89,7 +93,6 @@ def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client): } url = reverse('api:v1:providers:youtube:searchs') - response = client.post( - url, json.dumps(queries), content_type='application/json') + response = api_client.post(url, queries, format='json') - assert expected == json.loads(response.content.decode('utf-8'))['1'][0] + assert expected == response.data['1'][0] diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 02b903bf41ea6bb9169987bd5ed1d147aff2122e..4be586965f8d5f02bac7cfc9d3c9b871e2d8fd31 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -23,6 +23,23 @@ def test_can_create_user_via_api(preferences, client, db): assert u.username == 'test1' +def test_can_restrict_usernames(settings, preferences, db, client): + url = reverse('rest_register') + preferences['users__registration_enabled'] = True + settings.USERNAME_BLACKLIST = ['funkwhale'] + data = { + 'username': 'funkwhale', + 'email': 'contact@funkwhale.io', + 'password1': 'testtest', + 'password2': 'testtest', + } + + response = client.post(url, data) + + assert response.status_code == 400 + assert 'username' in response.data + + def test_can_disable_registration_view(preferences, client, db): url = reverse('rest_register') data = { diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index e1a381b945b88529107b9b3c7bea7224308dc498..a016b34c7eea37e0272fb8b08e5afcfd6a9c085f 100644 --- a/deploy/env.prod.sample +++ b/deploy/env.prod.sample @@ -4,12 +4,13 @@ # - DJANGO_ALLOWED_HOSTS # - FUNKWHALE_URL -# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those -# variables: +# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables: # - DATABASE_URL # - CACHE_URL # - STATIC_ROOT # - MEDIA_ROOT +# +# You **don't** need to update those variables on pure docker setups. # Docker only # ----------- diff --git a/deploy/funkwhale_proxy.conf b/deploy/funkwhale_proxy.conf index 1b1dd0d20e951455eb999e17aacc1c45e6fed669..312986f43a0bd2a15169eea427d9a2f54dd0e7fb 100644 --- a/deploy/funkwhale_proxy.conf +++ b/deploy/funkwhale_proxy.conf @@ -3,8 +3,8 @@ proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-Forwarded-Host $host:$server_port; -proxy_set_header X-Forwarded-Port $server_port; +proxy_set_header X-Forwarded-Host $host:$server_port; +proxy_set_header X-Forwarded-Port $server_port; proxy_redirect off; # websocket support diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 1c7b9ae8357d8222aba78e9c6dc34f45fbbb8ca4..1c304b4938892bf87ba50ab05f2394784b020248 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -62,6 +62,16 @@ server { proxy_pass http://funkwhale-api/api/; } + location /federation/ { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/federation/; + } + + location /.well-known/webfinger { + include /etc/nginx/funkwhale_proxy.conf; + proxy_pass http://funkwhale-api/.well-known/webfinger; + } + location /media/ { alias /srv/funkwhale/data/media/; } diff --git a/dev.yml b/dev.yml index 8d2129bef978e78bc8c1a874ae19692d8ee62997..c0470a2ab6f6172127e90fa9d3911b0cae9b33db 100644 --- a/dev.yml +++ b/dev.yml @@ -1,27 +1,35 @@ -version: '2' +version: '3' services: - front: build: front - env_file: .env.dev + env_file: + - .env.dev + - .env environment: - "HOST=0.0.0.0" + - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}" ports: - - "8080:8080" + - "${WEBPACK_DEVSERVER_PORT-8080}:${WEBPACK_DEVSERVER_PORT-8080}" volumes: - './front:/app' postgres: - env_file: .env.dev + env_file: + - .env.dev + - .env image: postgres redis: - env_file: .env.dev + env_file: + - .env.dev + - .env image: redis:3.0 celeryworker: - env_file: .env.dev + env_file: + - .env.dev + - .env build: context: ./api dockerfile: docker/Dockerfile.test @@ -30,18 +38,15 @@ services: - redis command: celery -A funkwhale_api.taskapp worker -l debug environment: - - "DJANGO_ALLOWED_HOSTS=localhost" - - "DJANGO_SETTINGS_MODULE=config.settings.local" - - "DJANGO_SECRET_KEY=dev" - - C_FORCE_ROOT=true - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" - - "FUNKWHALE_URL=http://funkwhale.test" volumes: - ./api:/app - ./data/music:/music api: - env_file: .env.dev + env_file: + - .env.dev + - .env build: context: ./api dockerfile: docker/Dockerfile.test @@ -50,24 +55,27 @@ services: - ./api:/app - ./data/music:/music environment: - - "DJANGO_ALLOWED_HOSTS=localhost,nginx" - - "DJANGO_SETTINGS_MODULE=config.settings.local" - - "DJANGO_SECRET_KEY=dev" - "DATABASE_URL=postgresql://postgres@postgres/postgres" - "CACHE_URL=redis://redis:6379/0" - - "FUNKWHALE_URL=http://funkwhale.test" links: - postgres - redis nginx: - env_file: .env.dev + command: /entrypoint.sh + env_file: + - .env.dev + - .env image: nginx + environment: + - "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}" links: - api - front volumes: - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf + - ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro + - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro - ./api/funkwhale_api/media:/protected/media ports: - "0.0.0.0:6001:6001" diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 9847c2dcbcc71bf3040802d6289c7336ff40c340..e832a5ae3ee5b4d96833bd1cf5bf23fee6d5033f 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -37,19 +37,7 @@ http { listen 6001; charset utf-8; client_max_body_size 20M; - - # global proxy pass config - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host localhost:8080; - proxy_set_header X-Forwarded-Port 8080; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_redirect off; - + include /etc/nginx/funkwhale_proxy.conf; location /_protected/media { internal; alias /protected/media; @@ -63,8 +51,7 @@ http { if ($request_uri ~* "[^\?]+\?(.*)$") { set $query $1; } - proxy_set_header X-Forwarded-Host localhost:8080; - proxy_set_header X-Forwarded-Port 8080; + include /etc/nginx/funkwhale_proxy.conf; proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query; proxy_pass_request_body off; proxy_set_header Content-Length ""; @@ -78,6 +65,7 @@ http { if ($args ~ (.*)jwt=[^&]*(.*)) { set $cleaned_args $1$2; } + include /etc/nginx/funkwhale_proxy.conf; proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args"; proxy_cache transcode; proxy_cache_valid 200 7d; @@ -87,6 +75,7 @@ http { proxy_pass http://api:12081; } location / { + include /etc/nginx/funkwhale_proxy.conf; proxy_pass http://api:12081/; } } diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh new file mode 100755 index 0000000000000000000000000000000000000000..1819acf1cf579cb3e0d70b754d06f2b6fc4959c6 --- /dev/null +++ b/docker/nginx/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/bash -eux +FIRST_HOST=$(echo ${DJANGO_ALLOWED_HOSTS} | cut -d, -f1) +echo "Copying template file..." +cp /etc/nginx/funkwhale_proxy.conf{.template,} +sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FIRST_HOST}:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf +sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FIRST_HOST}/" /etc/nginx/funkwhale_proxy.conf +sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf + +cat /etc/nginx/funkwhale_proxy.conf +nginx -g "daemon off;" diff --git a/docs/index.rst b/docs/index.rst index 17e9fe7f0b699cc427c71cac4597708b5a311e31..f5acecce516ee8d6b635c91e3aa0fed2686f5739 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,6 +15,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in installation/index configuration importing-music + upgrading changelog Indices and tables diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index 86ccb4dd3ee6df1678f758d0e8e7834bb16a4e3b..c4e54218d53bf85fc4aa647e0516be86956df319 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -89,7 +89,7 @@ First, we'll download the latest api release. curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/|version|/download?job=build_api" unzip "api-|version|.zip" -d extracted - mv extracted/api api + mv extracted/api/* api/ rmdir extracted @@ -100,7 +100,7 @@ Then we'll download the frontend files: curl -L -o "front-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/|version|/download?job=build_front" unzip "front-|version|.zip" -d extracted mv extracted/front . - rmdir extracted + rm -rf extracted You can leave the ZIP archives in the directory, this will help you know which version you've installed next time you want to upgrade your installation. diff --git a/docs/installation/docker.rst b/docs/installation/docker.rst index 34e8187c58dc2e104392badf862dec1dcadcad43..dc031caed91b8c169ff0bd68827865c432f3504d 100644 --- a/docs/installation/docker.rst +++ b/docs/installation/docker.rst @@ -17,7 +17,9 @@ Create your env file: .. parsed-literal:: + export FUNKWHALE_VERSION="|version|" curl -L -o .env "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/env.prod.sample" + sed -i "s/FUNKWHALE_VERSION=latest/FUNKWHALE_VERSION=$FUNKWHALE_VERSION/" .env Ensure to edit it to match your needs (this file is heavily commented) diff --git a/docs/upgrading.rst b/docs/upgrading.rst new file mode 100644 index 0000000000000000000000000000000000000000..674878ba7c4d8f4dd41f64f0dbda615ea515ba2d --- /dev/null +++ b/docs/upgrading.rst @@ -0,0 +1,94 @@ +Upgrading your funkwhale instance to a newer version +==================================================== + +.. note:: + + Before upgrading your instance, we strongly advise you to make at least a database backup. Ideally, you should make a full backup, including + the database and the media files. + + We're commited to make upgrade as easy and straightforward as possible, + however, funkwhale is still in development and you'll be safer with a backup. + + +Reading the release notes +------------------------- + +Please take a few minutes to read the :doc:`changelog`: updates should work +similarly from version to version, but some of them may require additional steps. +Those steps would be described in the version release notes. + +Upgrade the static files +------------------------ + +Regardless of your deployment choice (docker/non-docker) the front-end app +is updated separately from the API. This is as simple as downloading +the zip with the static files and extracting it in the correct place. + +The following example assume your setup match :ref:`frontend-setup`. + +.. parsed-literal:: + + # this assumes you want to upgrade to version "|version|" + export FUNKWHALE_VERSION="|version|" + cd /srv/funkwhale + curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front" + unzip -o front.zip + rm front.zip + +Upgrading the API +----------------- + +Docker setup +^^^^^^^^^^^^ + +If you've followed the setup instructions in :doc:`Docker`, upgrade path is +easy: + +.. parsed-literal:: + + cd /srv/funkwhale + # hardcode the targeted version your env file + # (look for the FUNKWHALE_VERSION variable) + nano .env + # Pull the new version containers + docker-compose pull + # Apply the database migrations + docker-compose run --rm api python manage.py migrate + # Relaunch the containers + docker-compose up -d + +Non-docker setup +^^^^^^^^^^^^^^^^ + +On non docker-setup, upgrade involves a few more commands. We assume your setup +match what is described in :doc:`debian`: + +.. parsed-literal:: + + # stop the services + sudo systemctl stop funkwhale.target + + # this assumes you want to upgrade to version "|version|" + export FUNKWALE_VERSION="|version|" + cd /srv/funkwhale + + # download more recent API files + curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWALE_VERSION/download?job=build_api" + unzip "api-$FUNKWALE_VERSION.zip" -d extracted + rm -rf api/ && mv extracted/api . + rm -rf extracted + + # update os dependencies + sudo api/install_os_dependencies.sh install + # update python dependencies + source /srv/funkwhale/load_env + source /srv/funkwhale/virtualenv/bin/activate + pip install -r api/requirements.txt + + # apply database migrations + python api/manage.py migrate + # collect static files + python api/manage.py collectstatic --no-input + + # restart the services + sudo systemctl restart funkwhale.target diff --git a/front/config/index.js b/front/config/index.js index 14cbe3e4388ea6a5e18a6053a1611ffd0cd6ab38..669ce54f37dbfe912cf61dda75b7ccb6366c0824 100644 --- a/front/config/index.js +++ b/front/config/index.js @@ -23,25 +23,37 @@ module.exports = { }, dev: { env: require('./dev.env'), - port: 8080, + port: parseInt(process.env.WEBPACK_DEVSERVER_PORT), host: '127.0.0.1', autoOpenBrowser: true, assetsSubDirectory: 'static', assetsPublicPath: '/', proxyTable: { - '/api': { + '**': { target: 'http://nginx:6001', changeOrigin: true, - ws: true + ws: true, + filter: function (pathname, req) { + let proxified = ['.well-known', 'staticfiles', 'media', 'federation', 'api'] + let matches = proxified.filter(e => { + return pathname.match(`^/${e}`) + }) + return matches.length > 0 + } }, - '/media': { - target: 'http://nginx:6001', - changeOrigin: true, - }, - '/staticfiles': { - target: 'http://nginx:6001', - changeOrigin: true, - } + // '/.well-known': { + // target: 'http://nginx:6001', + // changeOrigin: true + // }, + // '/media': { + // target: 'http://nginx:6001', + // changeOrigin: true, + // }, + // '/staticfiles': { + // target: 'http://nginx:6001', + // changeOrigin: true, + // }, + }, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README diff --git a/front/package.json b/front/package.json index 201694e43648e08c6bd23b2fa869ca83c29c7e2d..d67375f7e5851f7cf2af379ac298a6f85f973f78 100644 --- a/front/package.json +++ b/front/package.json @@ -41,7 +41,7 @@ "autoprefixer": "^6.7.2", "babel-core": "^6.22.1", "babel-eslint": "^7.1.1", - "babel-loader": "^6.2.10", + "babel-loader": "7", "babel-plugin-istanbul": "^4.1.1", "babel-plugin-transform-runtime": "^6.22.0", "babel-preset-env": "^1.3.2", @@ -101,7 +101,7 @@ "vue-loader": "^12.1.0", "vue-style-loader": "^3.0.1", "vue-template-compiler": "^2.3.3", - "webpack": "^2.6.1", + "webpack": "3", "webpack-bundle-analyzer": "^2.2.1", "webpack-dev-middleware": "^1.10.0", "webpack-hot-middleware": "^2.18.0", diff --git a/front/src/App.vue b/front/src/App.vue index d15eebdba69db25580d2d86a055819439a7c113a..e8ab18694ae4c44a5c6995d263b194dffe598dc2 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -21,6 +21,7 @@ <div class="ten wide column"> <h4 class="ui header">About funkwhale</h4> <p>Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!</p> + <p>The funkwhale logo was kindly designed and provided by Francis Gading.</p> </div> </div> </div> @@ -35,9 +36,6 @@ </template> <script> -import { WebSocketBridge } from 'django-channels' - -import logger from '@/logging' import Sidebar from '@/components/Sidebar' import Raven from '@/components/Raven' @@ -52,34 +50,11 @@ export default { }, created () { this.$store.dispatch('instance/fetchSettings') - this.openWebsocket() let self = this setInterval(() => { // used to redraw ago dates every minute self.$store.commit('ui/computeLastDate') }, 1000 * 60) - }, - methods: { - openWebsocket () { - if (!this.$store.state.auth.authenticated) { - return - } - let self = this - let token = this.$store.state.auth.token - // let token = 'test' - const bridge = new WebSocketBridge() - bridge.connect( - `/api/v1/instance/activity?token=${token}`, - null, - {reconnectInterval: 5000}) - bridge.listen(function (event) { - logger.default.info('Received timeline update', event) - self.$store.commit('instance/event', event) - }) - bridge.socket.addEventListener('open', function () { - console.log('Connected to WebSocket') - }) - } } } </script> diff --git a/front/src/components/About.vue b/front/src/components/About.vue index 92bafd7afddde359aa7ffef0b7a2fb1e4b531e29..09a5ee24c523f8f8f5db77b47b7c6694ae74387b 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'About This Instance'"> <div class="ui vertical center aligned stripe segment"> <div class="ui text container"> <h1 class="ui huge header"> diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index ad1ad93cb08c2a29cff5b84859b769345dee9872..0e24dcd59008c52fe198e6a1778d9d1e0c00545c 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -1,9 +1,9 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Welcome'"> <div class="ui vertical center aligned stripe segment"> <div class="ui text container"> <h1 class="ui huge header"> - Welcome on funkwhale + Welcome on Funkwhale </h1> <p>We think listening music should be simple.</p> <router-link class="ui icon button" to="/about"> @@ -143,9 +143,7 @@ export default { name: 'home', data () { - return { - msg: 'Welcome to Your Vue.js App' - } + return {} } } </script> diff --git a/front/src/components/PageNotFound.vue b/front/src/components/PageNotFound.vue index 3b88a6921f05d69aa49a5c7a965e509c3725249a..25e6f86fd209a7cb6132c9c36812e918c1f116f9 100644 --- a/front/src/components/PageNotFound.vue +++ b/front/src/components/PageNotFound.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Page Not Found'"> <div class="ui vertical stripe segment"> <div class="ui text container"> <h1 class="ui huge header"> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index f225313b6b5f64139ed668473a6ebea51bc58eec..42a923b6b3faaf5586a35613277bbcdd7ea8c27d 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -16,8 +16,8 @@ <div class="menu-area"> <div class="ui compact fluid two item inverted menu"> - <a class="active item" data-tab="library">Browse</a> - <a class="item" data-tab="queue"> + <a class="active item" @click="selectedTab = 'library'" data-tab="library">Browse</a> + <a class="item" @click="selectedTab = 'queue'" data-tab="queue"> Queue <template v-if="queue.tracks.length === 0"> (empty) @@ -46,6 +46,8 @@ v-if="$store.state.auth.authenticated" class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link> </div> + + <player></player> </div> <div v-if="queue.previousQueue " class="ui black icon message"> <i class="history icon"></i> @@ -96,7 +98,6 @@ </div> </div> </div> - <player></player> </div> </template> @@ -121,6 +122,7 @@ export default { }, data () { return { + selectedTab: 'library', backend: backend, isCollapsed: true } @@ -140,11 +142,36 @@ export default { }), reorder: function (oldValue, newValue) { this.$store.commit('queue/reorder', {oldValue, newValue}) + }, + scrollToCurrent () { + let current = $(this.$el).find('[data-tab="queue"] .active')[0] + if (!current) { + return + } + let container = $(this.$el).find('.tabs')[0] + // Position container at the top line then scroll current into view + container.scrollTop = 0 + current.scrollIntoView(true) + // Scroll back nothing if element is at bottom of container else do it + // for half the height of the containers display area + var scrollBack = (container.scrollHeight - container.scrollTop <= container.clientHeight) ? 0 : container.clientHeight / 2 + container.scrollTop = container.scrollTop - scrollBack + console.log(container.scrollHeight - container.scrollTop, container.clientHeight) } }, watch: { url: function () { this.isCollapsed = true + }, + selectedTab: function (newValue) { + if (newValue === 'queue') { + this.scrollToCurrent() + } + }, + '$store.state.queue.currentIndex': function () { + if (this.selectedTab !== 'queue') { + this.scrollToCurrent() + } } } } @@ -204,19 +231,38 @@ $sidebar-color: #3D3E3F; } } .tabs { + flex: 1; + display: flex; + flex-direction: column; overflow-y: auto; + justify-content: space-between; @include media(">tablet") { height: 0px; } @include media("<desktop") { - max-height: 400px; + max-height: 500px; } } +.ui.tab.active { + display: flex; +} .tab[data-tab="queue"] { + flex-direction: column; tr { cursor: pointer; } } +.tab[data-tab="library"] { + flex-direction: column; + flex: 1 1 auto; + > .menu { + flex: 1; + flex-grow: 1; + } + > .player-wrapper { + width: 100%; + } +} .sidebar .segment { margin: 0; border-radius: 0; @@ -225,9 +271,6 @@ $sidebar-color: #3D3E3F; .ui.inverted.segment.header-wrapper { padding: 0; } -.tabs { - flex: 1; -} .logo { cursor: pointer; diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 75a01c52e015b419d4c919b90bfe97e7db8ab02b..31f6dc35ac3478a9b3c84b15a76ea9adcc044882 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -59,8 +59,8 @@ <div title="Previous track" class="two wide column control" - :disabled="!hasPrevious"> - <i @click="previous" :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i> + :disabled="emptyQueue"> + <i @click="previous" :class="['ui', 'backward', {'disabled': emptyQueue}, 'big', 'icon']"></i> </div> <div v-if="!playing" @@ -205,7 +205,7 @@ export default { ...mapGetters({ currentTrack: 'queue/currentTrack', hasNext: 'queue/hasNext', - hasPrevious: 'queue/hasPrevious', + emptyQueue: 'queue/isEmpty', durationFormatted: 'player/durationFormatted', currentTimeFormatted: 'player/currentTimeFormatted', progress: 'player/progress' diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index 370d8ae2d3a6c5a4076c5220f5e6d0f1f7f80e66..68dd34459870f432d7ca5c9e3b35a6d7966b225d 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -31,7 +31,8 @@ export default { }, data () { return { - sourceErrors: 0 + sourceErrors: 0, + isUpdatingTime: false } }, computed: { @@ -99,6 +100,7 @@ export default { } }, updateProgress: _.throttle(function () { + this.isUpdatingTime = true if (this.$refs.audio) { this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime) } @@ -130,6 +132,12 @@ export default { }, volume: function (newValue) { this.$refs.audio.volume = newValue + }, + currentTime (newValue) { + if (!this.isUpdatingTime) { + this.setCurrentTime(newValue) + } + this.isUpdatingTime = false } } } diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue index 2cf6d5f6db1cd8bc4e4d72d96be5c70d559e9452..d232da2a35e38d4f774072fb049473ac5cfa1e86 100644 --- a/front/src/components/auth/Login.vue +++ b/front/src/components/auth/Login.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Log In'"> <div class="ui vertical stripe segment"> <div class="ui small text container"> <h2>Log in to your Funkwhale account</h2> diff --git a/front/src/components/auth/Logout.vue b/front/src/components/auth/Logout.vue index fbacca70338ed295dc284664d443c9b239b25da0..980740c94cfc415e5afc467186c14d1a193748cc 100644 --- a/front/src/components/auth/Logout.vue +++ b/front/src/components/auth/Logout.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Log Out'"> <div class="ui vertical stripe segment"> <div class="ui small text container"> <h2>Are you sure you want to log out?</h2> diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue index 54af5a11c4c0027fc68f81e9c6eca283d4ed8aee..9fafccffa83f0e9b63ac6a692de586a4adf9e4f8 100644 --- a/front/src/components/auth/Profile.vue +++ b/front/src/components/auth/Profile.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="username + '\'s Profile'"> <div v-if="isLoading" class="ui vertical segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 8cd03d75580aa52f4e7e382a5a6ec0278cb3db92..859c0d04b97ccecb97911575ad505cdda475ebf5 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Account Settings'"> <div class="ui vertical stripe segment"> <div class="ui small text container"> <h2 class="ui header">Account settings</h2> diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index 749d2eb0254a5d55b163532afff252302b4d50e6..39ca366631367afe452e3dddcaf54f56ad5f511a 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Sign Up'"> <div class="ui vertical stripe segment"> <div class="ui small text container"> <h2>Create a funkwhale account</h2> diff --git a/front/src/components/favorites/List.vue b/front/src/components/favorites/List.vue index c65144a93bf9c6ae1c43f0148f2df75ee4be9105..dbef186b6b5c7d8a6aec611b798ab22a41f03362 100644 --- a/front/src/components/favorites/List.vue +++ b/front/src/components/favorites/List.vue @@ -1,5 +1,5 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Your Favorites'"> <div class="ui vertical center aligned stripe segment"> <div :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> <div class="ui text loader">Loading your favorites...</div> diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 65768aafe7851868b35030e2b6273dfbcfefc3b7..1e96757c342a9b325cb293f8363ea2078f358aff 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -1,10 +1,10 @@ <template> <div> - <div v-if="isLoading" class="ui vertical segment"> + <div v-if="isLoading" class="ui vertical segment" v-title="'Album'"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="album"> - <div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle"> + <div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title"> <div class="segment-content"> <h2 class="ui center aligned icon header"> <i class="circular inverted sound yellow icon"></i> diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index 9a546aa0e9fa98aa4bdda13fcde57fd6ad4b2986..07d8cbbc74cff682e2251eb0c22515ca0fc8a9c0 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -1,10 +1,10 @@ <template> <div> - <div v-if="isLoading" class="ui vertical segment"> + <div v-if="isLoading" class="ui vertical segment" v-title="'Artist'"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="artist"> - <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle"> + <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="artist.name"> <div class="segment-content"> <h2 class="ui center aligned icon header"> <i class="circular inverted users violet icon"></i> diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 52ccbdd7465c654b28eb53c141f50a6e62ca0209..72ad108f1d2d78ce32357753d163045284263715 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Artists'"> <div class="ui vertical stripe segment"> <h2 class="ui header">Browsing artists</h2> <div :class="['ui', {'loading': isLoading}, 'form']"> diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index 40f6808f98f3c382353fd9bb205d3c09cbbcb8bd..26352d1fd6c0314129dc7e212d7ddb7d97dd4519 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Home'"> <div class="ui vertical stripe segment"> <search :autofocus="true"></search> </div> diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue index 303ce100ecf35ec46be8d6909b03398dbe125826..7ab14ab14f8d3dbc4fa02a52c34758d69b38b5e9 100644 --- a/front/src/components/library/Radios.vue +++ b/front/src/components/library/Radios.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Radios'"> <div class="ui vertical stripe segment"> <h2 class="ui header">Browsing radios</h2> <router-link class="ui green basic button" to="/library/radios/build" exact>Create your own radio</router-link> diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 0437ac88151ad166ea6704579b8069adf35007f2..e614635c9877ca315743d16cf0d4ebba47679597 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -1,10 +1,10 @@ <template> <div> - <div v-if="isLoadingTrack" class="ui vertical segment"> + <div v-if="isLoadingTrack" class="ui vertical segment" v-title="'Track'"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="track"> - <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle"> + <div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="track.title"> <div class="segment-content"> <h2 class="ui center aligned icon header"> <i class="circular inverted music orange icon"></i> diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue index 0864d2464abf9a769e9c15d1d6d32b3ebf21f219..362a5acd17972e6b26bc3deca8ae5881b2865e79 100644 --- a/front/src/components/library/import/BatchDetail.vue +++ b/front/src/components/library/import/BatchDetail.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Import Batch #' + id"> <div v-if="isLoading && !batch" class="ui vertical segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue index de4fef554c5c4699ec1d501dbcbc471dc28d2379..857fef0457256aa7e7ea12f7d355bab1a36b32fe 100644 --- a/front/src/components/library/import/BatchList.vue +++ b/front/src/components/library/import/BatchList.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Import Batches'"> <div class="ui vertical stripe segment"> <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> <button diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue index 66e16b71f467163cfb9313eaf5f6fa542cec4216..e2b6e484912d803a0494590afa04e6e8931401b8 100644 --- a/front/src/components/library/import/Main.vue +++ b/front/src/components/library/import/Main.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Import Music'"> <div class="ui vertical stripe segment"> <div class="ui top three attached ordered steps"> <a @click="currentStep = 0" :class="['step', {'active': currentStep === 0}, {'completed': currentStep > 0}]"> diff --git a/front/src/components/library/radios/Builder.vue b/front/src/components/library/radios/Builder.vue index 8d67b61e18b5f21d67e360c3c4fe05def2e52fcc..5fbf0c992618616e0c0be0f76e458deb997737b8 100644 --- a/front/src/components/library/radios/Builder.vue +++ b/front/src/components/library/radios/Builder.vue @@ -1,5 +1,5 @@ <template> - <div class="ui vertical stripe segment"> + <div class="ui vertical stripe segment" v-title="'Radio Builder'"> <div> <div> <h2 class="ui header">Builder</h2> @@ -180,7 +180,7 @@ export default { let url = 'radios/radios/' axios.post(url, final).then((response) => { self.$router.push({ - name: 'library.radios.edit', + name: 'library.radios.detail', params: { id: response.data.id } diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index d2c14c37c78dfbc23b858c93a5eddd28b3519fb9..17de3c85fe3c67d5a124d046aa9e5fa233503396 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -1,7 +1,11 @@ <template> <div class="ui card"> <div class="content"> - <div class="header">{{ radio.name }}</div> + <div class="header"> + <router-link class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}"> + {{ radio.name }} + </router-link> + </div> <div class="description"> {{ radio.description }} </div> diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue index 33ba04f53c61a47e59fcd991185d3961ad12e693..5d4db243acce765d72e64c61e3771b54bf17e452 100644 --- a/front/src/components/requests/RequestsList.vue +++ b/front/src/components/requests/RequestsList.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Import Requests'"> <div class="ui vertical stripe segment"> <h2 class="ui header">Music requests</h2> <div :class="['ui', {'loading': isLoading}, 'form']"> diff --git a/front/src/main.js b/front/src/main.js index f20af42bfa7ed7ab3850cc8ebf76d6e4ae0916e7..5030e5c9c429d62eda3d6c20e86acc1d4e19a3c2 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -30,6 +30,10 @@ require('masonry-layout') Vue.use(VueMasonryPlugin) Vue.use(VueLazyload) Vue.config.productionTip = false +Vue.directive('title', { + inserted: (el, binding) => { console.log(binding.value); document.title = binding.value + ' - Funkwhale' }, + updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' } +}) axios.defaults.baseURL = config.API_URL axios.interceptors.request.use(function (config) { diff --git a/front/src/router/index.js b/front/src/router/index.js index 802844461325560a48cf8002f21ef8e3f5462594..d41764227bb1a1cbd3536791ae24275b7228d249 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -18,6 +18,7 @@ import LibraryTrack from '@/components/library/Track' import LibraryImport from '@/components/library/import/Main' import LibraryRadios from '@/components/library/Radios' import RadioBuilder from '@/components/library/radios/Builder' +import RadioDetail from '@/views/radios/Detail' import BatchList from '@/components/library/import/BatchList' import BatchDetail from '@/components/library/import/BatchDetail' import RequestsList from '@/components/requests/RequestsList' @@ -111,6 +112,7 @@ export default new Router({ }, { path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true }, { path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true }, + { path: 'radios/:id', name: 'library.radios.detail', component: RadioDetail, props: true }, { path: 'playlists/', name: 'library.playlists.browse', diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 2436eab079cd72f11fe48ecf2f64e857bc4f1e58..245acaf039adb4cc92e5df0760c3b38733e6b241 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -43,6 +43,9 @@ export default { if (state.events.length > state.maxEvents) { state.events = state.events.slice(0, state.maxEvents) } + }, + events: (state, value) => { + state.events = value } }, actions: { diff --git a/front/src/store/queue.js b/front/src/store/queue.js index 07263da63315deb04fb7d819037bbd04ff669931..6a26fa1e9a0dc70609b0ea6fcba6e5b11745f277 100644 --- a/front/src/store/queue.js +++ b/front/src/store/queue.js @@ -41,7 +41,6 @@ export default { state.currentIndex += 1 } } - }, getters: { currentTrack: state => { @@ -50,9 +49,7 @@ export default { hasNext: state => { return state.currentIndex < state.tracks.length - 1 }, - hasPrevious: state => { - return state.currentIndex > 0 - } + isEmpty: state => state.tracks.length === 0 }, actions: { append ({commit, state, dispatch}, {track, index, skipPlay}) { @@ -104,9 +101,11 @@ export default { dispatch('next') } }, - previous ({state, dispatch}) { - if (state.currentIndex > 0) { + previous ({state, dispatch, rootState}) { + if (state.currentIndex > 0 && rootState.player.currentTime < 3) { dispatch('currentIndex', state.currentIndex - 1) + } else { + dispatch('currentIndex', state.currentIndex) } }, next ({state, dispatch, commit, rootState}) { @@ -141,7 +140,9 @@ export default { commit('ended', true) }, shuffle ({dispatch, commit, state}) { - let shuffled = _.shuffle(state.tracks) + let toKeep = state.tracks.slice(0, state.currentIndex + 1) + let toShuffle = state.tracks.slice(state.currentIndex + 1) + let shuffled = toKeep.concat(_.shuffle(toShuffle)) commit('player/currentTime', 0, {root: true}) commit('tracks', []) dispatch('appendMany', {tracks: shuffled}) diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue index b959c25d66d397fc24c3c4b9d2ab62f26cdfb9fb..f4ddedfb72e287235081cc8d86c5f5a388197c79 100644 --- a/front/src/views/instance/Timeline.vue +++ b/front/src/views/instance/Timeline.vue @@ -1,7 +1,10 @@ <template> - <div class="main pusher"> + <div class="main pusher" v-title="'Instance Timeline'"> <div class="ui vertical center aligned stripe segment"> - <div class="ui text container"> + <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> + <div class="ui text loader">Loading timeline...</div> + </div> + <div v-else class="ui text container"> <h1 class="ui header">Recent activity on this instance</h1> <div class="ui feed"> <component @@ -26,6 +29,9 @@ <script> import {mapState} from 'vuex' +import { WebSocketBridge } from 'django-channels' +import axios from 'axios' +import logger from '@/logging' import Like from '@/components/activity/Like' import Listen from '@/components/activity/Listen' @@ -33,16 +39,51 @@ import Listen from '@/components/activity/Listen' export default { data () { return { + isLoading: false, components: { 'Like': Like, 'Listen': Listen } } }, + created () { + this.openWebsocket() + this.fetchEvents() + }, computed: { ...mapState({ events: state => state.instance.events }) + }, + methods: { + fetchEvents () { + this.isLoading = true + let self = this + axios.get('/activity/').then((response) => { + self.isLoading = false + self.$store.commit('instance/events', response.data.results) + }) + }, + openWebsocket () { + if (!this.$store.state.auth.authenticated) { + return + } + let self = this + let token = this.$store.state.auth.token + // let token = 'test' + const bridge = new WebSocketBridge() + bridge.connect( + `/api/v1/instance/activity?token=${token}`, + null, + {reconnectInterval: 5000}) + bridge.listen(function (event) { + logger.default.info('Received timeline update', event) + self.$store.commit('instance/event', event) + }) + bridge.socket.addEventListener('open', function () { + console.log('Connected to WebSocket') + }) + } } } </script> diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 6c3a988fd195ae3df5d4e597a69f4bb3dee9c40b..2769317e6440f24c034f21d5db68bbd7fd52524b 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -1,9 +1,9 @@ <template> <div> - <div v-if="isLoading" class="ui vertical segment"> + <div v-if="isLoading" class="ui vertical segment" v-title="'Playlist'"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> - <div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment"> + <div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment" v-title="playlist.name"> <div class="segment-content"> <h2 class="ui center aligned icon header"> <i class="circular inverted list yellow icon"></i> diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index fc5dcbe54b441c4ee2009ba4ebe7881173ab108a..96aa36c4779bc11a6050208cf497ac861a7dabcd 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -1,5 +1,5 @@ <template> - <div> + <div v-title="'Playlists'"> <div class="ui vertical stripe segment"> <h2 class="ui header">Browsing playlists</h2> <div :class="['ui', {'loading': isLoading}, 'form']"> diff --git a/front/src/views/radios/Detail.vue b/front/src/views/radios/Detail.vue new file mode 100644 index 0000000000000000000000000000000000000000..397dcb49ec3fbd7e05fa45007f38418712a2d3b7 --- /dev/null +++ b/front/src/views/radios/Detail.vue @@ -0,0 +1,110 @@ +<template> + <div> + <div v-if="isLoading" class="ui vertical segment" v-title="'Radio'"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <div v-if="!isLoading && radio" class="ui head vertical center aligned stripe segment" v-title="radio.name"> + <div class="segment-content"> + <h2 class="ui center aligned icon header"> + <i class="circular inverted feed blue icon"></i> + <div class="content"> + {{ radio.name }} + <div class="sub header"> + Radio containing {{ tracks.length }} tracks, + by <username :username="radio.user.username"></username> + </div> + </div> + </h2> + <div class="ui hidden divider"></div> + <radio-button type="custom" :custom-radio-id="radio.id"></radio-button> + <router-link class="ui icon button" :to="{name: 'library.radios.edit', params: {id: radio.id}}" exact> + <i class="pencil icon"></i> + Edit… + </router-link> + <dangerous-button class="labeled icon" :action="deleteRadio"> + <i class="trash icon"></i> Delete + <p slot="modal-header">Do you want to delete the radio "{{ radio.name }}"?</p> + <p slot="modal-content">This will completely delete this radio and cannot be undone.</p> + <p slot="modal-confirm">Delete radio</p> + </dangerous-button> + </div> + </div> + <div class="ui vertical stripe segment"> + <h2>Tracks</h2> + <track-table :tracks="tracks"></track-table> + <div class="ui center aligned basic segment"> + <pagination + v-if="totalTracks > 25" + @page-changed="selectPage" + :current="page" + :paginate-by="25" + :total="totalTracks" + ></pagination> + </div> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import TrackTable from '@/components/audio/track/Table' +import RadioButton from '@/components/radios/Button' +import Pagination from '@/components/Pagination' + +export default { + props: { + id: {required: true} + }, + components: { + TrackTable, + RadioButton, + Pagination + }, + data: function () { + return { + isLoading: false, + radio: null, + tracks: [], + totalTracks: 0, + page: 1 + } + }, + created: function () { + this.fetch() + }, + methods: { + selectPage: function (page) { + this.page = page + }, + fetch: function () { + let self = this + self.isLoading = true + let url = 'radios/radios/' + this.id + '/' + axios.get(url).then((response) => { + self.radio = response.data + axios.get(url + 'tracks', {params: {page: this.page}}).then((response) => { + console.log(response.data.count) + this.totalTracks = response.data.count + this.tracks = response.data.results + }).then(() => { + self.isLoading = false + }) + }) + }, + deleteRadio () { + let self = this + let url = 'radios/radios/' + this.id + '/' + axios.delete(url).then((response) => { + self.$router.push({ + path: '/library' + }) + }) + } + }, + watch: { + page: function () { + this.fetch() + } + } +} +</script> diff --git a/front/static/favicon.png b/front/static/favicon.png index 089442fab7cb4e68cec9de87d421b64495e3263a..0220a4202cf807816c57b0433bed5bc97fa3f875 100644 Binary files a/front/static/favicon.png and b/front/static/favicon.png differ diff --git a/front/test/unit/specs/store/queue.spec.js b/front/test/unit/specs/store/queue.spec.js index 5439362dc389e1731e65e038a58c247974a816b8..2bc5cde4efec16a0cfdcf40ce36b1385780b93a1 100644 --- a/front/test/unit/specs/store/queue.spec.js +++ b/front/test/unit/specs/store/queue.spec.js @@ -81,14 +81,6 @@ describe('store/queue', () => { const state = { tracks: [1, 2, 3], currentIndex: 2 } expect(store.getters['hasNext'](state)).to.equal(false) }) - it('hasPrevious true', () => { - const state = { currentIndex: 1 } - expect(store.getters['hasPrevious'](state)).to.equal(true) - }) - it('hasPrevious false', () => { - const state = { currentIndex: 0 } - expect(store.getters['hasPrevious'](state)).to.equal(false) - }) }) describe('actions', () => { it('append at end', (done) => { @@ -212,22 +204,33 @@ describe('store/queue', () => { expectedActions: [] }, done) }) - it('previous when at beginning does nothing', (done) => { + it('previous when at beginning', (done) => { testAction({ action: store.actions.previous, params: {state: {currentIndex: 0}}, - expectedActions: [] + expectedActions: [ + { type: 'currentIndex', payload: 0 } + ] }, done) }) - it('previous', (done) => { + it('previous after less than 3 seconds of playback', (done) => { testAction({ action: store.actions.previous, - params: {state: {currentIndex: 1}}, + params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 1}}}, expectedActions: [ { type: 'currentIndex', payload: 0 } ] }, done) }) + it('previous after more than 3 seconds of playback', (done) => { + testAction({ + action: store.actions.previous, + params: {state: {currentIndex: 1}, rootState: {player: {currentTime: 3}}}, + expectedActions: [ + { type: 'currentIndex', payload: 1 } + ] + }, done) + }) it('next on last track when looping on queue', (done) => { testAction({ action: store.actions.next, @@ -316,18 +319,18 @@ describe('store/queue', () => { }) it('shuffle', (done) => { let _shuffle = sandbox.stub(_, 'shuffle') - let tracks = [1, 2, 3] - let shuffledTracks = [2, 3, 1] + let tracks = ['a', 'b', 'c', 'd', 'e'] + let shuffledTracks = ['e', 'd', 'c'] _shuffle.returns(shuffledTracks) testAction({ action: store.actions.shuffle, - params: {state: {tracks: tracks}}, + params: {state: {currentIndex: 1, tracks: tracks}}, expectedMutations: [ { type: 'player/currentTime', payload: 0 , options: {root: true}}, { type: 'tracks', payload: [] } ], expectedActions: [ - { type: 'appendMany', payload: {tracks: shuffledTracks} } + { type: 'appendMany', payload: {tracks: ['a', 'b'].concat(shuffledTracks)} } ] }, done) }) diff --git a/pyproject.toml b/pyproject.toml index baea16861cd0cb3b81afa8c6561921148f8b1d68..421f98c0261cb940ed3e5f5ce0e094977ee52aff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,11 @@ name = "Features" showcontent = true + [[tool.towncrier.type]] + directory = "enhancement" + name = "Enhancements" + showcontent = true + [[tool.towncrier.type]] directory = "bugfix" name = "Bugfixes"