Merge branch '463-user-libraries-full' into 'develop'
Resolve "Per-user libraries" (use !368 instead) See merge request funkwhale/funkwhale!372
This commit is contained in:
		
						commit
						07830506a8
					
				|  | @ -14,12 +14,11 @@ router.register(r"settings", GlobalPreferencesViewSet, base_name="settings") | ||||||
| router.register(r"activity", activity_views.ActivityViewSet, "activity") | router.register(r"activity", activity_views.ActivityViewSet, "activity") | ||||||
| router.register(r"tags", views.TagViewSet, "tags") | router.register(r"tags", views.TagViewSet, "tags") | ||||||
| router.register(r"tracks", views.TrackViewSet, "tracks") | router.register(r"tracks", views.TrackViewSet, "tracks") | ||||||
| router.register(r"trackfiles", views.TrackFileViewSet, "trackfiles") | router.register(r"track-files", views.TrackFileViewSet, "trackfiles") | ||||||
|  | router.register(r"libraries", views.LibraryViewSet, "libraries") | ||||||
|  | router.register(r"listen", views.ListenViewSet, "listen") | ||||||
| router.register(r"artists", views.ArtistViewSet, "artists") | router.register(r"artists", views.ArtistViewSet, "artists") | ||||||
| router.register(r"albums", views.AlbumViewSet, "albums") | router.register(r"albums", views.AlbumViewSet, "albums") | ||||||
| router.register(r"import-batches", views.ImportBatchViewSet, "import-batches") |  | ||||||
| router.register(r"import-jobs", views.ImportJobViewSet, "import-jobs") |  | ||||||
| router.register(r"submit", views.SubmitViewSet, "submit") |  | ||||||
| router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") | router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") | ||||||
| router.register( | router.register( | ||||||
|     r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" |     r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" | ||||||
|  |  | ||||||
|  | @ -8,9 +8,7 @@ application = ProtocolTypeRouter( | ||||||
|     { |     { | ||||||
|         # Empty for now (http->django views is added by default) |         # Empty for now (http->django views is added by default) | ||||||
|         "websocket": TokenAuthMiddleware( |         "websocket": TokenAuthMiddleware( | ||||||
|             URLRouter( |             URLRouter([url("^api/v1/activity$", consumers.InstanceActivityConsumer)]) | ||||||
|                 [url("^api/v1/instance/activity$", consumers.InstanceActivityConsumer)] |  | ||||||
|             ) |  | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | @ -126,7 +126,6 @@ LOCAL_APPS = ( | ||||||
|     "funkwhale_api.history", |     "funkwhale_api.history", | ||||||
|     "funkwhale_api.playlists", |     "funkwhale_api.playlists", | ||||||
|     "funkwhale_api.providers.audiofile", |     "funkwhale_api.providers.audiofile", | ||||||
|     "funkwhale_api.providers.youtube", |  | ||||||
|     "funkwhale_api.providers.acoustid", |     "funkwhale_api.providers.acoustid", | ||||||
|     "funkwhale_api.subsonic", |     "funkwhale_api.subsonic", | ||||||
| ) | ) | ||||||
|  | @ -280,7 +279,7 @@ MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media"))) | ||||||
| 
 | 
 | ||||||
| # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url | # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url | ||||||
| MEDIA_URL = env("MEDIA_URL", default="/media/") | MEDIA_URL = env("MEDIA_URL", default="/media/") | ||||||
| 
 | FILE_UPLOAD_PERMISSIONS = 0o644 | ||||||
| # URL Configuration | # URL Configuration | ||||||
| # ------------------------------------------------------------------------------ | # ------------------------------------------------------------------------------ | ||||||
| ROOT_URLCONF = "config.urls" | ROOT_URLCONF = "config.urls" | ||||||
|  | @ -446,7 +445,7 @@ REST_FRAMEWORK = { | ||||||
|     "DEFAULT_AUTHENTICATION_CLASSES": ( |     "DEFAULT_AUTHENTICATION_CLASSES": ( | ||||||
|         "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS", |         "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS", | ||||||
|         "funkwhale_api.common.authentication.BearerTokenHeaderAuth", |         "funkwhale_api.common.authentication.BearerTokenHeaderAuth", | ||||||
|         "rest_framework_jwt.authentication.JSONWebTokenAuthentication", |         "funkwhale_api.common.authentication.JSONWebTokenAuthentication", | ||||||
|         "rest_framework.authentication.SessionAuthentication", |         "rest_framework.authentication.SessionAuthentication", | ||||||
|         "rest_framework.authentication.BasicAuthentication", |         "rest_framework.authentication.BasicAuthentication", | ||||||
|     ), |     ), | ||||||
|  |  | ||||||
|  | @ -2,11 +2,13 @@ | ||||||
| from __future__ import unicode_literals | from __future__ import unicode_literals | ||||||
| 
 | 
 | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.conf.urls import include, url | from django.conf.urls import url | ||||||
|  | from django.urls import include, path | ||||||
| from django.conf.urls.static import static | from django.conf.urls.static import static | ||||||
| from django.contrib import admin | from django.contrib import admin | ||||||
| from django.views import defaults as default_views | from django.views import defaults as default_views | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     # Django Admin, use {% url 'admin:index' %} |     # Django Admin, use {% url 'admin:index' %} | ||||||
|     url(settings.ADMIN_URL, admin.site.urls), |     url(settings.ADMIN_URL, admin.site.urls), | ||||||
|  | @ -36,4 +38,6 @@ if settings.DEBUG: | ||||||
|     if "debug_toolbar" in settings.INSTALLED_APPS: |     if "debug_toolbar" in settings.INSTALLED_APPS: | ||||||
|         import debug_toolbar |         import debug_toolbar | ||||||
| 
 | 
 | ||||||
|         urlpatterns += [url(r"^__debug__/", include(debug_toolbar.urls))] |         urlpatterns = [ | ||||||
|  |             path("api/__debug__/", include(debug_toolbar.urls)) | ||||||
|  |         ] + urlpatterns | ||||||
|  |  | ||||||
|  | @ -56,3 +56,20 @@ class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication): | ||||||
| 
 | 
 | ||||||
|     def authenticate_header(self, request): |     def authenticate_header(self, request): | ||||||
|         return '{0} realm="{1}"'.format("Bearer", self.www_authenticate_realm) |         return '{0} realm="{1}"'.format("Bearer", self.www_authenticate_realm) | ||||||
|  | 
 | ||||||
|  |     def authenticate(self, request): | ||||||
|  |         auth = super().authenticate(request) | ||||||
|  |         if auth: | ||||||
|  |             if not auth[0].actor: | ||||||
|  |                 auth[0].create_actor() | ||||||
|  |         return auth | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class JSONWebTokenAuthentication(authentication.JSONWebTokenAuthentication): | ||||||
|  |     def authenticate(self, request): | ||||||
|  |         auth = super().authenticate(request) | ||||||
|  | 
 | ||||||
|  |         if auth: | ||||||
|  |             if not auth[0].actor: | ||||||
|  |                 auth[0].create_actor() | ||||||
|  |         return auth | ||||||
|  |  | ||||||
|  | @ -1,6 +1,25 @@ | ||||||
|  | import json | ||||||
|  | import logging | ||||||
|  | 
 | ||||||
| from asgiref.sync import async_to_sync | from asgiref.sync import async_to_sync | ||||||
| from channels.layers import get_channel_layer | from channels.layers import get_channel_layer | ||||||
|  | from django.core.serializers.json import DjangoJSONEncoder | ||||||
| 
 | 
 | ||||||
|  | logger = logging.getLogger(__file__) | ||||||
| channel_layer = get_channel_layer() | channel_layer = get_channel_layer() | ||||||
| group_send = async_to_sync(channel_layer.group_send) |  | ||||||
| group_add = async_to_sync(channel_layer.group_add) | group_add = async_to_sync(channel_layer.group_add) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def group_send(group, event): | ||||||
|  |     # we serialize the payload ourselves and deserialize it to ensure it | ||||||
|  |     # works with msgpack. This is dirty, but we'll find a better solution | ||||||
|  |     # later | ||||||
|  |     s = json.dumps(event, cls=DjangoJSONEncoder) | ||||||
|  |     event = json.loads(s) | ||||||
|  |     logger.debug( | ||||||
|  |         "[channels] Dispatching %s to group %s: %s", | ||||||
|  |         event["type"], | ||||||
|  |         group, | ||||||
|  |         {"type": event["data"]["type"]}, | ||||||
|  |     ) | ||||||
|  |     async_to_sync(channel_layer.group_send)(group, event) | ||||||
|  |  | ||||||
|  | @ -16,3 +16,5 @@ class JsonAuthConsumer(JsonWebsocketConsumer): | ||||||
|         super().accept() |         super().accept() | ||||||
|         for group in self.groups: |         for group in self.groups: | ||||||
|             channels.group_add(group, self.channel_name) |             channels.group_add(group, self.channel_name) | ||||||
|  |         for group in self.scope["user"].get_channels_groups(): | ||||||
|  |             channels.group_add(group, self.channel_name) | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| from . import create_actors | from . import create_actors | ||||||
| from . import create_image_variations | from . import create_image_variations | ||||||
| from . import django_permissions_to_user_permissions | from . import django_permissions_to_user_permissions | ||||||
|  | from . import migrate_to_user_libraries | ||||||
| from . import test | from . import test | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -8,5 +9,6 @@ __all__ = [ | ||||||
|     "create_actors", |     "create_actors", | ||||||
|     "create_image_variations", |     "create_image_variations", | ||||||
|     "django_permissions_to_user_permissions", |     "django_permissions_to_user_permissions", | ||||||
|  |     "migrate_to_user_libraries", | ||||||
|     "test", |     "test", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | @ -0,0 +1,58 @@ | ||||||
|  | """ | ||||||
|  | Mirate instance files to a library #463. For each user that imported music on an | ||||||
|  | instance, we will create a "default" library with related files and an instance-level | ||||||
|  | visibility. | ||||||
|  | 
 | ||||||
|  | Files without any import job will be bounded to a "default" library on the first | ||||||
|  | superuser account found. This should now happen though. | ||||||
|  | """ | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.music import models | ||||||
|  | from funkwhale_api.users.models import User | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def main(command, **kwargs): | ||||||
|  |     importer_ids = set( | ||||||
|  |         models.ImportBatch.objects.values_list("submitted_by", flat=True) | ||||||
|  |     ) | ||||||
|  |     importers = User.objects.filter(pk__in=importer_ids).order_by("id").select_related() | ||||||
|  |     command.stdout.write( | ||||||
|  |         "* {} users imported music on this instance".format(len(importers)) | ||||||
|  |     ) | ||||||
|  |     files = models.TrackFile.objects.filter( | ||||||
|  |         library__isnull=True, jobs__isnull=False | ||||||
|  |     ).distinct() | ||||||
|  |     command.stdout.write( | ||||||
|  |         "* Reassigning {} files to importers libraries...".format(files.count()) | ||||||
|  |     ) | ||||||
|  |     for user in importers: | ||||||
|  |         command.stdout.write( | ||||||
|  |             "  * Setting up @{}'s 'default' library".format(user.username) | ||||||
|  |         ) | ||||||
|  |         library = user.actor.libraries.get_or_create(actor=user.actor, name="default")[ | ||||||
|  |             0 | ||||||
|  |         ] | ||||||
|  |         user_files = files.filter(jobs__batch__submitted_by=user) | ||||||
|  |         total = user_files.count() | ||||||
|  |         command.stdout.write( | ||||||
|  |             "    * Reassigning {} files to the user library...".format(total) | ||||||
|  |         ) | ||||||
|  |         user_files.update(library=library) | ||||||
|  | 
 | ||||||
|  |     files = models.TrackFile.objects.filter( | ||||||
|  |         library__isnull=True, jobs__isnull=True | ||||||
|  |     ).distinct() | ||||||
|  |     command.stdout.write( | ||||||
|  |         "* Handling {} files with no import jobs...".format(files.count()) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     user = User.objects.order_by("id").filter(is_superuser=True).first() | ||||||
|  | 
 | ||||||
|  |     command.stdout.write("  * Setting up @{}'s 'default' library".format(user.username)) | ||||||
|  |     library = user.actor.libraries.get_or_create(actor=user.actor, name="default")[0] | ||||||
|  |     total = files.count() | ||||||
|  |     command.stdout.write( | ||||||
|  |         "    * Reassigning {} files to the user library...".format(total) | ||||||
|  |     ) | ||||||
|  |     files.update(library=library) | ||||||
|  |     command.stdout.write(" * Done!") | ||||||
|  | @ -1,5 +1,70 @@ | ||||||
|  | import collections | ||||||
|  | 
 | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
| 
 | 
 | ||||||
|  | from django.core.exceptions import ObjectDoesNotExist | ||||||
|  | from django.utils.encoding import smart_text | ||||||
|  | from django.utils.translation import ugettext_lazy as _ | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class RelatedField(serializers.RelatedField): | ||||||
|  |     default_error_messages = { | ||||||
|  |         "does_not_exist": _("Object with {related_field_name}={value} does not exist."), | ||||||
|  |         "invalid": _("Invalid value."), | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     def __init__(self, related_field_name, serializer, **kwargs): | ||||||
|  |         self.related_field_name = related_field_name | ||||||
|  |         self.serializer = serializer | ||||||
|  |         self.filters = kwargs.pop("filters", None) | ||||||
|  |         kwargs["queryset"] = kwargs.pop( | ||||||
|  |             "queryset", self.serializer.Meta.model.objects.all() | ||||||
|  |         ) | ||||||
|  |         super().__init__(**kwargs) | ||||||
|  | 
 | ||||||
|  |     def get_filters(self, data): | ||||||
|  |         filters = {self.related_field_name: data} | ||||||
|  |         if self.filters: | ||||||
|  |             filters.update(self.filters(self.context)) | ||||||
|  |         return filters | ||||||
|  | 
 | ||||||
|  |     def to_internal_value(self, data): | ||||||
|  |         try: | ||||||
|  |             queryset = self.get_queryset() | ||||||
|  |             filters = self.get_filters(data) | ||||||
|  |             return queryset.get(**filters) | ||||||
|  |         except ObjectDoesNotExist: | ||||||
|  |             self.fail( | ||||||
|  |                 "does_not_exist", | ||||||
|  |                 related_field_name=self.related_field_name, | ||||||
|  |                 value=smart_text(data), | ||||||
|  |             ) | ||||||
|  |         except (TypeError, ValueError): | ||||||
|  |             self.fail("invalid") | ||||||
|  | 
 | ||||||
|  |     def to_representation(self, obj): | ||||||
|  |         return self.serializer.to_representation(obj) | ||||||
|  | 
 | ||||||
|  |     def get_choices(self, cutoff=None): | ||||||
|  |         queryset = self.get_queryset() | ||||||
|  |         if queryset is None: | ||||||
|  |             # Ensure that field.choices returns something sensible | ||||||
|  |             # even when accessed with a read-only field. | ||||||
|  |             return {} | ||||||
|  | 
 | ||||||
|  |         if cutoff is not None: | ||||||
|  |             queryset = queryset[:cutoff] | ||||||
|  | 
 | ||||||
|  |         return collections.OrderedDict( | ||||||
|  |             [ | ||||||
|  |                 ( | ||||||
|  |                     self.to_representation(item)[self.related_field_name], | ||||||
|  |                     self.display_value(item), | ||||||
|  |                 ) | ||||||
|  |                 for item in queryset | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class Action(object): | class Action(object): | ||||||
|     def __init__(self, name, allow_all=False, qs_filter=None): |     def __init__(self, name, allow_all=False, qs_filter=None): | ||||||
|  | @ -21,6 +86,7 @@ class ActionSerializer(serializers.Serializer): | ||||||
|     objects = serializers.JSONField(required=True) |     objects = serializers.JSONField(required=True) | ||||||
|     filters = serializers.DictField(required=False) |     filters = serializers.DictField(required=False) | ||||||
|     actions = None |     actions = None | ||||||
|  |     pk_field = "pk" | ||||||
| 
 | 
 | ||||||
|     def __init__(self, *args, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         self.actions_by_name = {a.name: a for a in self.actions} |         self.actions_by_name = {a.name: a for a in self.actions} | ||||||
|  | @ -51,7 +117,9 @@ class ActionSerializer(serializers.Serializer): | ||||||
|         if value == "all": |         if value == "all": | ||||||
|             return self.queryset.all().order_by("id") |             return self.queryset.all().order_by("id") | ||||||
|         if type(value) in [list, tuple]: |         if type(value) in [list, tuple]: | ||||||
|             return self.queryset.filter(pk__in=value).order_by("id") |             return self.queryset.filter( | ||||||
|  |                 **{"{}__in".format(self.pk_field): value} | ||||||
|  |             ).order_by("id") | ||||||
| 
 | 
 | ||||||
|         raise serializers.ValidationError( |         raise serializers.ValidationError( | ||||||
|             "{} is not a valid value for objects. You must provide either a " |             "{} is not a valid value for objects. You must provide either a " | ||||||
|  |  | ||||||
|  | @ -3,9 +3,12 @@ from rest_framework.decorators import list_route | ||||||
| from rest_framework.permissions import IsAuthenticatedOrReadOnly | from rest_framework.permissions import IsAuthenticatedOrReadOnly | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| 
 | 
 | ||||||
|  | from django.db.models import Prefetch | ||||||
|  | 
 | ||||||
| from funkwhale_api.activity import record | from funkwhale_api.activity import record | ||||||
| from funkwhale_api.common import fields, permissions | from funkwhale_api.common import fields, permissions | ||||||
| from funkwhale_api.music.models import Track | from funkwhale_api.music.models import Track | ||||||
|  | from funkwhale_api.music import utils as music_utils | ||||||
| 
 | 
 | ||||||
| from . import filters, models, serializers | from . import filters, models, serializers | ||||||
| 
 | 
 | ||||||
|  | @ -19,11 +22,7 @@ class TrackFavoriteViewSet( | ||||||
| 
 | 
 | ||||||
|     filter_class = filters.TrackFavoriteFilter |     filter_class = filters.TrackFavoriteFilter | ||||||
|     serializer_class = serializers.UserTrackFavoriteSerializer |     serializer_class = serializers.UserTrackFavoriteSerializer | ||||||
|     queryset = ( |     queryset = models.TrackFavorite.objects.all().select_related("user") | ||||||
|         models.TrackFavorite.objects.all() |  | ||||||
|         .select_related("track__artist", "track__album__artist", "user") |  | ||||||
|         .prefetch_related("track__files") |  | ||||||
|     ) |  | ||||||
|     permission_classes = [ |     permission_classes = [ | ||||||
|         permissions.ConditionalAuthentication, |         permissions.ConditionalAuthentication, | ||||||
|         permissions.OwnerPermission, |         permissions.OwnerPermission, | ||||||
|  | @ -49,9 +48,14 @@ class TrackFavoriteViewSet( | ||||||
| 
 | 
 | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|         queryset = super().get_queryset() |         queryset = super().get_queryset() | ||||||
|         return queryset.filter( |         queryset = queryset.filter( | ||||||
|             fields.privacy_level_query(self.request.user, "user__privacy_level") |             fields.privacy_level_query(self.request.user, "user__privacy_level") | ||||||
|         ) |         ) | ||||||
|  |         tracks = Track.objects.annotate_playable_by_actor( | ||||||
|  |             music_utils.get_actor_from_request(self.request) | ||||||
|  |         ).select_related("artist", "album__artist") | ||||||
|  |         queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) | ||||||
|  |         return queryset | ||||||
| 
 | 
 | ||||||
|     def perform_create(self, serializer): |     def perform_create(self, serializer): | ||||||
|         track = Track.objects.get(pk=serializer.data["track"]) |         track = Track.objects.get(pk=serializer.data["track"]) | ||||||
|  |  | ||||||
|  | @ -1,3 +1,9 @@ | ||||||
|  | import uuid | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.common import utils as funkwhale_utils | ||||||
|  | 
 | ||||||
|  | PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public" | ||||||
|  | 
 | ||||||
| ACTIVITY_TYPES = [ | ACTIVITY_TYPES = [ | ||||||
|     "Accept", |     "Accept", | ||||||
|     "Add", |     "Add", | ||||||
|  | @ -58,4 +64,145 @@ def accept_follow(follow): | ||||||
|     from . import serializers |     from . import serializers | ||||||
| 
 | 
 | ||||||
|     serializer = serializers.AcceptFollowSerializer(follow) |     serializer = serializers.AcceptFollowSerializer(follow) | ||||||
|     return deliver(serializer.data, to=[follow.actor.url], on_behalf_of=follow.target) |     return deliver(serializer.data, to=[follow.actor.fid], on_behalf_of=follow.target) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def receive(activity, on_behalf_of): | ||||||
|  |     from . import models | ||||||
|  |     from . import serializers | ||||||
|  |     from . import tasks | ||||||
|  | 
 | ||||||
|  |     # we ensure the activity has the bare minimum structure before storing | ||||||
|  |     # it in our database | ||||||
|  |     serializer = serializers.BaseActivitySerializer( | ||||||
|  |         data=activity, context={"actor": on_behalf_of, "local_recipients": True} | ||||||
|  |     ) | ||||||
|  |     serializer.is_valid(raise_exception=True) | ||||||
|  |     copy = serializer.save() | ||||||
|  |     # we create inbox items for further delivery | ||||||
|  |     items = [ | ||||||
|  |         models.InboxItem(activity=copy, actor=r, type="to") | ||||||
|  |         for r in serializer.validated_data["recipients"]["to"] | ||||||
|  |         if hasattr(r, "fid") | ||||||
|  |     ] | ||||||
|  |     items += [ | ||||||
|  |         models.InboxItem(activity=copy, actor=r, type="cc") | ||||||
|  |         for r in serializer.validated_data["recipients"]["cc"] | ||||||
|  |         if hasattr(r, "fid") | ||||||
|  |     ] | ||||||
|  |     models.InboxItem.objects.bulk_create(items) | ||||||
|  |     # at this point, we have the activity in database. Even if we crash, it's | ||||||
|  |     # okay, as we can retry later | ||||||
|  |     tasks.dispatch_inbox.delay(activity_id=copy.pk) | ||||||
|  |     return copy | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Router: | ||||||
|  |     def __init__(self): | ||||||
|  |         self.routes = [] | ||||||
|  | 
 | ||||||
|  |     def connect(self, route, handler): | ||||||
|  |         self.routes.append((route, handler)) | ||||||
|  | 
 | ||||||
|  |     def register(self, route): | ||||||
|  |         def decorator(handler): | ||||||
|  |             self.connect(route, handler) | ||||||
|  |             return handler | ||||||
|  | 
 | ||||||
|  |         return decorator | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class InboxRouter(Router): | ||||||
|  |     def dispatch(self, payload, context): | ||||||
|  |         """ | ||||||
|  |         Receives an Activity payload and some context and trigger our | ||||||
|  |         business logic | ||||||
|  |         """ | ||||||
|  |         for route, handler in self.routes: | ||||||
|  |             if match_route(route, payload): | ||||||
|  |                 return handler(payload, context=context) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class OutboxRouter(Router): | ||||||
|  |     def dispatch(self, routing, context): | ||||||
|  |         """ | ||||||
|  |         Receives a routing payload and some business objects in the context | ||||||
|  |         and may yield data that should be persisted in the Activity model | ||||||
|  |         for further delivery. | ||||||
|  |         """ | ||||||
|  |         from . import models | ||||||
|  |         from . import tasks | ||||||
|  | 
 | ||||||
|  |         for route, handler in self.routes: | ||||||
|  |             if match_route(route, routing): | ||||||
|  |                 activities_data = [] | ||||||
|  |                 for e in handler(context): | ||||||
|  |                     # a route can yield zero, one or more activity payloads | ||||||
|  |                     if e: | ||||||
|  |                         activities_data.append(e) | ||||||
|  | 
 | ||||||
|  |                 inbox_items_by_activity_uuid = {} | ||||||
|  |                 prepared_activities = [] | ||||||
|  |                 for activity_data in activities_data: | ||||||
|  |                     to = activity_data.pop("to", []) | ||||||
|  |                     cc = activity_data.pop("cc", []) | ||||||
|  |                     a = models.Activity(**activity_data) | ||||||
|  |                     a.uuid = uuid.uuid4() | ||||||
|  |                     to_items, new_to = prepare_inbox_items(to, "to") | ||||||
|  |                     cc_items, new_cc = prepare_inbox_items(cc, "cc") | ||||||
|  |                     if not to_items and not cc_items: | ||||||
|  |                         continue | ||||||
|  |                     inbox_items_by_activity_uuid[str(a.uuid)] = to_items + cc_items | ||||||
|  |                     if new_to: | ||||||
|  |                         a.payload["to"] = new_to | ||||||
|  |                     if new_cc: | ||||||
|  |                         a.payload["cc"] = new_cc | ||||||
|  |                     prepared_activities.append(a) | ||||||
|  | 
 | ||||||
|  |                 activities = models.Activity.objects.bulk_create(prepared_activities) | ||||||
|  |                 activities = [a for a in activities if a] | ||||||
|  | 
 | ||||||
|  |                 final_inbox_items = [] | ||||||
|  |                 for a in activities: | ||||||
|  |                     try: | ||||||
|  |                         prepared_inbox_items = inbox_items_by_activity_uuid[str(a.uuid)] | ||||||
|  |                     except KeyError: | ||||||
|  |                         continue | ||||||
|  | 
 | ||||||
|  |                     for ii in prepared_inbox_items: | ||||||
|  |                         ii.activity = a | ||||||
|  |                         final_inbox_items.append(ii) | ||||||
|  | 
 | ||||||
|  |                 # create all inbox items, in bulk | ||||||
|  |                 models.InboxItem.objects.bulk_create(final_inbox_items) | ||||||
|  | 
 | ||||||
|  |                 for a in activities: | ||||||
|  |                     funkwhale_utils.on_commit( | ||||||
|  |                         tasks.dispatch_outbox.delay, activity_id=a.pk | ||||||
|  |                     ) | ||||||
|  |                 return activities | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def match_route(route, payload): | ||||||
|  |     for key, value in route.items(): | ||||||
|  |         if payload.get(key) != value: | ||||||
|  |             return False | ||||||
|  | 
 | ||||||
|  |     return True | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def prepare_inbox_items(recipient_list, type): | ||||||
|  |     from . import models | ||||||
|  | 
 | ||||||
|  |     items = [] | ||||||
|  |     new_list = []  # we return a list of actors url instead | ||||||
|  | 
 | ||||||
|  |     for r in recipient_list: | ||||||
|  |         if r != PUBLIC_ADDRESS: | ||||||
|  |             item = models.InboxItem(actor=r, type=type) | ||||||
|  |             items.append(item) | ||||||
|  |             new_list.append(r.fid) | ||||||
|  |         else: | ||||||
|  |             new_list.append(r) | ||||||
|  | 
 | ||||||
|  |     return items, new_list | ||||||
|  |  | ||||||
|  | @ -3,15 +3,11 @@ import logging | ||||||
| import xml | import xml | ||||||
| 
 | 
 | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db import transaction |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from rest_framework.exceptions import PermissionDenied | from rest_framework.exceptions import PermissionDenied | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import preferences, session | from funkwhale_api.common import preferences, session | ||||||
| from funkwhale_api.common import utils as funkwhale_utils |  | ||||||
| from funkwhale_api.music import models as music_models |  | ||||||
| from funkwhale_api.music import tasks as music_tasks |  | ||||||
| 
 | 
 | ||||||
| from . import activity, keys, models, serializers, signing, utils | from . import activity, keys, models, serializers, signing, utils | ||||||
| 
 | 
 | ||||||
|  | @ -39,9 +35,9 @@ def get_actor_data(actor_url): | ||||||
|         raise ValueError("Invalid actor payload: {}".format(response.text)) |         raise ValueError("Invalid actor payload: {}".format(response.text)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_actor(actor_url): | def get_actor(fid): | ||||||
|     try: |     try: | ||||||
|         actor = models.Actor.objects.get(url=actor_url) |         actor = models.Actor.objects.get(fid=fid) | ||||||
|     except models.Actor.DoesNotExist: |     except models.Actor.DoesNotExist: | ||||||
|         actor = None |         actor = None | ||||||
|     fetch_delta = datetime.timedelta( |     fetch_delta = datetime.timedelta( | ||||||
|  | @ -50,7 +46,7 @@ def get_actor(actor_url): | ||||||
|     if actor and actor.last_fetch_date > timezone.now() - fetch_delta: |     if actor and actor.last_fetch_date > timezone.now() - fetch_delta: | ||||||
|         # cache is hot, we can return as is |         # cache is hot, we can return as is | ||||||
|         return actor |         return actor | ||||||
|     data = get_actor_data(actor_url) |     data = get_actor_data(fid) | ||||||
|     serializer = serializers.ActorSerializer(data=data) |     serializer = serializers.ActorSerializer(data=data) | ||||||
|     serializer.is_valid(raise_exception=True) |     serializer.is_valid(raise_exception=True) | ||||||
| 
 | 
 | ||||||
|  | @ -72,7 +68,7 @@ class SystemActor(object): | ||||||
| 
 | 
 | ||||||
|     def get_actor_instance(self): |     def get_actor_instance(self): | ||||||
|         try: |         try: | ||||||
|             return models.Actor.objects.get(url=self.get_actor_url()) |             return models.Actor.objects.get(fid=self.get_actor_id()) | ||||||
|         except models.Actor.DoesNotExist: |         except models.Actor.DoesNotExist: | ||||||
|             pass |             pass | ||||||
|         private, public = keys.get_key_pair() |         private, public = keys.get_key_pair() | ||||||
|  | @ -83,7 +79,7 @@ class SystemActor(object): | ||||||
|         args["public_key"] = public.decode("utf-8") |         args["public_key"] = public.decode("utf-8") | ||||||
|         return models.Actor.objects.create(**args) |         return models.Actor.objects.create(**args) | ||||||
| 
 | 
 | ||||||
|     def get_actor_url(self): |     def get_actor_id(self): | ||||||
|         return utils.full_url( |         return utils.full_url( | ||||||
|             reverse("federation:instance-actors-detail", kwargs={"actor": self.id}) |             reverse("federation:instance-actors-detail", kwargs={"actor": self.id}) | ||||||
|         ) |         ) | ||||||
|  | @ -95,7 +91,7 @@ class SystemActor(object): | ||||||
|             "type": "Person", |             "type": "Person", | ||||||
|             "name": name.format(host=settings.FEDERATION_HOSTNAME), |             "name": name.format(host=settings.FEDERATION_HOSTNAME), | ||||||
|             "manually_approves_followers": True, |             "manually_approves_followers": True, | ||||||
|             "url": self.get_actor_url(), |             "fid": self.get_actor_id(), | ||||||
|             "shared_inbox_url": utils.full_url( |             "shared_inbox_url": utils.full_url( | ||||||
|                 reverse("federation:instance-actors-inbox", kwargs={"actor": id}) |                 reverse("federation:instance-actors-inbox", kwargs={"actor": id}) | ||||||
|             ), |             ), | ||||||
|  | @ -178,91 +174,13 @@ class SystemActor(object): | ||||||
|         if ac["object"]["type"] != "Follow": |         if ac["object"]["type"] != "Follow": | ||||||
|             return |             return | ||||||
| 
 | 
 | ||||||
|         if ac["object"]["actor"] != sender.url: |         if ac["object"]["actor"] != sender.fid: | ||||||
|             # not the same actor, permission issue |             # not the same actor, permission issue | ||||||
|             return |             return | ||||||
| 
 | 
 | ||||||
|         self.handle_undo_follow(ac, sender) |         self.handle_undo_follow(ac, sender) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class LibraryActor(SystemActor): |  | ||||||
|     id = "library" |  | ||||||
|     name = "{host}'s library" |  | ||||||
|     summary = "Bot account to federate with {host}'s library" |  | ||||||
|     additional_attributes = {"manually_approves_followers": True} |  | ||||||
| 
 |  | ||||||
|     def serialize(self): |  | ||||||
|         data = super().serialize() |  | ||||||
|         urls = data.setdefault("url", []) |  | ||||||
|         urls.append( |  | ||||||
|             { |  | ||||||
|                 "type": "Link", |  | ||||||
|                 "mediaType": "application/activity+json", |  | ||||||
|                 "name": "library", |  | ||||||
|                 "href": utils.full_url(reverse("federation:music:files-list")), |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|         return data |  | ||||||
| 
 |  | ||||||
|     @property |  | ||||||
|     def manually_approves_followers(self): |  | ||||||
|         return preferences.get("federation__music_needs_approval") |  | ||||||
| 
 |  | ||||||
|     @transaction.atomic |  | ||||||
|     def handle_create(self, ac, sender): |  | ||||||
|         try: |  | ||||||
|             remote_library = models.Library.objects.get( |  | ||||||
|                 actor=sender, federation_enabled=True |  | ||||||
|             ) |  | ||||||
|         except models.Library.DoesNotExist: |  | ||||||
|             logger.info("Skipping import, we're not following %s", sender.url) |  | ||||||
|             return |  | ||||||
| 
 |  | ||||||
|         if ac["object"]["type"] != "Collection": |  | ||||||
|             return |  | ||||||
| 
 |  | ||||||
|         if ac["object"]["totalItems"] <= 0: |  | ||||||
|             return |  | ||||||
| 
 |  | ||||||
|         try: |  | ||||||
|             items = ac["object"]["items"] |  | ||||||
|         except KeyError: |  | ||||||
|             logger.warning("No items in collection!") |  | ||||||
|             return |  | ||||||
| 
 |  | ||||||
|         item_serializers = [ |  | ||||||
|             serializers.AudioSerializer(data=i, context={"library": remote_library}) |  | ||||||
|             for i in items |  | ||||||
|         ] |  | ||||||
|         now = timezone.now() |  | ||||||
|         valid_serializers = [] |  | ||||||
|         for s in item_serializers: |  | ||||||
|             if s.is_valid(): |  | ||||||
|                 valid_serializers.append(s) |  | ||||||
|             else: |  | ||||||
|                 logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors) |  | ||||||
| 
 |  | ||||||
|         lts = [] |  | ||||||
|         for s in valid_serializers: |  | ||||||
|             lts.append(s.save()) |  | ||||||
| 
 |  | ||||||
|         if remote_library.autoimport: |  | ||||||
|             batch = music_models.ImportBatch.objects.create(source="federation") |  | ||||||
|             for lt in lts: |  | ||||||
|                 if lt.creation_date < now: |  | ||||||
|                     # track was already in the library, we do not trigger |  | ||||||
|                     # an import |  | ||||||
|                     continue |  | ||||||
|                 job = music_models.ImportJob.objects.create( |  | ||||||
|                     batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url |  | ||||||
|                 ) |  | ||||||
|                 funkwhale_utils.on_commit( |  | ||||||
|                     music_tasks.import_job_run.delay, |  | ||||||
|                     import_job_id=job.pk, |  | ||||||
|                     use_acoustid=False, |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class TestActor(SystemActor): | class TestActor(SystemActor): | ||||||
|     id = "test" |     id = "test" | ||||||
|     name = "{host}'s test account" |     name = "{host}'s test account" | ||||||
|  | @ -321,7 +239,7 @@ class TestActor(SystemActor): | ||||||
|                 {}, |                 {}, | ||||||
|             ], |             ], | ||||||
|             "type": "Create", |             "type": "Create", | ||||||
|             "actor": test_actor.url, |             "actor": test_actor.fid, | ||||||
|             "id": "{}/activity".format(reply_url), |             "id": "{}/activity".format(reply_url), | ||||||
|             "published": now.isoformat(), |             "published": now.isoformat(), | ||||||
|             "to": ac["actor"], |             "to": ac["actor"], | ||||||
|  | @ -336,14 +254,14 @@ class TestActor(SystemActor): | ||||||
|                 "sensitive": False, |                 "sensitive": False, | ||||||
|                 "url": reply_url, |                 "url": reply_url, | ||||||
|                 "to": [ac["actor"]], |                 "to": [ac["actor"]], | ||||||
|                 "attributedTo": test_actor.url, |                 "attributedTo": test_actor.fid, | ||||||
|                 "cc": [], |                 "cc": [], | ||||||
|                 "attachment": [], |                 "attachment": [], | ||||||
|                 "tag": [ |                 "tag": [ | ||||||
|                     { |                     { | ||||||
|                         "type": "Mention", |                         "type": "Mention", | ||||||
|                         "href": ac["actor"], |                         "href": ac["actor"], | ||||||
|                         "name": sender.mention_username, |                         "name": sender.full_username, | ||||||
|                     } |                     } | ||||||
|                 ], |                 ], | ||||||
|             }, |             }, | ||||||
|  | @ -359,7 +277,7 @@ class TestActor(SystemActor): | ||||||
|         )[0] |         )[0] | ||||||
|         activity.deliver( |         activity.deliver( | ||||||
|             serializers.FollowSerializer(follow_back).data, |             serializers.FollowSerializer(follow_back).data, | ||||||
|             to=[follow_back.target.url], |             to=[follow_back.target.fid], | ||||||
|             on_behalf_of=follow_back.actor, |             on_behalf_of=follow_back.actor, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  | @ -373,7 +291,7 @@ class TestActor(SystemActor): | ||||||
|             return |             return | ||||||
|         undo = serializers.UndoFollowSerializer(follow).data |         undo = serializers.UndoFollowSerializer(follow).data | ||||||
|         follow.delete() |         follow.delete() | ||||||
|         activity.deliver(undo, to=[sender.url], on_behalf_of=actor) |         activity.deliver(undo, to=[sender.fid], on_behalf_of=actor) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()} | SYSTEM_ACTORS = {"test": TestActor()} | ||||||
|  |  | ||||||
|  | @ -6,14 +6,14 @@ from . import models | ||||||
| @admin.register(models.Actor) | @admin.register(models.Actor) | ||||||
| class ActorAdmin(admin.ModelAdmin): | class ActorAdmin(admin.ModelAdmin): | ||||||
|     list_display = [ |     list_display = [ | ||||||
|         "url", |         "fid", | ||||||
|         "domain", |         "domain", | ||||||
|         "preferred_username", |         "preferred_username", | ||||||
|         "type", |         "type", | ||||||
|         "creation_date", |         "creation_date", | ||||||
|         "last_fetch_date", |         "last_fetch_date", | ||||||
|     ] |     ] | ||||||
|     search_fields = ["url", "domain", "preferred_username"] |     search_fields = ["fid", "domain", "preferred_username"] | ||||||
|     list_filter = ["type"] |     list_filter = ["type"] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -21,14 +21,14 @@ class ActorAdmin(admin.ModelAdmin): | ||||||
| class FollowAdmin(admin.ModelAdmin): | class FollowAdmin(admin.ModelAdmin): | ||||||
|     list_display = ["actor", "target", "approved", "creation_date"] |     list_display = ["actor", "target", "approved", "creation_date"] | ||||||
|     list_filter = ["approved"] |     list_filter = ["approved"] | ||||||
|     search_fields = ["actor__url", "target__url"] |     search_fields = ["actor__fid", "target__fid"] | ||||||
|     list_select_related = True |     list_select_related = True | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @admin.register(models.Library) | @admin.register(models.Library) | ||||||
| class LibraryAdmin(admin.ModelAdmin): | class LibraryAdmin(admin.ModelAdmin): | ||||||
|     list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"] |     list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"] | ||||||
|     search_fields = ["actor__url", "url"] |     search_fields = ["actor__fid", "url"] | ||||||
|     list_filter = ["federation_enabled", "download_files", "autoimport"] |     list_filter = ["federation_enabled", "download_files", "autoimport"] | ||||||
|     list_select_related = True |     list_select_related = True | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,57 @@ | ||||||
|  | from rest_framework import serializers | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.common import serializers as common_serializers | ||||||
|  | from funkwhale_api.music import models as music_models | ||||||
|  | 
 | ||||||
|  | from . import serializers as federation_serializers | ||||||
|  | from . import models | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class NestedLibraryFollowSerializer(serializers.ModelSerializer): | ||||||
|  |     class Meta: | ||||||
|  |         model = models.LibraryFollow | ||||||
|  |         fields = ["creation_date", "uuid", "fid", "approved", "modification_date"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibrarySerializer(serializers.ModelSerializer): | ||||||
|  |     actor = federation_serializers.APIActorSerializer() | ||||||
|  |     files_count = serializers.SerializerMethodField() | ||||||
|  |     follow = serializers.SerializerMethodField() | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = music_models.Library | ||||||
|  |         fields = [ | ||||||
|  |             "fid", | ||||||
|  |             "uuid", | ||||||
|  |             "actor", | ||||||
|  |             "name", | ||||||
|  |             "description", | ||||||
|  |             "creation_date", | ||||||
|  |             "files_count", | ||||||
|  |             "privacy_level", | ||||||
|  |             "follow", | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |     def get_files_count(self, o): | ||||||
|  |         return max(getattr(o, "_files_count", 0), o.files_count) | ||||||
|  | 
 | ||||||
|  |     def get_follow(self, o): | ||||||
|  |         try: | ||||||
|  |             return NestedLibraryFollowSerializer(o._follows[0]).data | ||||||
|  |         except (AttributeError, IndexError): | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibraryFollowSerializer(serializers.ModelSerializer): | ||||||
|  |     target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = models.LibraryFollow | ||||||
|  |         fields = ["creation_date", "uuid", "target", "approved"] | ||||||
|  |         read_only_fields = ["uuid", "approved", "creation_date"] | ||||||
|  | 
 | ||||||
|  |     def validate_target(self, v): | ||||||
|  |         actor = self.context["actor"] | ||||||
|  |         if v.received_follows.filter(actor=actor).exists(): | ||||||
|  |             raise serializers.ValidationError("You are already following this library") | ||||||
|  |         return v | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| from rest_framework import routers | from rest_framework import routers | ||||||
| 
 | 
 | ||||||
| from . import views | from . import api_views | ||||||
| 
 | 
 | ||||||
| router = routers.SimpleRouter() | router = routers.SimpleRouter() | ||||||
| router.register(r"libraries", views.LibraryViewSet, "libraries") | router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows") | ||||||
| router.register(r"library-tracks", views.LibraryTrackViewSet, "library-tracks") | router.register(r"libraries", api_views.LibraryViewSet, "libraries") | ||||||
| 
 | 
 | ||||||
| urlpatterns = router.urls | urlpatterns = router.urls | ||||||
|  |  | ||||||
|  | @ -0,0 +1,92 @@ | ||||||
|  | import requests.exceptions | ||||||
|  | 
 | ||||||
|  | from django.db.models import Count | ||||||
|  | 
 | ||||||
|  | from rest_framework import decorators | ||||||
|  | from rest_framework import mixins | ||||||
|  | from rest_framework import permissions | ||||||
|  | from rest_framework import response | ||||||
|  | from rest_framework import viewsets | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.music import models as music_models | ||||||
|  | 
 | ||||||
|  | from . import api_serializers | ||||||
|  | from . import filters | ||||||
|  | from . import models | ||||||
|  | from . import routes | ||||||
|  | from . import serializers | ||||||
|  | from . import utils | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibraryFollowViewSet( | ||||||
|  |     mixins.CreateModelMixin, | ||||||
|  |     mixins.ListModelMixin, | ||||||
|  |     mixins.RetrieveModelMixin, | ||||||
|  |     viewsets.GenericViewSet, | ||||||
|  | ): | ||||||
|  |     lookup_field = "uuid" | ||||||
|  |     queryset = ( | ||||||
|  |         models.LibraryFollow.objects.all() | ||||||
|  |         .order_by("-creation_date") | ||||||
|  |         .select_related("target__actor", "actor") | ||||||
|  |     ) | ||||||
|  |     serializer_class = api_serializers.LibraryFollowSerializer | ||||||
|  |     permission_classes = [permissions.IsAuthenticated] | ||||||
|  |     filter_class = filters.LibraryFollowFilter | ||||||
|  |     ordering_fields = ("creation_date",) | ||||||
|  | 
 | ||||||
|  |     def get_queryset(self): | ||||||
|  |         qs = super().get_queryset() | ||||||
|  |         return qs.filter(actor=self.request.user.actor) | ||||||
|  | 
 | ||||||
|  |     def perform_create(self, serializer): | ||||||
|  |         follow = serializer.save(actor=self.request.user.actor) | ||||||
|  |         routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow}) | ||||||
|  | 
 | ||||||
|  |     def get_serializer_context(self): | ||||||
|  |         context = super().get_serializer_context() | ||||||
|  |         context["actor"] = self.request.user.actor | ||||||
|  |         return context | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): | ||||||
|  |     lookup_field = "uuid" | ||||||
|  |     queryset = ( | ||||||
|  |         music_models.Library.objects.all() | ||||||
|  |         .order_by("-creation_date") | ||||||
|  |         .select_related("actor") | ||||||
|  |         .annotate(_files_count=Count("files")) | ||||||
|  |     ) | ||||||
|  |     serializer_class = api_serializers.LibrarySerializer | ||||||
|  |     permission_classes = [permissions.IsAuthenticated] | ||||||
|  |     filter_class = filters.LibraryFollowFilter | ||||||
|  |     ordering_fields = ("creation_date",) | ||||||
|  | 
 | ||||||
|  |     def get_queryset(self): | ||||||
|  |         qs = super().get_queryset() | ||||||
|  |         return qs.viewable_by(actor=self.request.user.actor) | ||||||
|  | 
 | ||||||
|  |     @decorators.list_route(methods=["post"]) | ||||||
|  |     def scan(self, request, *args, **kwargs): | ||||||
|  |         try: | ||||||
|  |             fid = request.data["fid"] | ||||||
|  |         except KeyError: | ||||||
|  |             return response.Response({"fid": ["This field is required"]}) | ||||||
|  |         try: | ||||||
|  |             library = utils.retrieve( | ||||||
|  |                 fid, | ||||||
|  |                 queryset=self.queryset, | ||||||
|  |                 serializer_class=serializers.LibrarySerializer, | ||||||
|  |             ) | ||||||
|  |         except requests.exceptions.RequestException as e: | ||||||
|  |             return response.Response( | ||||||
|  |                 {"detail": "Error while scanning the library: {}".format(str(e))}, | ||||||
|  |                 status=400, | ||||||
|  |             ) | ||||||
|  |         except serializers.serializers.ValidationError as e: | ||||||
|  |             return response.Response( | ||||||
|  |                 {"detail": "Invalid data in remote library: {}".format(str(e))}, | ||||||
|  |                 status=400, | ||||||
|  |             ) | ||||||
|  |         serializer = self.serializer_class(library) | ||||||
|  |         return response.Response({"count": 1, "results": [serializer.data]}) | ||||||
|  | @ -8,6 +8,7 @@ from django.utils import timezone | ||||||
| from django.utils.http import http_date | from django.utils.http import http_date | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.factories import registry | from funkwhale_api.factories import registry | ||||||
|  | from funkwhale_api.users import factories as user_factories | ||||||
| 
 | 
 | ||||||
| from . import keys, models | from . import keys, models | ||||||
| 
 | 
 | ||||||
|  | @ -61,6 +62,10 @@ class LinkFactory(factory.Factory): | ||||||
|         audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"])) |         audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"])) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def create_user(actor): | ||||||
|  |     return user_factories.UserFactory(actor=actor) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @registry.register | @registry.register | ||||||
| class ActorFactory(factory.DjangoModelFactory): | class ActorFactory(factory.DjangoModelFactory): | ||||||
|     public_key = None |     public_key = None | ||||||
|  | @ -68,7 +73,7 @@ class ActorFactory(factory.DjangoModelFactory): | ||||||
|     preferred_username = factory.Faker("user_name") |     preferred_username = factory.Faker("user_name") | ||||||
|     summary = factory.Faker("paragraph") |     summary = factory.Faker("paragraph") | ||||||
|     domain = factory.Faker("domain_name") |     domain = factory.Faker("domain_name") | ||||||
|     url = factory.LazyAttribute( |     fid = factory.LazyAttribute( | ||||||
|         lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username) |         lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username) | ||||||
|     ) |     ) | ||||||
|     inbox_url = factory.LazyAttribute( |     inbox_url = factory.LazyAttribute( | ||||||
|  | @ -81,20 +86,34 @@ class ActorFactory(factory.DjangoModelFactory): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Actor |         model = models.Actor | ||||||
| 
 | 
 | ||||||
|     class Params: |     @factory.post_generation | ||||||
|         local = factory.Trait( |     def local(self, create, extracted, **kwargs): | ||||||
|             domain=factory.LazyAttribute(lambda o: settings.FEDERATION_HOSTNAME) |         if not extracted and not kwargs: | ||||||
|         ) |             return | ||||||
|  |         from funkwhale_api.users.factories import UserFactory | ||||||
| 
 | 
 | ||||||
|     @classmethod |         self.domain = settings.FEDERATION_HOSTNAME | ||||||
|     def _generate(cls, create, attrs): |         self.save(update_fields=["domain"]) | ||||||
|         has_public = attrs.get("public_key") is not None |         if not create: | ||||||
|         has_private = attrs.get("private_key") is not None |             if extracted and hasattr(extracted, "pk"): | ||||||
|         if not has_public and not has_private: |                 extracted.actor = self | ||||||
|  |             else: | ||||||
|  |                 UserFactory.build(actor=self, **kwargs) | ||||||
|  |         if extracted and hasattr(extracted, "pk"): | ||||||
|  |             extracted.actor = self | ||||||
|  |             extracted.save(update_fields=["user"]) | ||||||
|  |         else: | ||||||
|  |             self.user = UserFactory(actor=self, **kwargs) | ||||||
|  | 
 | ||||||
|  |     @factory.post_generation | ||||||
|  |     def keys(self, create, extracted, **kwargs): | ||||||
|  |         if not create: | ||||||
|  |             # Simple build, do nothing. | ||||||
|  |             return | ||||||
|  |         if not extracted: | ||||||
|             private, public = keys.get_key_pair() |             private, public = keys.get_key_pair() | ||||||
|             attrs["private_key"] = private.decode("utf-8") |             self.private_key = private.decode("utf-8") | ||||||
|             attrs["public_key"] = public.decode("utf-8") |             self.public_key = public.decode("utf-8") | ||||||
|         return super()._generate(create, attrs) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @registry.register | @registry.register | ||||||
|  | @ -110,15 +129,70 @@ class FollowFactory(factory.DjangoModelFactory): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @registry.register | @registry.register | ||||||
| class LibraryFactory(factory.DjangoModelFactory): | class MusicLibraryFactory(factory.django.DjangoModelFactory): | ||||||
|     actor = factory.SubFactory(ActorFactory) |     actor = factory.SubFactory(ActorFactory) | ||||||
|     url = factory.Faker("url") |     privacy_level = "me" | ||||||
|     federation_enabled = True |     name = factory.Faker("sentence") | ||||||
|     download_files = False |     description = factory.Faker("sentence") | ||||||
|     autoimport = False |     files_count = 0 | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Library |         model = "music.Library" | ||||||
|  | 
 | ||||||
|  |     @factory.post_generation | ||||||
|  |     def fid(self, create, extracted, **kwargs): | ||||||
|  |         if not create: | ||||||
|  |             # Simple build, do nothing. | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         self.fid = extracted or self.get_federation_id() | ||||||
|  | 
 | ||||||
|  |     @factory.post_generation | ||||||
|  |     def followers_url(self, create, extracted, **kwargs): | ||||||
|  |         if not create: | ||||||
|  |             # Simple build, do nothing. | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         self.followers_url = extracted or self.fid + "/followers" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @registry.register | ||||||
|  | class LibraryScan(factory.django.DjangoModelFactory): | ||||||
|  |     library = factory.SubFactory(MusicLibraryFactory) | ||||||
|  |     actor = factory.SubFactory(ActorFactory) | ||||||
|  |     total_files = factory.LazyAttribute(lambda o: o.library.files_count) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = "music.LibraryScan" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @registry.register | ||||||
|  | class ActivityFactory(factory.django.DjangoModelFactory): | ||||||
|  |     actor = factory.SubFactory(ActorFactory) | ||||||
|  |     url = factory.Faker("url") | ||||||
|  |     payload = factory.LazyFunction(lambda: {"type": "Create"}) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = "federation.Activity" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @registry.register | ||||||
|  | class InboxItemFactory(factory.django.DjangoModelFactory): | ||||||
|  |     actor = factory.SubFactory(ActorFactory) | ||||||
|  |     activity = factory.SubFactory(ActivityFactory) | ||||||
|  |     type = "to" | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = "federation.InboxItem" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @registry.register | ||||||
|  | class LibraryFollowFactory(factory.DjangoModelFactory): | ||||||
|  |     target = factory.SubFactory(MusicLibraryFactory) | ||||||
|  |     actor = factory.SubFactory(ActorFactory) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = "federation.LibraryFollow" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ArtistMetadataFactory(factory.Factory): | class ArtistMetadataFactory(factory.Factory): | ||||||
|  | @ -161,25 +235,6 @@ class LibraryTrackMetadataFactory(factory.Factory): | ||||||
|         model = dict |         model = dict | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @registry.register |  | ||||||
| class LibraryTrackFactory(factory.DjangoModelFactory): |  | ||||||
|     library = factory.SubFactory(LibraryFactory) |  | ||||||
|     url = factory.Faker("url") |  | ||||||
|     title = factory.Faker("sentence") |  | ||||||
|     artist_name = factory.Faker("sentence") |  | ||||||
|     album_title = factory.Faker("sentence") |  | ||||||
|     audio_url = factory.Faker("url") |  | ||||||
|     audio_mimetype = "audio/ogg" |  | ||||||
|     metadata = factory.SubFactory(LibraryTrackMetadataFactory) |  | ||||||
|     published_date = factory.LazyFunction(timezone.now) |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.LibraryTrack |  | ||||||
| 
 |  | ||||||
|     class Params: |  | ||||||
|         with_audio_file = factory.Trait(audio_file=factory.django.FileField()) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @registry.register(name="federation.Note") | @registry.register(name="federation.Note") | ||||||
| class NoteFactory(factory.Factory): | class NoteFactory(factory.Factory): | ||||||
|     type = "Note" |     type = "Note" | ||||||
|  | @ -192,22 +247,6 @@ class NoteFactory(factory.Factory): | ||||||
|         model = dict |         model = dict | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @registry.register(name="federation.Activity") |  | ||||||
| class ActivityFactory(factory.Factory): |  | ||||||
|     type = "Create" |  | ||||||
|     id = factory.Faker("url") |  | ||||||
|     published = factory.LazyFunction(lambda: timezone.now().isoformat()) |  | ||||||
|     actor = factory.Faker("url") |  | ||||||
|     object = factory.SubFactory( |  | ||||||
|         NoteFactory, |  | ||||||
|         actor=factory.SelfAttribute("..actor"), |  | ||||||
|         published=factory.SelfAttribute("..published"), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = dict |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @registry.register(name="federation.AudioMetadata") | @registry.register(name="federation.AudioMetadata") | ||||||
| class AudioMetadataFactory(factory.Factory): | class AudioMetadataFactory(factory.Factory): | ||||||
|     recording = factory.LazyAttribute( |     recording = factory.LazyAttribute( | ||||||
|  |  | ||||||
|  | @ -1,68 +1,10 @@ | ||||||
| import django_filters | import django_filters | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import fields | from funkwhale_api.common import fields | ||||||
| from funkwhale_api.common import search |  | ||||||
| 
 | 
 | ||||||
| from . import models | from . import models | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class LibraryFilter(django_filters.FilterSet): |  | ||||||
|     approved = django_filters.BooleanFilter("following__approved") |  | ||||||
|     q = fields.SearchFilter(search_fields=["actor__domain"]) |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.Library |  | ||||||
|         fields = { |  | ||||||
|             "approved": ["exact"], |  | ||||||
|             "federation_enabled": ["exact"], |  | ||||||
|             "download_files": ["exact"], |  | ||||||
|             "autoimport": ["exact"], |  | ||||||
|             "tracks_count": ["exact"], |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class LibraryTrackFilter(django_filters.FilterSet): |  | ||||||
|     library = django_filters.CharFilter("library__uuid") |  | ||||||
|     status = django_filters.CharFilter(method="filter_status") |  | ||||||
|     q = fields.SmartSearchFilter( |  | ||||||
|         config=search.SearchConfig( |  | ||||||
|             search_fields={ |  | ||||||
|                 "domain": {"to": "library__actor__domain"}, |  | ||||||
|                 "artist": {"to": "artist_name"}, |  | ||||||
|                 "album": {"to": "album_title"}, |  | ||||||
|                 "title": {"to": "title"}, |  | ||||||
|             }, |  | ||||||
|             filter_fields={ |  | ||||||
|                 "domain": {"to": "library__actor__domain"}, |  | ||||||
|                 "artist": {"to": "artist_name__iexact"}, |  | ||||||
|                 "album": {"to": "album_title__iexact"}, |  | ||||||
|                 "title": {"to": "title__iexact"}, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     def filter_status(self, queryset, field_name, value): |  | ||||||
|         if value == "imported": |  | ||||||
|             return queryset.filter(local_track_file__isnull=False) |  | ||||||
|         elif value == "not_imported": |  | ||||||
|             return queryset.filter(local_track_file__isnull=True).exclude( |  | ||||||
|                 import_jobs__status="pending" |  | ||||||
|             ) |  | ||||||
|         elif value == "import_pending": |  | ||||||
|             return queryset.filter(import_jobs__status="pending") |  | ||||||
|         return queryset |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.LibraryTrack |  | ||||||
|         fields = { |  | ||||||
|             "library": ["exact"], |  | ||||||
|             "artist_name": ["exact", "icontains"], |  | ||||||
|             "title": ["exact", "icontains"], |  | ||||||
|             "album_title": ["exact", "icontains"], |  | ||||||
|             "audio_mimetype": ["exact", "icontains"], |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class FollowFilter(django_filters.FilterSet): | class FollowFilter(django_filters.FilterSet): | ||||||
|     pending = django_filters.CharFilter(method="filter_pending") |     pending = django_filters.CharFilter(method="filter_pending") | ||||||
|     ordering = django_filters.OrderingFilter( |     ordering = django_filters.OrderingFilter( | ||||||
|  | @ -84,3 +26,9 @@ class FollowFilter(django_filters.FilterSet): | ||||||
|         if value.lower() in ["true", "1", "yes"]: |         if value.lower() in ["true", "1", "yes"]: | ||||||
|             queryset = queryset.filter(approved__isnull=True) |             queryset = queryset.filter(approved__isnull=True) | ||||||
|         return queryset |         return queryset | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibraryFollowFilter(django_filters.FilterSet): | ||||||
|  |     class Meta: | ||||||
|  |         model = models.LibraryFollow | ||||||
|  |         fields = ["approved"] | ||||||
|  |  | ||||||
|  | @ -71,8 +71,7 @@ def scan_from_account_name(account_name): | ||||||
|     return data |     return data | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_library_data(library_url): | def get_library_data(library_url, actor): | ||||||
|     actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     auth = signing.get_auth(actor.private_key, actor.private_key_id) |     auth = signing.get_auth(actor.private_key, actor.private_key_id) | ||||||
|     try: |     try: | ||||||
|         response = session.get_session().get( |         response = session.get_session().get( | ||||||
|  | @ -98,8 +97,7 @@ def get_library_data(library_url): | ||||||
|     return serializer.validated_data |     return serializer.validated_data | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_library_page(library, page_url): | def get_library_page(library, page_url, actor): | ||||||
|     actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     auth = signing.get_auth(actor.private_key, actor.private_key_id) |     auth = signing.get_auth(actor.private_key, actor.private_key_id) | ||||||
|     response = session.get_session().get( |     response = session.get_session().get( | ||||||
|         page_url, |         page_url, | ||||||
|  |  | ||||||
|  | @ -0,0 +1,95 @@ | ||||||
|  | # Generated by Django 2.0.7 on 2018-08-07 17:48 | ||||||
|  | 
 | ||||||
|  | import django.contrib.postgres.fields.jsonb | ||||||
|  | import django.core.serializers.json | ||||||
|  | from django.db import migrations, models | ||||||
|  | import django.db.models.deletion | ||||||
|  | import django.utils.timezone | ||||||
|  | import uuid | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  | 
 | ||||||
|  |     dependencies = [("federation", "0006_auto_20180521_1702")] | ||||||
|  | 
 | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="Activity", | ||||||
|  |             fields=[ | ||||||
|  |                 ( | ||||||
|  |                     "id", | ||||||
|  |                     models.AutoField( | ||||||
|  |                         auto_created=True, | ||||||
|  |                         primary_key=True, | ||||||
|  |                         serialize=False, | ||||||
|  |                         verbose_name="ID", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)), | ||||||
|  |                 ( | ||||||
|  |                     "fid", | ||||||
|  |                     models.URLField(blank=True, max_length=500, null=True, unique=True), | ||||||
|  |                 ), | ||||||
|  |                 ("url", models.URLField(blank=True, max_length=500, null=True)), | ||||||
|  |                 ( | ||||||
|  |                     "payload", | ||||||
|  |                     django.contrib.postgres.fields.jsonb.JSONField( | ||||||
|  |                         default={}, | ||||||
|  |                         encoder=django.core.serializers.json.DjangoJSONEncoder, | ||||||
|  |                         max_length=50000, | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "creation_date", | ||||||
|  |                     models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |                 ), | ||||||
|  |                 ("delivered", models.NullBooleanField(default=None)), | ||||||
|  |                 ("delivered_date", models.DateTimeField(blank=True, null=True)), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="LibraryFollow", | ||||||
|  |             fields=[ | ||||||
|  |                 ( | ||||||
|  |                     "id", | ||||||
|  |                     models.AutoField( | ||||||
|  |                         auto_created=True, | ||||||
|  |                         primary_key=True, | ||||||
|  |                         serialize=False, | ||||||
|  |                         verbose_name="ID", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "fid", | ||||||
|  |                     models.URLField(blank=True, max_length=500, null=True, unique=True), | ||||||
|  |                 ), | ||||||
|  |                 ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)), | ||||||
|  |                 ( | ||||||
|  |                     "creation_date", | ||||||
|  |                     models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |                 ), | ||||||
|  |                 ("modification_date", models.DateTimeField(auto_now=True)), | ||||||
|  |                 ("approved", models.NullBooleanField(default=None)), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |         migrations.RenameField("actor", "url", "fid"), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="actor", | ||||||
|  |             name="url", | ||||||
|  |             field=models.URLField(blank=True, max_length=500, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="follow", | ||||||
|  |             name="fid", | ||||||
|  |             field=models.URLField(blank=True, max_length=500, null=True, unique=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="libraryfollow", | ||||||
|  |             name="actor", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="library_follows", | ||||||
|  |                 to="federation.Actor", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -0,0 +1,36 @@ | ||||||
|  | # Generated by Django 2.0.7 on 2018-08-07 17:48 | ||||||
|  | 
 | ||||||
|  | from django.db import migrations, models | ||||||
|  | import django.db.models.deletion | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  | 
 | ||||||
|  |     dependencies = [ | ||||||
|  |         ("music", "0029_auto_20180807_1748"), | ||||||
|  |         ("federation", "0007_auto_20180807_1748"), | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="libraryfollow", | ||||||
|  |             name="target", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="received_follows", | ||||||
|  |                 to="music.Library", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="activity", | ||||||
|  |             name="actor", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="activities", | ||||||
|  |                 to="federation.Actor", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterUniqueTogether( | ||||||
|  |             name="libraryfollow", unique_together={("actor", "target")} | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -0,0 +1,44 @@ | ||||||
|  | # Generated by Django 2.0.8 on 2018-08-22 19:56 | ||||||
|  | 
 | ||||||
|  | import django.contrib.postgres.fields.jsonb | ||||||
|  | import django.core.serializers.json | ||||||
|  | from django.db import migrations, models | ||||||
|  | import django.db.models.deletion | ||||||
|  | import funkwhale_api.federation.models | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  | 
 | ||||||
|  |     dependencies = [("federation", "0008_auto_20180807_1748")] | ||||||
|  | 
 | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="activity", | ||||||
|  |             name="recipient", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  |                 related_name="inbox_activities", | ||||||
|  |                 to="federation.Actor", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="activity", | ||||||
|  |             name="payload", | ||||||
|  |             field=django.contrib.postgres.fields.jsonb.JSONField( | ||||||
|  |                 default=funkwhale_api.federation.models.empty_dict, | ||||||
|  |                 encoder=django.core.serializers.json.DjangoJSONEncoder, | ||||||
|  |                 max_length=50000, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="librarytrack", | ||||||
|  |             name="metadata", | ||||||
|  |             field=django.contrib.postgres.fields.jsonb.JSONField( | ||||||
|  |                 default=funkwhale_api.federation.models.empty_dict, | ||||||
|  |                 encoder=django.core.serializers.json.DjangoJSONEncoder, | ||||||
|  |                 max_length=10000, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -0,0 +1,74 @@ | ||||||
|  | # Generated by Django 2.0.8 on 2018-09-04 20:11 | ||||||
|  | 
 | ||||||
|  | from django.db import migrations, models | ||||||
|  | import django.db.models.deletion | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  | 
 | ||||||
|  |     dependencies = [("federation", "0009_auto_20180822_1956")] | ||||||
|  | 
 | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="InboxItem", | ||||||
|  |             fields=[ | ||||||
|  |                 ( | ||||||
|  |                     "id", | ||||||
|  |                     models.AutoField( | ||||||
|  |                         auto_created=True, | ||||||
|  |                         primary_key=True, | ||||||
|  |                         serialize=False, | ||||||
|  |                         verbose_name="ID", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ("is_delivered", models.BooleanField(default=False)), | ||||||
|  |                 ( | ||||||
|  |                     "type", | ||||||
|  |                     models.CharField( | ||||||
|  |                         choices=[("to", "to"), ("cc", "cc")], max_length=10 | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ("last_delivery_date", models.DateTimeField(blank=True, null=True)), | ||||||
|  |                 ("delivery_attempts", models.PositiveIntegerField(default=0)), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |         migrations.RemoveField(model_name="activity", name="delivered"), | ||||||
|  |         migrations.RemoveField(model_name="activity", name="delivered_date"), | ||||||
|  |         migrations.RemoveField(model_name="activity", name="recipient"), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="activity", | ||||||
|  |             name="actor", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="outbox_activities", | ||||||
|  |                 to="federation.Actor", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="inboxitem", | ||||||
|  |             name="activity", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="inbox_items", | ||||||
|  |                 to="federation.Activity", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="inboxitem", | ||||||
|  |             name="actor", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="inbox_items", | ||||||
|  |                 to="federation.Actor", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="activity", | ||||||
|  |             name="recipients", | ||||||
|  |             field=models.ManyToManyField( | ||||||
|  |                 related_name="inbox_activities", | ||||||
|  |                 through="federation.InboxItem", | ||||||
|  |                 to="federation.Actor", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -3,6 +3,7 @@ import uuid | ||||||
| 
 | 
 | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.postgres.fields import JSONField | from django.contrib.postgres.fields import JSONField | ||||||
|  | from django.core.exceptions import ObjectDoesNotExist | ||||||
| from django.core.serializers.json import DjangoJSONEncoder | from django.core.serializers.json import DjangoJSONEncoder | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
|  | @ -11,6 +12,8 @@ from funkwhale_api.common import session | ||||||
| from funkwhale_api.common import utils as common_utils | from funkwhale_api.common import utils as common_utils | ||||||
| from funkwhale_api.music import utils as music_utils | from funkwhale_api.music import utils as music_utils | ||||||
| 
 | 
 | ||||||
|  | from . import utils as federation_utils | ||||||
|  | 
 | ||||||
| TYPE_CHOICES = [ | TYPE_CHOICES = [ | ||||||
|     ("Person", "Person"), |     ("Person", "Person"), | ||||||
|     ("Application", "Application"), |     ("Application", "Application"), | ||||||
|  | @ -20,15 +23,43 @@ TYPE_CHOICES = [ | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def empty_dict(): | ||||||
|  |     return {} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class FederationMixin(models.Model): | ||||||
|  |     # federation id/url | ||||||
|  |     fid = models.URLField(unique=True, max_length=500, db_index=True) | ||||||
|  |     url = models.URLField(max_length=500, null=True, blank=True) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         abstract = True | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class ActorQuerySet(models.QuerySet): | class ActorQuerySet(models.QuerySet): | ||||||
|     def local(self, include=True): |     def local(self, include=True): | ||||||
|         return self.exclude(user__isnull=include) |         return self.exclude(user__isnull=include) | ||||||
| 
 | 
 | ||||||
|  |     def with_current_usage(self): | ||||||
|  |         qs = self | ||||||
|  |         for s in ["pending", "skipped", "errored", "finished"]: | ||||||
|  |             qs = qs.annotate( | ||||||
|  |                 **{ | ||||||
|  |                     "_usage_{}".format(s): models.Sum( | ||||||
|  |                         "libraries__files__size", | ||||||
|  |                         filter=models.Q(libraries__files__import_status=s), | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         return qs | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class Actor(models.Model): | class Actor(models.Model): | ||||||
|     ap_type = "Actor" |     ap_type = "Actor" | ||||||
| 
 | 
 | ||||||
|     url = models.URLField(unique=True, max_length=500, db_index=True) |     fid = models.URLField(unique=True, max_length=500, db_index=True) | ||||||
|  |     url = models.URLField(max_length=500, null=True, blank=True) | ||||||
|     outbox_url = models.URLField(max_length=500) |     outbox_url = models.URLField(max_length=500) | ||||||
|     inbox_url = models.URLField(max_length=500) |     inbox_url = models.URLField(max_length=500) | ||||||
|     following_url = models.URLField(max_length=500, null=True, blank=True) |     following_url = models.URLField(max_length=500, null=True, blank=True) | ||||||
|  | @ -63,11 +94,14 @@ class Actor(models.Model): | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def private_key_id(self): |     def private_key_id(self): | ||||||
|         return "{}#main-key".format(self.url) |         return "{}#main-key".format(self.fid) | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def mention_username(self): |     def full_username(self): | ||||||
|         return "@{}@{}".format(self.preferred_username, self.domain) |         return "{}@{}".format(self.preferred_username, self.domain) | ||||||
|  | 
 | ||||||
|  |     def __str__(self): | ||||||
|  |         return "{}@{}".format(self.preferred_username, self.domain) | ||||||
| 
 | 
 | ||||||
|     def save(self, **kwargs): |     def save(self, **kwargs): | ||||||
|         lowercase_fields = ["domain"] |         lowercase_fields = ["domain"] | ||||||
|  | @ -104,26 +138,98 @@ class Actor(models.Model): | ||||||
|         follows = self.received_follows.filter(approved=True) |         follows = self.received_follows.filter(approved=True) | ||||||
|         return self.followers.filter(pk__in=follows.values_list("actor", flat=True)) |         return self.followers.filter(pk__in=follows.values_list("actor", flat=True)) | ||||||
| 
 | 
 | ||||||
|  |     def should_autoapprove_follow(self, actor): | ||||||
|  |         return False | ||||||
| 
 | 
 | ||||||
| class Follow(models.Model): |     def get_user(self): | ||||||
|     ap_type = "Follow" |         try: | ||||||
|  |             return self.user | ||||||
|  |         except ObjectDoesNotExist: | ||||||
|  |             return None | ||||||
| 
 | 
 | ||||||
|  |     def get_current_usage(self): | ||||||
|  |         actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get() | ||||||
|  |         data = {} | ||||||
|  |         for s in ["pending", "skipped", "errored", "finished"]: | ||||||
|  |             data[s] = getattr(actor, "_usage_{}".format(s)) or 0 | ||||||
|  | 
 | ||||||
|  |         data["total"] = sum(data.values()) | ||||||
|  |         return data | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class InboxItemQuerySet(models.QuerySet): | ||||||
|  |     def local(self, include=True): | ||||||
|  |         return self.exclude(actor__user__isnull=include) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class InboxItem(models.Model): | ||||||
|  |     actor = models.ForeignKey( | ||||||
|  |         Actor, related_name="inbox_items", on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  |     activity = models.ForeignKey( | ||||||
|  |         "Activity", related_name="inbox_items", on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  |     is_delivered = models.BooleanField(default=False) | ||||||
|  |     type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")]) | ||||||
|  |     last_delivery_date = models.DateTimeField(null=True, blank=True) | ||||||
|  |     delivery_attempts = models.PositiveIntegerField(default=0) | ||||||
|  | 
 | ||||||
|  |     objects = InboxItemQuerySet.as_manager() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Activity(models.Model): | ||||||
|  |     actor = models.ForeignKey( | ||||||
|  |         Actor, related_name="outbox_activities", on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  |     recipients = models.ManyToManyField( | ||||||
|  |         Actor, related_name="inbox_activities", through=InboxItem | ||||||
|  |     ) | ||||||
|     uuid = models.UUIDField(default=uuid.uuid4, unique=True) |     uuid = models.UUIDField(default=uuid.uuid4, unique=True) | ||||||
|  |     fid = models.URLField(unique=True, max_length=500, null=True, blank=True) | ||||||
|  |     url = models.URLField(max_length=500, null=True, blank=True) | ||||||
|  |     payload = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder) | ||||||
|  |     creation_date = models.DateTimeField(default=timezone.now) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class AbstractFollow(models.Model): | ||||||
|  |     ap_type = "Follow" | ||||||
|  |     fid = models.URLField(unique=True, max_length=500, null=True, blank=True) | ||||||
|  |     uuid = models.UUIDField(default=uuid.uuid4, unique=True) | ||||||
|  |     creation_date = models.DateTimeField(default=timezone.now) | ||||||
|  |     modification_date = models.DateTimeField(auto_now=True) | ||||||
|  |     approved = models.NullBooleanField(default=None) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         abstract = True | ||||||
|  | 
 | ||||||
|  |     def get_federation_id(self): | ||||||
|  |         return federation_utils.full_url( | ||||||
|  |             "{}#follows/{}".format(self.actor.fid, self.uuid) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Follow(AbstractFollow): | ||||||
|     actor = models.ForeignKey( |     actor = models.ForeignKey( | ||||||
|         Actor, related_name="emitted_follows", on_delete=models.CASCADE |         Actor, related_name="emitted_follows", on_delete=models.CASCADE | ||||||
|     ) |     ) | ||||||
|     target = models.ForeignKey( |     target = models.ForeignKey( | ||||||
|         Actor, related_name="received_follows", on_delete=models.CASCADE |         Actor, related_name="received_follows", on_delete=models.CASCADE | ||||||
|     ) |     ) | ||||||
|     creation_date = models.DateTimeField(default=timezone.now) |  | ||||||
|     modification_date = models.DateTimeField(auto_now=True) |  | ||||||
|     approved = models.NullBooleanField(default=None) |  | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         unique_together = ["actor", "target"] |         unique_together = ["actor", "target"] | ||||||
| 
 | 
 | ||||||
|     def get_federation_url(self): | 
 | ||||||
|         return "{}#follows/{}".format(self.actor.url, self.uuid) | class LibraryFollow(AbstractFollow): | ||||||
|  |     actor = models.ForeignKey( | ||||||
|  |         Actor, related_name="library_follows", on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  |     target = models.ForeignKey( | ||||||
|  |         "music.Library", related_name="received_follows", on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         unique_together = ["actor", "target"] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Library(models.Model): | class Library(models.Model): | ||||||
|  | @ -167,7 +273,9 @@ class LibraryTrack(models.Model): | ||||||
|     artist_name = models.CharField(max_length=500) |     artist_name = models.CharField(max_length=500) | ||||||
|     album_title = models.CharField(max_length=500) |     album_title = models.CharField(max_length=500) | ||||||
|     title = models.CharField(max_length=500) |     title = models.CharField(max_length=500) | ||||||
|     metadata = JSONField(default={}, max_length=10000, encoder=DjangoJSONEncoder) |     metadata = JSONField( | ||||||
|  |         default=empty_dict, max_length=10000, encoder=DjangoJSONEncoder | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     @property |     @property | ||||||
|     def mbid(self): |     def mbid(self): | ||||||
|  |  | ||||||
|  | @ -1,19 +0,0 @@ | ||||||
| 
 |  | ||||||
| from rest_framework.permissions import BasePermission |  | ||||||
| 
 |  | ||||||
| from funkwhale_api.common import preferences |  | ||||||
| 
 |  | ||||||
| from . import actors |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class LibraryFollower(BasePermission): |  | ||||||
|     def has_permission(self, request, view): |  | ||||||
|         if not preferences.get("federation__music_needs_approval"): |  | ||||||
|             return True |  | ||||||
| 
 |  | ||||||
|         actor = getattr(request, "actor", None) |  | ||||||
|         if actor is None: |  | ||||||
|             return False |  | ||||||
| 
 |  | ||||||
|         library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|         return library.received_follows.filter(approved=True, actor=actor).exists() |  | ||||||
|  | @ -0,0 +1,78 @@ | ||||||
|  | import logging | ||||||
|  | 
 | ||||||
|  | from . import activity | ||||||
|  | from . import serializers | ||||||
|  | 
 | ||||||
|  | logger = logging.getLogger(__name__) | ||||||
|  | inbox = activity.InboxRouter() | ||||||
|  | outbox = activity.OutboxRouter() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def with_recipients(payload, to=[], cc=[]): | ||||||
|  |     if to: | ||||||
|  |         payload["to"] = to | ||||||
|  |     if cc: | ||||||
|  |         payload["cc"] = cc | ||||||
|  |     return payload | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @inbox.register({"type": "Follow"}) | ||||||
|  | def inbox_follow(payload, context): | ||||||
|  |     context["recipient"] = [ | ||||||
|  |         ii.actor for ii in context["inbox_items"] if ii.type == "to" | ||||||
|  |     ][0] | ||||||
|  |     serializer = serializers.FollowSerializer(data=payload, context=context) | ||||||
|  |     if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): | ||||||
|  |         logger.debug( | ||||||
|  |             "Discarding invalid follow from {}: %s", | ||||||
|  |             context["actor"].fid, | ||||||
|  |             serializer.errors, | ||||||
|  |         ) | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     autoapprove = serializer.validated_data["object"].should_autoapprove_follow( | ||||||
|  |         context["actor"] | ||||||
|  |     ) | ||||||
|  |     follow = serializer.save(approved=autoapprove) | ||||||
|  | 
 | ||||||
|  |     if autoapprove: | ||||||
|  |         activity.accept_follow(follow) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @inbox.register({"type": "Accept"}) | ||||||
|  | def inbox_accept(payload, context): | ||||||
|  |     context["recipient"] = [ | ||||||
|  |         ii.actor for ii in context["inbox_items"] if ii.type == "to" | ||||||
|  |     ][0] | ||||||
|  |     serializer = serializers.AcceptFollowSerializer(data=payload, context=context) | ||||||
|  |     if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): | ||||||
|  |         logger.debug( | ||||||
|  |             "Discarding invalid accept from {}: %s", | ||||||
|  |             context["actor"].fid, | ||||||
|  |             serializer.errors, | ||||||
|  |         ) | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     serializer.save() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @outbox.register({"type": "Accept"}) | ||||||
|  | def outbox_accept(context): | ||||||
|  |     follow = context["follow"] | ||||||
|  |     if follow._meta.label == "federation.LibraryFollow": | ||||||
|  |         actor = follow.target.actor | ||||||
|  |     else: | ||||||
|  |         actor = follow.target | ||||||
|  |     payload = serializers.AcceptFollowSerializer(follow, context={"actor": actor}).data | ||||||
|  |     yield {"actor": actor, "payload": with_recipients(payload, to=[follow.actor])} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @outbox.register({"type": "Follow"}) | ||||||
|  | def outbox_follow(context): | ||||||
|  |     follow = context["follow"] | ||||||
|  |     if follow._meta.label == "federation.LibraryFollow": | ||||||
|  |         target = follow.target.actor | ||||||
|  |     else: | ||||||
|  |         target = follow.target | ||||||
|  |     payload = serializers.FollowSerializer(follow, context={"actor": follow.actor}).data | ||||||
|  |     yield {"actor": follow.actor, "payload": with_recipients(payload, to=[target])} | ||||||
|  | @ -4,15 +4,12 @@ import urllib.parse | ||||||
| 
 | 
 | ||||||
| from django.core.exceptions import ObjectDoesNotExist | from django.core.exceptions import ObjectDoesNotExist | ||||||
| from django.core.paginator import Paginator | from django.core.paginator import Paginator | ||||||
| from django.db import transaction |  | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import serializers as common_serializers |  | ||||||
| from funkwhale_api.common import utils as funkwhale_utils | from funkwhale_api.common import utils as funkwhale_utils | ||||||
| from funkwhale_api.music import models as music_models | from funkwhale_api.music import models as music_models | ||||||
| from funkwhale_api.music import tasks as music_tasks |  | ||||||
| 
 | 
 | ||||||
| from . import activity, filters, models, utils | from . import activity, models, utils | ||||||
| 
 | 
 | ||||||
| AP_CONTEXT = [ | AP_CONTEXT = [ | ||||||
|     "https://www.w3.org/ns/activitystreams", |     "https://www.w3.org/ns/activitystreams", | ||||||
|  | @ -38,7 +35,7 @@ class ActorSerializer(serializers.Serializer): | ||||||
| 
 | 
 | ||||||
|     def to_representation(self, instance): |     def to_representation(self, instance): | ||||||
|         ret = { |         ret = { | ||||||
|             "id": instance.url, |             "id": instance.fid, | ||||||
|             "outbox": instance.outbox_url, |             "outbox": instance.outbox_url, | ||||||
|             "inbox": instance.inbox_url, |             "inbox": instance.inbox_url, | ||||||
|             "preferredUsername": instance.preferred_username, |             "preferredUsername": instance.preferred_username, | ||||||
|  | @ -58,9 +55,9 @@ class ActorSerializer(serializers.Serializer): | ||||||
|         ret["@context"] = AP_CONTEXT |         ret["@context"] = AP_CONTEXT | ||||||
|         if instance.public_key: |         if instance.public_key: | ||||||
|             ret["publicKey"] = { |             ret["publicKey"] = { | ||||||
|                 "owner": instance.url, |                 "owner": instance.fid, | ||||||
|                 "publicKeyPem": instance.public_key, |                 "publicKeyPem": instance.public_key, | ||||||
|                 "id": "{}#main-key".format(instance.url), |                 "id": "{}#main-key".format(instance.fid), | ||||||
|             } |             } | ||||||
|         ret["endpoints"] = {} |         ret["endpoints"] = {} | ||||||
|         if instance.shared_inbox_url: |         if instance.shared_inbox_url: | ||||||
|  | @ -78,7 +75,7 @@ class ActorSerializer(serializers.Serializer): | ||||||
| 
 | 
 | ||||||
|     def prepare_missing_fields(self): |     def prepare_missing_fields(self): | ||||||
|         kwargs = { |         kwargs = { | ||||||
|             "url": self.validated_data["id"], |             "fid": self.validated_data["id"], | ||||||
|             "outbox_url": self.validated_data["outbox"], |             "outbox_url": self.validated_data["outbox"], | ||||||
|             "inbox_url": self.validated_data["inbox"], |             "inbox_url": self.validated_data["inbox"], | ||||||
|             "following_url": self.validated_data.get("following"), |             "following_url": self.validated_data.get("following"), | ||||||
|  | @ -91,7 +88,7 @@ class ActorSerializer(serializers.Serializer): | ||||||
|         maf = self.validated_data.get("manuallyApprovesFollowers") |         maf = self.validated_data.get("manuallyApprovesFollowers") | ||||||
|         if maf is not None: |         if maf is not None: | ||||||
|             kwargs["manually_approves_followers"] = maf |             kwargs["manually_approves_followers"] = maf | ||||||
|         domain = urllib.parse.urlparse(kwargs["url"]).netloc |         domain = urllib.parse.urlparse(kwargs["fid"]).netloc | ||||||
|         kwargs["domain"] = domain |         kwargs["domain"] = domain | ||||||
|         for endpoint, url in self.initial_data.get("endpoints", {}).items(): |         for endpoint, url in self.initial_data.get("endpoints", {}).items(): | ||||||
|             if endpoint == "sharedInbox": |             if endpoint == "sharedInbox": | ||||||
|  | @ -110,7 +107,7 @@ class ActorSerializer(serializers.Serializer): | ||||||
|     def save(self, **kwargs): |     def save(self, **kwargs): | ||||||
|         d = self.prepare_missing_fields() |         d = self.prepare_missing_fields() | ||||||
|         d.update(kwargs) |         d.update(kwargs) | ||||||
|         return models.Actor.objects.update_or_create(url=d["url"], defaults=d)[0] |         return models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0] | ||||||
| 
 | 
 | ||||||
|     def validate_summary(self, value): |     def validate_summary(self, value): | ||||||
|         if value: |         if value: | ||||||
|  | @ -122,6 +119,7 @@ class APIActorSerializer(serializers.ModelSerializer): | ||||||
|         model = models.Actor |         model = models.Actor | ||||||
|         fields = [ |         fields = [ | ||||||
|             "id", |             "id", | ||||||
|  |             "fid", | ||||||
|             "url", |             "url", | ||||||
|             "creation_date", |             "creation_date", | ||||||
|             "summary", |             "summary", | ||||||
|  | @ -131,190 +129,73 @@ class APIActorSerializer(serializers.ModelSerializer): | ||||||
|             "domain", |             "domain", | ||||||
|             "type", |             "type", | ||||||
|             "manually_approves_followers", |             "manually_approves_followers", | ||||||
|  |             "full_username", | ||||||
|         ] |         ] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class LibraryActorSerializer(ActorSerializer): | class BaseActivitySerializer(serializers.Serializer): | ||||||
|     url = serializers.ListField(child=serializers.JSONField()) |     id = serializers.URLField(max_length=500, required=False) | ||||||
| 
 |     type = serializers.CharField(max_length=100) | ||||||
|     def validate(self, validated_data): |  | ||||||
|         try: |  | ||||||
|             urls = validated_data["url"] |  | ||||||
|         except KeyError: |  | ||||||
|             raise serializers.ValidationError("Missing URL field") |  | ||||||
| 
 |  | ||||||
|         for u in urls: |  | ||||||
|             try: |  | ||||||
|                 if u["name"] != "library": |  | ||||||
|                     continue |  | ||||||
|                 validated_data["library_url"] = u["href"] |  | ||||||
|                 break |  | ||||||
|             except KeyError: |  | ||||||
|                 continue |  | ||||||
| 
 |  | ||||||
|         return validated_data |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class APIFollowSerializer(serializers.ModelSerializer): |  | ||||||
|     class Meta: |  | ||||||
|         model = models.Follow |  | ||||||
|         fields = [ |  | ||||||
|             "uuid", |  | ||||||
|             "actor", |  | ||||||
|             "target", |  | ||||||
|             "approved", |  | ||||||
|             "creation_date", |  | ||||||
|             "modification_date", |  | ||||||
|         ] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class APILibrarySerializer(serializers.ModelSerializer): |  | ||||||
|     actor = APIActorSerializer() |  | ||||||
|     follow = APIFollowSerializer() |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.Library |  | ||||||
| 
 |  | ||||||
|         read_only_fields = [ |  | ||||||
|             "actor", |  | ||||||
|             "uuid", |  | ||||||
|             "url", |  | ||||||
|             "tracks_count", |  | ||||||
|             "follow", |  | ||||||
|             "fetched_date", |  | ||||||
|             "modification_date", |  | ||||||
|             "creation_date", |  | ||||||
|         ] |  | ||||||
|         fields = [ |  | ||||||
|             "autoimport", |  | ||||||
|             "federation_enabled", |  | ||||||
|             "download_files", |  | ||||||
|         ] + read_only_fields |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class APILibraryScanSerializer(serializers.Serializer): |  | ||||||
|     until = serializers.DateTimeField(required=False) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class APILibraryFollowUpdateSerializer(serializers.Serializer): |  | ||||||
|     follow = serializers.IntegerField() |  | ||||||
|     approved = serializers.BooleanField() |  | ||||||
| 
 |  | ||||||
|     def validate_follow(self, value): |  | ||||||
|         from . import actors |  | ||||||
| 
 |  | ||||||
|         library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|         qs = models.Follow.objects.filter(pk=value, target=library_actor) |  | ||||||
|         try: |  | ||||||
|             return qs.get() |  | ||||||
|         except models.Follow.DoesNotExist: |  | ||||||
|             raise serializers.ValidationError("Invalid follow") |  | ||||||
| 
 |  | ||||||
|     def save(self): |  | ||||||
|         new_status = self.validated_data["approved"] |  | ||||||
|         follow = self.validated_data["follow"] |  | ||||||
|         if new_status == follow.approved: |  | ||||||
|             return follow |  | ||||||
| 
 |  | ||||||
|         follow.approved = new_status |  | ||||||
|         follow.save(update_fields=["approved", "modification_date"]) |  | ||||||
|         if new_status: |  | ||||||
|             activity.accept_follow(follow) |  | ||||||
|         return follow |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class APILibraryCreateSerializer(serializers.ModelSerializer): |  | ||||||
|     actor = serializers.URLField(max_length=500) |     actor = serializers.URLField(max_length=500) | ||||||
|     federation_enabled = serializers.BooleanField() |  | ||||||
|     uuid = serializers.UUIDField(read_only=True) |  | ||||||
| 
 | 
 | ||||||
|     class Meta: |     def validate_actor(self, v): | ||||||
|         model = models.Library |         expected = self.context.get("actor") | ||||||
|         fields = ["uuid", "actor", "autoimport", "federation_enabled", "download_files"] |         if expected and expected.fid != v: | ||||||
| 
 |             raise serializers.ValidationError("Invalid actor") | ||||||
|     def validate(self, validated_data): |         if expected: | ||||||
|         from . import actors |             # avoid a DB lookup | ||||||
|         from . import library |             return expected | ||||||
| 
 |  | ||||||
|         actor_url = validated_data["actor"] |  | ||||||
|         actor_data = actors.get_actor_data(actor_url) |  | ||||||
|         acs = LibraryActorSerializer(data=actor_data) |  | ||||||
|         acs.is_valid(raise_exception=True) |  | ||||||
|         try: |         try: | ||||||
|             actor = models.Actor.objects.get(url=actor_url) |             return models.Actor.objects.get(fid=v) | ||||||
|         except models.Actor.DoesNotExist: |         except models.Actor.DoesNotExist: | ||||||
|             actor = acs.save() |             raise serializers.ValidationError("Actor not found") | ||||||
|         library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|         validated_data["follow"] = models.Follow.objects.get_or_create( |  | ||||||
|             actor=library_actor, target=actor |  | ||||||
|         )[0] |  | ||||||
|         if validated_data["follow"].approved is None: |  | ||||||
|             funkwhale_utils.on_commit( |  | ||||||
|                 activity.deliver, |  | ||||||
|                 FollowSerializer(validated_data["follow"]).data, |  | ||||||
|                 on_behalf_of=validated_data["follow"].actor, |  | ||||||
|                 to=[validated_data["follow"].target.url], |  | ||||||
|             ) |  | ||||||
| 
 |  | ||||||
|         library_data = library.get_library_data(acs.validated_data["library_url"]) |  | ||||||
|         if "errors" in library_data: |  | ||||||
|             # we pass silently because it may means we require permission |  | ||||||
|             # before scanning |  | ||||||
|             pass |  | ||||||
|         validated_data["library"] = library_data |  | ||||||
|         validated_data["library"].setdefault("id", acs.validated_data["library_url"]) |  | ||||||
|         validated_data["actor"] = actor |  | ||||||
|         return validated_data |  | ||||||
| 
 | 
 | ||||||
|     def create(self, validated_data): |     def create(self, validated_data): | ||||||
|         library = models.Library.objects.update_or_create( |         return models.Activity.objects.create( | ||||||
|             url=validated_data["library"]["id"], |             fid=validated_data.get("id"), | ||||||
|             defaults={ |             actor=validated_data["actor"], | ||||||
|                 "actor": validated_data["actor"], |             payload=self.initial_data, | ||||||
|                 "follow": validated_data["follow"], |         ) | ||||||
|                 "tracks_count": validated_data["library"].get("totalItems"), |  | ||||||
|                 "federation_enabled": validated_data["federation_enabled"], |  | ||||||
|                 "autoimport": validated_data["autoimport"], |  | ||||||
|                 "download_files": validated_data["download_files"], |  | ||||||
|             }, |  | ||||||
|         )[0] |  | ||||||
|         return library |  | ||||||
| 
 | 
 | ||||||
|  |     def validate(self, data): | ||||||
|  |         data["recipients"] = self.validate_recipients(self.initial_data) | ||||||
|  |         return super().validate(data) | ||||||
| 
 | 
 | ||||||
| class APILibraryTrackSerializer(serializers.ModelSerializer): |     def validate_recipients(self, payload): | ||||||
|     library = APILibrarySerializer() |         """ | ||||||
|     status = serializers.SerializerMethodField() |         Ensure we have at least a to/cc field with valid actors | ||||||
|  |         """ | ||||||
|  |         to = payload.get("to", []) | ||||||
|  |         cc = payload.get("cc", []) | ||||||
| 
 | 
 | ||||||
|     class Meta: |         if not to and not cc: | ||||||
|         model = models.LibraryTrack |             raise serializers.ValidationError( | ||||||
|         fields = [ |                 "We cannot handle an activity with no recipient" | ||||||
|             "id", |             ) | ||||||
|             "url", |  | ||||||
|             "audio_url", |  | ||||||
|             "audio_mimetype", |  | ||||||
|             "creation_date", |  | ||||||
|             "modification_date", |  | ||||||
|             "fetched_date", |  | ||||||
|             "published_date", |  | ||||||
|             "metadata", |  | ||||||
|             "artist_name", |  | ||||||
|             "album_title", |  | ||||||
|             "title", |  | ||||||
|             "library", |  | ||||||
|             "local_track_file", |  | ||||||
|             "status", |  | ||||||
|         ] |  | ||||||
| 
 | 
 | ||||||
|     def get_status(self, o): |         matching = models.Actor.objects.filter(fid__in=to + cc) | ||||||
|  |         if self.context.get("local_recipients", False): | ||||||
|  |             matching = matching.local() | ||||||
|  | 
 | ||||||
|  |         if not len(matching): | ||||||
|  |             raise serializers.ValidationError("No matching recipients found") | ||||||
|  | 
 | ||||||
|  |         actors_by_fid = {a.fid: a for a in matching} | ||||||
|  | 
 | ||||||
|  |         def match(recipients, actors): | ||||||
|  |             for r in recipients: | ||||||
|  |                 if r == activity.PUBLIC_ADDRESS: | ||||||
|  |                     yield r | ||||||
|  |                 else: | ||||||
|                     try: |                     try: | ||||||
|             if o.local_track_file is not None: |                         yield actors[r] | ||||||
|                 return "imported" |                     except KeyError: | ||||||
|         except music_models.TrackFile.DoesNotExist: |  | ||||||
|                         pass |                         pass | ||||||
|         for job in o.import_jobs.all(): | 
 | ||||||
|             if job.status == "pending": |         return { | ||||||
|                 return "import_pending" |             "to": list(match(to, actors_by_fid)), | ||||||
|         return "not_imported" |             "cc": list(match(cc, actors_by_fid)), | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class FollowSerializer(serializers.Serializer): | class FollowSerializer(serializers.Serializer): | ||||||
|  | @ -325,35 +206,61 @@ class FollowSerializer(serializers.Serializer): | ||||||
| 
 | 
 | ||||||
|     def validate_object(self, v): |     def validate_object(self, v): | ||||||
|         expected = self.context.get("follow_target") |         expected = self.context.get("follow_target") | ||||||
|         if expected and expected.url != v: |         if self.parent: | ||||||
|  |             # it's probably an accept, so everything is inverted, the actor | ||||||
|  |             # the recipient does not matter | ||||||
|  |             recipient = None | ||||||
|  |         else: | ||||||
|  |             recipient = self.context.get("recipient") | ||||||
|  |         if expected and expected.fid != v: | ||||||
|             raise serializers.ValidationError("Invalid target") |             raise serializers.ValidationError("Invalid target") | ||||||
|         try: |         try: | ||||||
|             return models.Actor.objects.get(url=v) |             obj = models.Actor.objects.get(fid=v) | ||||||
|  |             if recipient and recipient.fid != obj.fid: | ||||||
|  |                 raise serializers.ValidationError("Invalid target") | ||||||
|  |             return obj | ||||||
|         except models.Actor.DoesNotExist: |         except models.Actor.DoesNotExist: | ||||||
|  |             pass | ||||||
|  |         try: | ||||||
|  |             qs = music_models.Library.objects.filter(fid=v) | ||||||
|  |             if recipient: | ||||||
|  |                 qs = qs.filter(actor=recipient) | ||||||
|  |             return qs.get() | ||||||
|  |         except music_models.Library.DoesNotExist: | ||||||
|  |             pass | ||||||
|  | 
 | ||||||
|         raise serializers.ValidationError("Target not found") |         raise serializers.ValidationError("Target not found") | ||||||
| 
 | 
 | ||||||
|     def validate_actor(self, v): |     def validate_actor(self, v): | ||||||
|         expected = self.context.get("follow_actor") |         expected = self.context.get("follow_actor") | ||||||
|         if expected and expected.url != v: |         if expected and expected.fid != v: | ||||||
|             raise serializers.ValidationError("Invalid actor") |             raise serializers.ValidationError("Invalid actor") | ||||||
|         try: |         try: | ||||||
|             return models.Actor.objects.get(url=v) |             return models.Actor.objects.get(fid=v) | ||||||
|         except models.Actor.DoesNotExist: |         except models.Actor.DoesNotExist: | ||||||
|             raise serializers.ValidationError("Actor not found") |             raise serializers.ValidationError("Actor not found") | ||||||
| 
 | 
 | ||||||
|     def save(self, **kwargs): |     def save(self, **kwargs): | ||||||
|         return models.Follow.objects.get_or_create( |         target = self.validated_data["object"] | ||||||
|  | 
 | ||||||
|  |         if target._meta.label == "music.Library": | ||||||
|  |             follow_class = models.LibraryFollow | ||||||
|  |         else: | ||||||
|  |             follow_class = models.Follow | ||||||
|  |         defaults = kwargs | ||||||
|  |         defaults["fid"] = self.validated_data["id"] | ||||||
|  |         return follow_class.objects.update_or_create( | ||||||
|             actor=self.validated_data["actor"], |             actor=self.validated_data["actor"], | ||||||
|             target=self.validated_data["object"], |             target=self.validated_data["object"], | ||||||
|             **kwargs,  # noqa |             defaults=defaults, | ||||||
|         )[0] |         )[0] | ||||||
| 
 | 
 | ||||||
|     def to_representation(self, instance): |     def to_representation(self, instance): | ||||||
|         return { |         return { | ||||||
|             "@context": AP_CONTEXT, |             "@context": AP_CONTEXT, | ||||||
|             "actor": instance.actor.url, |             "actor": instance.actor.fid, | ||||||
|             "id": instance.get_federation_url(), |             "id": instance.get_federation_id(), | ||||||
|             "object": instance.target.url, |             "object": instance.target.fid, | ||||||
|             "type": "Follow", |             "type": "Follow", | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -376,50 +283,66 @@ class APIFollowSerializer(serializers.ModelSerializer): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class AcceptFollowSerializer(serializers.Serializer): | class AcceptFollowSerializer(serializers.Serializer): | ||||||
|     id = serializers.URLField(max_length=500) |     id = serializers.URLField(max_length=500, required=False) | ||||||
|     actor = serializers.URLField(max_length=500) |     actor = serializers.URLField(max_length=500) | ||||||
|     object = FollowSerializer() |     object = FollowSerializer() | ||||||
|     type = serializers.ChoiceField(choices=["Accept"]) |     type = serializers.ChoiceField(choices=["Accept"]) | ||||||
| 
 | 
 | ||||||
|     def validate_actor(self, v): |     def validate_actor(self, v): | ||||||
|         expected = self.context.get("follow_target") |         expected = self.context.get("actor") | ||||||
|         if expected and expected.url != v: |         if expected and expected.fid != v: | ||||||
|             raise serializers.ValidationError("Invalid actor") |             raise serializers.ValidationError("Invalid actor") | ||||||
|         try: |         try: | ||||||
|             return models.Actor.objects.get(url=v) |             return models.Actor.objects.get(fid=v) | ||||||
|         except models.Actor.DoesNotExist: |         except models.Actor.DoesNotExist: | ||||||
|             raise serializers.ValidationError("Actor not found") |             raise serializers.ValidationError("Actor not found") | ||||||
| 
 | 
 | ||||||
|     def validate(self, validated_data): |     def validate(self, validated_data): | ||||||
|         # we ensure the accept actor actually match the follow target |         # we ensure the accept actor actually match the follow target / library owner | ||||||
|         if validated_data["actor"] != validated_data["object"]["object"]: |         target = validated_data["object"]["object"] | ||||||
|  | 
 | ||||||
|  |         if target._meta.label == "music.Library": | ||||||
|  |             expected = target.actor | ||||||
|  |             follow_class = models.LibraryFollow | ||||||
|  |         else: | ||||||
|  |             expected = target | ||||||
|  |             follow_class = models.Follow | ||||||
|  |         if validated_data["actor"] != expected: | ||||||
|             raise serializers.ValidationError("Actor mismatch") |             raise serializers.ValidationError("Actor mismatch") | ||||||
|         try: |         try: | ||||||
|             validated_data["follow"] = ( |             validated_data["follow"] = ( | ||||||
|                 models.Follow.objects.filter( |                 follow_class.objects.filter( | ||||||
|                     target=validated_data["actor"], |                     target=target, actor=validated_data["object"]["actor"] | ||||||
|                     actor=validated_data["object"]["actor"], |  | ||||||
|                 ) |                 ) | ||||||
|                 .exclude(approved=True) |                 .exclude(approved=True) | ||||||
|  |                 .select_related() | ||||||
|                 .get() |                 .get() | ||||||
|             ) |             ) | ||||||
|         except models.Follow.DoesNotExist: |         except follow_class.DoesNotExist: | ||||||
|             raise serializers.ValidationError("No follow to accept") |             raise serializers.ValidationError("No follow to accept") | ||||||
|         return validated_data |         return validated_data | ||||||
| 
 | 
 | ||||||
|     def to_representation(self, instance): |     def to_representation(self, instance): | ||||||
|  |         if instance.target._meta.label == "music.Library": | ||||||
|  |             actor = instance.target.actor | ||||||
|  |         else: | ||||||
|  |             actor = instance.target | ||||||
|  | 
 | ||||||
|         return { |         return { | ||||||
|             "@context": AP_CONTEXT, |             "@context": AP_CONTEXT, | ||||||
|             "id": instance.get_federation_url() + "/accept", |             "id": instance.get_federation_id() + "/accept", | ||||||
|             "type": "Accept", |             "type": "Accept", | ||||||
|             "actor": instance.target.url, |             "actor": actor.fid, | ||||||
|             "object": FollowSerializer(instance).data, |             "object": FollowSerializer(instance).data, | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|     def save(self): |     def save(self): | ||||||
|         self.validated_data["follow"].approved = True |         follow = self.validated_data["follow"] | ||||||
|         self.validated_data["follow"].save() |         follow.approved = True | ||||||
|         return self.validated_data["follow"] |         follow.save() | ||||||
|  |         if follow.target._meta.label == "music.Library": | ||||||
|  |             follow.target.schedule_scan() | ||||||
|  |         return follow | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class UndoFollowSerializer(serializers.Serializer): | class UndoFollowSerializer(serializers.Serializer): | ||||||
|  | @ -430,10 +353,10 @@ class UndoFollowSerializer(serializers.Serializer): | ||||||
| 
 | 
 | ||||||
|     def validate_actor(self, v): |     def validate_actor(self, v): | ||||||
|         expected = self.context.get("follow_target") |         expected = self.context.get("follow_target") | ||||||
|         if expected and expected.url != v: |         if expected and expected.fid != v: | ||||||
|             raise serializers.ValidationError("Invalid actor") |             raise serializers.ValidationError("Invalid actor") | ||||||
|         try: |         try: | ||||||
|             return models.Actor.objects.get(url=v) |             return models.Actor.objects.get(fid=v) | ||||||
|         except models.Actor.DoesNotExist: |         except models.Actor.DoesNotExist: | ||||||
|             raise serializers.ValidationError("Actor not found") |             raise serializers.ValidationError("Actor not found") | ||||||
| 
 | 
 | ||||||
|  | @ -452,9 +375,9 @@ class UndoFollowSerializer(serializers.Serializer): | ||||||
|     def to_representation(self, instance): |     def to_representation(self, instance): | ||||||
|         return { |         return { | ||||||
|             "@context": AP_CONTEXT, |             "@context": AP_CONTEXT, | ||||||
|             "id": instance.get_federation_url() + "/undo", |             "id": instance.get_federation_id() + "/undo", | ||||||
|             "type": "Undo", |             "type": "Undo", | ||||||
|             "actor": instance.actor.url, |             "actor": instance.actor.fid, | ||||||
|             "object": FollowSerializer(instance).data, |             "object": FollowSerializer(instance).data, | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -488,9 +411,9 @@ class ActorWebfingerSerializer(serializers.Serializer): | ||||||
|         data = {} |         data = {} | ||||||
|         data["subject"] = "acct:{}".format(instance.webfinger_subject) |         data["subject"] = "acct:{}".format(instance.webfinger_subject) | ||||||
|         data["links"] = [ |         data["links"] = [ | ||||||
|             {"rel": "self", "href": instance.url, "type": "application/activity+json"} |             {"rel": "self", "href": instance.fid, "type": "application/activity+json"} | ||||||
|         ] |         ] | ||||||
|         data["aliases"] = [instance.url] |         data["aliases"] = [instance.fid] | ||||||
|         return data |         return data | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -519,7 +442,7 @@ class ActivitySerializer(serializers.Serializer): | ||||||
| 
 | 
 | ||||||
|     def validate_actor(self, value): |     def validate_actor(self, value): | ||||||
|         request_actor = self.context.get("actor") |         request_actor = self.context.get("actor") | ||||||
|         if request_actor and request_actor.url != value: |         if request_actor and request_actor.fid != value: | ||||||
|             raise serializers.ValidationError( |             raise serializers.ValidationError( | ||||||
|                 "The actor making the request do not match" " the activity actor" |                 "The actor making the request do not match" " the activity actor" | ||||||
|             ) |             ) | ||||||
|  | @ -560,6 +483,18 @@ class ObjectSerializer(serializers.Serializer): | ||||||
| OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES} | OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES} | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def get_additional_fields(data): | ||||||
|  |     UNSET = object() | ||||||
|  |     additional_fields = {} | ||||||
|  |     for field in ["name", "summary"]: | ||||||
|  |         v = data.get(field, UNSET) | ||||||
|  |         if v == UNSET: | ||||||
|  |             continue | ||||||
|  |         additional_fields[field] = v | ||||||
|  | 
 | ||||||
|  |     return additional_fields | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class PaginatedCollectionSerializer(serializers.Serializer): | class PaginatedCollectionSerializer(serializers.Serializer): | ||||||
|     type = serializers.ChoiceField(choices=["Collection"]) |     type = serializers.ChoiceField(choices=["Collection"]) | ||||||
|     totalItems = serializers.IntegerField(min_value=0) |     totalItems = serializers.IntegerField(min_value=0) | ||||||
|  | @ -575,18 +510,70 @@ class PaginatedCollectionSerializer(serializers.Serializer): | ||||||
|         last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages) |         last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages) | ||||||
|         d = { |         d = { | ||||||
|             "id": conf["id"], |             "id": conf["id"], | ||||||
|             "actor": conf["actor"].url, |             "actor": conf["actor"].fid, | ||||||
|             "totalItems": paginator.count, |             "totalItems": paginator.count, | ||||||
|             "type": "Collection", |             "type": conf.get("type", "Collection"), | ||||||
|             "current": current, |             "current": current, | ||||||
|             "first": first, |             "first": first, | ||||||
|             "last": last, |             "last": last, | ||||||
|         } |         } | ||||||
|  |         d.update(get_additional_fields(conf)) | ||||||
|         if self.context.get("include_ap_context", True): |         if self.context.get("include_ap_context", True): | ||||||
|             d["@context"] = AP_CONTEXT |             d["@context"] = AP_CONTEXT | ||||||
|         return d |         return d | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class LibrarySerializer(PaginatedCollectionSerializer): | ||||||
|  |     type = serializers.ChoiceField(choices=["Library"]) | ||||||
|  |     name = serializers.CharField() | ||||||
|  |     summary = serializers.CharField(allow_blank=True, allow_null=True, required=False) | ||||||
|  |     audience = serializers.ChoiceField( | ||||||
|  |         choices=["", None, "https://www.w3.org/ns/activitystreams#Public"], | ||||||
|  |         required=False, | ||||||
|  |         allow_null=True, | ||||||
|  |         allow_blank=True, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     def to_representation(self, library): | ||||||
|  |         conf = { | ||||||
|  |             "id": library.fid, | ||||||
|  |             "name": library.name, | ||||||
|  |             "summary": library.description, | ||||||
|  |             "page_size": 100, | ||||||
|  |             "actor": library.actor, | ||||||
|  |             "items": library.files.filter(import_status="finished"), | ||||||
|  |             "type": "Library", | ||||||
|  |         } | ||||||
|  |         r = super().to_representation(conf) | ||||||
|  |         r["audience"] = ( | ||||||
|  |             "https://www.w3.org/ns/activitystreams#Public" | ||||||
|  |             if library.privacy_level == "public" | ||||||
|  |             else "" | ||||||
|  |         ) | ||||||
|  |         return r | ||||||
|  | 
 | ||||||
|  |     def create(self, validated_data): | ||||||
|  |         actor = utils.retrieve( | ||||||
|  |             validated_data["actor"], | ||||||
|  |             queryset=models.Actor, | ||||||
|  |             serializer_class=ActorSerializer, | ||||||
|  |         ) | ||||||
|  |         library, created = music_models.Library.objects.update_or_create( | ||||||
|  |             fid=validated_data["id"], | ||||||
|  |             actor=actor, | ||||||
|  |             defaults={ | ||||||
|  |                 "files_count": validated_data["totalItems"], | ||||||
|  |                 "name": validated_data["name"], | ||||||
|  |                 "description": validated_data["summary"], | ||||||
|  |                 "privacy_level": "everyone" | ||||||
|  |                 if validated_data["audience"] | ||||||
|  |                 == "https://www.w3.org/ns/activitystreams#Public" | ||||||
|  |                 else "me", | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         return library | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class CollectionPageSerializer(serializers.Serializer): | class CollectionPageSerializer(serializers.Serializer): | ||||||
|     type = serializers.ChoiceField(choices=["CollectionPage"]) |     type = serializers.ChoiceField(choices=["CollectionPage"]) | ||||||
|     totalItems = serializers.IntegerField(min_value=0) |     totalItems = serializers.IntegerField(min_value=0) | ||||||
|  | @ -623,7 +610,7 @@ class CollectionPageSerializer(serializers.Serializer): | ||||||
|         d = { |         d = { | ||||||
|             "id": id, |             "id": id, | ||||||
|             "partOf": conf["id"], |             "partOf": conf["id"], | ||||||
|             "actor": conf["actor"].url, |             "actor": conf["actor"].fid, | ||||||
|             "totalItems": page.paginator.count, |             "totalItems": page.paginator.count, | ||||||
|             "type": "CollectionPage", |             "type": "CollectionPage", | ||||||
|             "first": first, |             "first": first, | ||||||
|  | @ -645,7 +632,7 @@ class CollectionPageSerializer(serializers.Serializer): | ||||||
|             d["next"] = funkwhale_utils.set_query_parameter( |             d["next"] = funkwhale_utils.set_query_parameter( | ||||||
|                 conf["id"], page=page.next_page_number() |                 conf["id"], page=page.next_page_number() | ||||||
|             ) |             ) | ||||||
| 
 |         d.update(get_additional_fields(conf)) | ||||||
|         if self.context.get("include_ap_context", True): |         if self.context.get("include_ap_context", True): | ||||||
|             d["@context"] = AP_CONTEXT |             d["@context"] = AP_CONTEXT | ||||||
|         return d |         return d | ||||||
|  | @ -678,6 +665,7 @@ class AudioMetadataSerializer(serializers.Serializer): | ||||||
| class AudioSerializer(serializers.Serializer): | class AudioSerializer(serializers.Serializer): | ||||||
|     type = serializers.CharField() |     type = serializers.CharField() | ||||||
|     id = serializers.URLField(max_length=500) |     id = serializers.URLField(max_length=500) | ||||||
|  |     library = serializers.URLField(max_length=500) | ||||||
|     url = serializers.JSONField() |     url = serializers.JSONField() | ||||||
|     published = serializers.DateTimeField() |     published = serializers.DateTimeField() | ||||||
|     updated = serializers.DateTimeField(required=False) |     updated = serializers.DateTimeField(required=False) | ||||||
|  | @ -704,32 +692,40 @@ class AudioSerializer(serializers.Serializer): | ||||||
| 
 | 
 | ||||||
|         return v |         return v | ||||||
| 
 | 
 | ||||||
|  |     def validate_library(self, v): | ||||||
|  |         lb = self.context.get("library") | ||||||
|  |         if lb: | ||||||
|  |             if lb.fid != v: | ||||||
|  |                 raise serializers.ValidationError("Invalid library") | ||||||
|  |             return lb | ||||||
|  |         try: | ||||||
|  |             return music_models.Library.objects.get(fid=v) | ||||||
|  |         except music_models.Library.DoesNotExist: | ||||||
|  |             raise serializers.ValidationError("Invalid library") | ||||||
|  | 
 | ||||||
|     def create(self, validated_data): |     def create(self, validated_data): | ||||||
|         defaults = { |         defaults = { | ||||||
|             "audio_mimetype": validated_data["url"]["mediaType"], |             "mimetype": validated_data["url"]["mediaType"], | ||||||
|             "audio_url": validated_data["url"]["href"], |             "source": validated_data["url"]["href"], | ||||||
|             "metadata": validated_data["metadata"], |             "creation_date": validated_data["published"], | ||||||
|             "artist_name": validated_data["metadata"]["artist"]["name"], |  | ||||||
|             "album_title": validated_data["metadata"]["release"]["title"], |  | ||||||
|             "title": validated_data["metadata"]["recording"]["title"], |  | ||||||
|             "published_date": validated_data["published"], |  | ||||||
|             "modification_date": validated_data.get("updated"), |             "modification_date": validated_data.get("updated"), | ||||||
|  |             "metadata": self.initial_data, | ||||||
|         } |         } | ||||||
|         return models.LibraryTrack.objects.get_or_create( |         tf, created = validated_data["library"].files.update_or_create( | ||||||
|             library=self.context["library"], url=validated_data["id"], defaults=defaults |             fid=validated_data["id"], defaults=defaults | ||||||
|         )[0] |         ) | ||||||
|  |         return tf | ||||||
| 
 | 
 | ||||||
|     def to_representation(self, instance): |     def to_representation(self, instance): | ||||||
|         track = instance.track |         track = instance.track | ||||||
|         album = instance.track.album |         album = instance.track.album | ||||||
|         artist = instance.track.artist |         artist = instance.track.artist | ||||||
| 
 |  | ||||||
|         d = { |         d = { | ||||||
|             "type": "Audio", |             "type": "Audio", | ||||||
|             "id": instance.get_federation_url(), |             "id": instance.get_federation_id(), | ||||||
|  |             "library": instance.library.get_federation_id(), | ||||||
|             "name": instance.track.full_name, |             "name": instance.track.full_name, | ||||||
|             "published": instance.creation_date.isoformat(), |             "published": instance.creation_date.isoformat(), | ||||||
|             "updated": instance.modification_date.isoformat(), |  | ||||||
|             "metadata": { |             "metadata": { | ||||||
|                 "artist": { |                 "artist": { | ||||||
|                     "musicbrainz_id": str(artist.mbid) if artist.mbid else None, |                     "musicbrainz_id": str(artist.mbid) if artist.mbid else None, | ||||||
|  | @ -748,12 +744,14 @@ class AudioSerializer(serializers.Serializer): | ||||||
|                 "length": instance.duration, |                 "length": instance.duration, | ||||||
|             }, |             }, | ||||||
|             "url": { |             "url": { | ||||||
|                 "href": utils.full_url(instance.path), |                 "href": utils.full_url(instance.listen_url), | ||||||
|                 "type": "Link", |                 "type": "Link", | ||||||
|                 "mediaType": instance.mimetype, |                 "mediaType": instance.mimetype, | ||||||
|             }, |             }, | ||||||
|             "attributedTo": [self.context["actor"].url], |  | ||||||
|         } |         } | ||||||
|  |         if instance.modification_date: | ||||||
|  |             d["updated"] = instance.modification_date.isoformat() | ||||||
|  | 
 | ||||||
|         if self.context.get("include_ap_context", True): |         if self.context.get("include_ap_context", True): | ||||||
|             d["@context"] = AP_CONTEXT |             d["@context"] = AP_CONTEXT | ||||||
|         return d |         return d | ||||||
|  | @ -763,7 +761,7 @@ class CollectionSerializer(serializers.Serializer): | ||||||
|     def to_representation(self, conf): |     def to_representation(self, conf): | ||||||
|         d = { |         d = { | ||||||
|             "id": conf["id"], |             "id": conf["id"], | ||||||
|             "actor": conf["actor"].url, |             "actor": conf["actor"].fid, | ||||||
|             "totalItems": len(conf["items"]), |             "totalItems": len(conf["items"]), | ||||||
|             "type": "Collection", |             "type": "Collection", | ||||||
|             "items": [ |             "items": [ | ||||||
|  | @ -777,27 +775,3 @@ class CollectionSerializer(serializers.Serializer): | ||||||
|         if self.context.get("include_ap_context", True): |         if self.context.get("include_ap_context", True): | ||||||
|             d["@context"] = AP_CONTEXT |             d["@context"] = AP_CONTEXT | ||||||
|         return d |         return d | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class LibraryTrackActionSerializer(common_serializers.ActionSerializer): |  | ||||||
|     actions = [common_serializers.Action("import", allow_all=True)] |  | ||||||
|     filterset_class = filters.LibraryTrackFilter |  | ||||||
| 
 |  | ||||||
|     @transaction.atomic |  | ||||||
|     def handle_import(self, objects): |  | ||||||
|         batch = music_models.ImportBatch.objects.create( |  | ||||||
|             source="federation", submitted_by=self.context["submitted_by"] |  | ||||||
|         ) |  | ||||||
|         jobs = [] |  | ||||||
|         for lt in objects: |  | ||||||
|             job = music_models.ImportJob( |  | ||||||
|                 batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url |  | ||||||
|             ) |  | ||||||
|             jobs.append(job) |  | ||||||
| 
 |  | ||||||
|         music_models.ImportJob.objects.bulk_create(jobs) |  | ||||||
|         funkwhale_utils.on_commit( |  | ||||||
|             music_tasks.import_batch_run.delay, import_batch_id=batch.pk |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         return {"batch": {"id": batch.pk}} |  | ||||||
|  |  | ||||||
|  | @ -1,92 +1,23 @@ | ||||||
| import datetime | import datetime | ||||||
| import json |  | ||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
| 
 | 
 | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db.models import Q | from django.db.models import Q, F | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from dynamic_preferences.registries import global_preferences_registry | from dynamic_preferences.registries import global_preferences_registry | ||||||
| from requests.exceptions import RequestException | from requests.exceptions import RequestException | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import session | from funkwhale_api.common import session | ||||||
|  | from funkwhale_api.music import models as music_models | ||||||
| from funkwhale_api.taskapp import celery | from funkwhale_api.taskapp import celery | ||||||
| 
 | 
 | ||||||
| from . import actors |  | ||||||
| from . import library as lb |  | ||||||
| from . import models, signing | from . import models, signing | ||||||
|  | from . import routes | ||||||
| 
 | 
 | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @celery.app.task( |  | ||||||
|     name="federation.send", |  | ||||||
|     autoretry_for=[RequestException], |  | ||||||
|     retry_backoff=30, |  | ||||||
|     max_retries=5, |  | ||||||
| ) |  | ||||||
| @celery.require_instance(models.Actor, "actor") |  | ||||||
| def send(activity, actor, to): |  | ||||||
|     logger.info("Preparing activity delivery to %s", to) |  | ||||||
|     auth = signing.get_auth(actor.private_key, actor.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 = session.get_session().post( |  | ||||||
|             auth=auth, |  | ||||||
|             json=activity, |  | ||||||
|             url=recipient_actor.inbox_url, |  | ||||||
|             timeout=5, |  | ||||||
|             verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, |  | ||||||
|             headers={"Content-Type": "application/activity+json"}, |  | ||||||
|         ) |  | ||||||
|         response.raise_for_status() |  | ||||||
|         logger.debug("Remote answered with %s", response.status_code) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @celery.app.task( |  | ||||||
|     name="federation.scan_library", |  | ||||||
|     autoretry_for=[RequestException], |  | ||||||
|     retry_backoff=30, |  | ||||||
|     max_retries=5, |  | ||||||
| ) |  | ||||||
| @celery.require_instance(models.Library, "library") |  | ||||||
| def scan_library(library, until=None): |  | ||||||
|     if not library.federation_enabled: |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     data = lb.get_library_data(library.url) |  | ||||||
|     scan_library_page.delay(library_id=library.id, page_url=data["first"], until=until) |  | ||||||
|     library.fetched_date = timezone.now() |  | ||||||
|     library.tracks_count = data["totalItems"] |  | ||||||
|     library.save(update_fields=["fetched_date", "tracks_count"]) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @celery.app.task( |  | ||||||
|     name="federation.scan_library_page", |  | ||||||
|     autoretry_for=[RequestException], |  | ||||||
|     retry_backoff=30, |  | ||||||
|     max_retries=5, |  | ||||||
| ) |  | ||||||
| @celery.require_instance(models.Library, "library") |  | ||||||
| def scan_library_page(library, page_url, until=None): |  | ||||||
|     if not library.federation_enabled: |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     data = lb.get_library_page(library, page_url) |  | ||||||
|     lts = [] |  | ||||||
|     for item_serializer in data["items"]: |  | ||||||
|         item_date = item_serializer.validated_data["published"] |  | ||||||
|         if until and item_date < until: |  | ||||||
|             return |  | ||||||
|         lts.append(item_serializer.save()) |  | ||||||
| 
 |  | ||||||
|     next_page = data.get("next") |  | ||||||
|     if next_page and next_page != page_url: |  | ||||||
|         scan_library_page.delay(library_id=library.id, page_url=next_page) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @celery.app.task(name="federation.clean_music_cache") | @celery.app.task(name="federation.clean_music_cache") | ||||||
| def clean_music_cache(): | def clean_music_cache(): | ||||||
|     preferences = global_preferences_registry.manager() |     preferences = global_preferences_registry.manager() | ||||||
|  | @ -96,23 +27,22 @@ def clean_music_cache(): | ||||||
|     limit = timezone.now() - datetime.timedelta(minutes=delay) |     limit = timezone.now() - datetime.timedelta(minutes=delay) | ||||||
| 
 | 
 | ||||||
|     candidates = ( |     candidates = ( | ||||||
|         models.LibraryTrack.objects.filter( |         music_models.TrackFile.objects.filter( | ||||||
|             Q(audio_file__isnull=False) |             Q(audio_file__isnull=False) | ||||||
|             & ( |             & (Q(accessed_date__lt=limit) | Q(accessed_date=None)) | ||||||
|                 Q(local_track_file__accessed_date__lt=limit) |  | ||||||
|                 | Q(local_track_file__accessed_date=None) |  | ||||||
|             ) |  | ||||||
|         ) |         ) | ||||||
|  |         .local(False) | ||||||
|         .exclude(audio_file="") |         .exclude(audio_file="") | ||||||
|         .only("audio_file", "id") |         .only("audio_file", "id") | ||||||
|  |         .order_by("id") | ||||||
|     ) |     ) | ||||||
|     for lt in candidates: |     for tf in candidates: | ||||||
|         lt.audio_file.delete() |         tf.audio_file.delete() | ||||||
| 
 | 
 | ||||||
|     # we also delete orphaned files, if any |     # we also delete orphaned files, if any | ||||||
|     storage = models.LibraryTrack._meta.get_field("audio_file").storage |     storage = models.LibraryTrack._meta.get_field("audio_file").storage | ||||||
|     files = get_files(storage, "federation_cache") |     files = get_files(storage, "federation_cache/tracks") | ||||||
|     existing = models.LibraryTrack.objects.filter(audio_file__in=files) |     existing = music_models.TrackFile.objects.filter(audio_file__in=files) | ||||||
|     missing = set(files) - set(existing.values_list("audio_file", flat=True)) |     missing = set(files) - set(existing.values_list("audio_file", flat=True)) | ||||||
|     for m in missing: |     for m in missing: | ||||||
|         storage.delete(m) |         storage.delete(m) | ||||||
|  | @ -130,3 +60,100 @@ def get_files(storage, *parts): | ||||||
|     for dir in dirs: |     for dir in dirs: | ||||||
|         files += get_files(storage, *(list(parts) + [dir])) |         files += get_files(storage, *(list(parts) + [dir])) | ||||||
|     return [os.path.join(parts[-1], path) for path in files] |     return [os.path.join(parts[-1], path) for path in files] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @celery.app.task(name="federation.dispatch_inbox") | ||||||
|  | @celery.require_instance(models.Activity.objects.select_related(), "activity") | ||||||
|  | def dispatch_inbox(activity): | ||||||
|  |     """ | ||||||
|  |     Given an activity instance, triggers our internal delivery logic (follow | ||||||
|  |     creation, etc.) | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         routes.inbox.dispatch( | ||||||
|  |             activity.payload, | ||||||
|  |             context={ | ||||||
|  |                 "actor": activity.actor, | ||||||
|  |                 "inbox_items": list(activity.inbox_items.local().select_related()), | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |     except Exception: | ||||||
|  |         activity.inbox_items.local().update( | ||||||
|  |             delivery_attempts=F("delivery_attempts") + 1, | ||||||
|  |             last_delivery_date=timezone.now(), | ||||||
|  |         ) | ||||||
|  |         raise | ||||||
|  |     else: | ||||||
|  |         activity.inbox_items.local().update( | ||||||
|  |             delivery_attempts=F("delivery_attempts") + 1, | ||||||
|  |             last_delivery_date=timezone.now(), | ||||||
|  |             is_delivered=True, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @celery.app.task(name="federation.dispatch_outbox") | ||||||
|  | @celery.require_instance(models.Activity.objects.select_related(), "activity") | ||||||
|  | def dispatch_outbox(activity): | ||||||
|  |     """ | ||||||
|  |     Deliver a local activity to its recipients | ||||||
|  |     """ | ||||||
|  |     inbox_items = activity.inbox_items.all().select_related("actor") | ||||||
|  |     local_recipients_items = [ii for ii in inbox_items if ii.actor.is_local] | ||||||
|  |     if local_recipients_items: | ||||||
|  |         dispatch_inbox.delay(activity_id=activity.pk) | ||||||
|  |     remote_recipients_items = [ii for ii in inbox_items if not ii.actor.is_local] | ||||||
|  | 
 | ||||||
|  |     shared_inbox_urls = { | ||||||
|  |         ii.actor.shared_inbox_url | ||||||
|  |         for ii in remote_recipients_items | ||||||
|  |         if ii.actor.shared_inbox_url | ||||||
|  |     } | ||||||
|  |     inbox_urls = { | ||||||
|  |         ii.actor.inbox_url | ||||||
|  |         for ii in remote_recipients_items | ||||||
|  |         if not ii.actor.shared_inbox_url | ||||||
|  |     } | ||||||
|  |     for url in shared_inbox_urls: | ||||||
|  |         deliver_to_remote_inbox.delay(activity_id=activity.pk, shared_inbox_url=url) | ||||||
|  | 
 | ||||||
|  |     for url in inbox_urls: | ||||||
|  |         deliver_to_remote_inbox.delay(activity_id=activity.pk, inbox_url=url) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @celery.app.task( | ||||||
|  |     name="federation.deliver_to_remote_inbox", | ||||||
|  |     autoretry_for=[RequestException], | ||||||
|  |     retry_backoff=30, | ||||||
|  |     max_retries=5, | ||||||
|  | ) | ||||||
|  | @celery.require_instance(models.Activity.objects.select_related(), "activity") | ||||||
|  | def deliver_to_remote_inbox(activity, inbox_url=None, shared_inbox_url=None): | ||||||
|  |     url = inbox_url or shared_inbox_url | ||||||
|  |     actor = activity.actor | ||||||
|  |     inbox_items = activity.inbox_items.filter(is_delivered=False) | ||||||
|  |     if inbox_url: | ||||||
|  |         inbox_items = inbox_items.filter(actor__inbox_url=inbox_url) | ||||||
|  |     else: | ||||||
|  |         inbox_items = inbox_items.filter(actor__shared_inbox_url=shared_inbox_url) | ||||||
|  |     logger.info("Preparing activity delivery to %s", url) | ||||||
|  |     auth = signing.get_auth(actor.private_key, actor.private_key_id) | ||||||
|  |     try: | ||||||
|  |         response = session.get_session().post( | ||||||
|  |             auth=auth, | ||||||
|  |             json=activity.payload, | ||||||
|  |             url=url, | ||||||
|  |             timeout=5, | ||||||
|  |             verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, | ||||||
|  |             headers={"Content-Type": "application/activity+json"}, | ||||||
|  |         ) | ||||||
|  |         logger.debug("Remote answered with %s", response.status_code) | ||||||
|  |         response.raise_for_status() | ||||||
|  |     except Exception: | ||||||
|  |         inbox_items.update( | ||||||
|  |             last_delivery_date=timezone.now(), | ||||||
|  |             delivery_attempts=F("delivery_attempts") + 1, | ||||||
|  |         ) | ||||||
|  |         raise | ||||||
|  |     else: | ||||||
|  |         inbox_items.update(last_delivery_date=timezone.now(), is_delivered=True) | ||||||
|  |  | ||||||
|  | @ -11,7 +11,7 @@ router.register( | ||||||
| router.register(r"federation/actors", views.ActorViewSet, "actors") | router.register(r"federation/actors", views.ActorViewSet, "actors") | ||||||
| router.register(r".well-known", views.WellKnownViewSet, "well-known") | router.register(r".well-known", views.WellKnownViewSet, "well-known") | ||||||
| 
 | 
 | ||||||
| music_router.register(r"files", views.MusicFilesViewSet, "files") | music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") | ||||||
| urlpatterns = router.urls + [ | urlpatterns = router.urls + [ | ||||||
|     url("federation/music/", include((music_router.urls, "music"), namespace="music")) |     url("federation/music/", include((music_router.urls, "music"), namespace="music")) | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | @ -2,6 +2,10 @@ import unicodedata | ||||||
| import re | import re | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| 
 | 
 | ||||||
|  | from funkwhale_api.common import session | ||||||
|  | 
 | ||||||
|  | from . import signing | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def full_url(path): | def full_url(path): | ||||||
|     """ |     """ | ||||||
|  | @ -52,3 +56,35 @@ def slugify_username(username): | ||||||
|     ) |     ) | ||||||
|     value = re.sub(r"[^\w\s-]", "", value).strip() |     value = re.sub(r"[^\w\s-]", "", value).strip() | ||||||
|     return re.sub(r"[-\s]+", "_", value) |     return re.sub(r"[-\s]+", "_", value) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def retrieve(fid, actor=None, serializer_class=None, queryset=None): | ||||||
|  |     if queryset: | ||||||
|  |         try: | ||||||
|  |             # queryset can also be a Model class | ||||||
|  |             existing = queryset.filter(fid=fid).first() | ||||||
|  |         except AttributeError: | ||||||
|  |             existing = queryset.objects.filter(fid=fid).first() | ||||||
|  |         if existing: | ||||||
|  |             return existing | ||||||
|  | 
 | ||||||
|  |     auth = ( | ||||||
|  |         None if not actor else signing.get_auth(actor.private_key, actor.private_key_id) | ||||||
|  |     ) | ||||||
|  |     response = session.get_session().get( | ||||||
|  |         fid, | ||||||
|  |         auth=auth, | ||||||
|  |         timeout=5, | ||||||
|  |         verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, | ||||||
|  |         headers={ | ||||||
|  |             "Accept": "application/activity+json", | ||||||
|  |             "Content-Type": "application/activity+json", | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     response.raise_for_status() | ||||||
|  |     data = response.json() | ||||||
|  |     if not serializer_class: | ||||||
|  |         return data | ||||||
|  |     serializer = serializer_class(data=data) | ||||||
|  |     serializer.is_valid(raise_exception=True) | ||||||
|  |     return serializer.save() | ||||||
|  |  | ||||||
|  | @ -1,25 +1,20 @@ | ||||||
| from django import forms | from django import forms | ||||||
| from django.core import paginator | from django.core import paginator | ||||||
| from django.db import transaction |  | ||||||
| from django.http import HttpResponse, Http404 | from django.http import HttpResponse, Http404 | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from rest_framework import mixins, response, viewsets | from rest_framework import exceptions, mixins, response, viewsets | ||||||
| from rest_framework.decorators import detail_route, list_route | from rest_framework.decorators import detail_route, list_route | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import preferences | from funkwhale_api.common import preferences | ||||||
| from funkwhale_api.music import models as music_models | from funkwhale_api.music import models as music_models | ||||||
| from funkwhale_api.users.permissions import HasUserPermission |  | ||||||
| 
 | 
 | ||||||
| from . import ( | from . import ( | ||||||
|  |     activity, | ||||||
|     actors, |     actors, | ||||||
|     authentication, |     authentication, | ||||||
|     filters, |  | ||||||
|     library, |  | ||||||
|     models, |     models, | ||||||
|     permissions, |  | ||||||
|     renderers, |     renderers, | ||||||
|     serializers, |     serializers, | ||||||
|     tasks, |  | ||||||
|     utils, |     utils, | ||||||
|     webfinger, |     webfinger, | ||||||
| ) | ) | ||||||
|  | @ -34,7 +29,6 @@ class FederationMixin(object): | ||||||
| 
 | 
 | ||||||
| class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): | class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): | ||||||
|     lookup_field = "preferred_username" |     lookup_field = "preferred_username" | ||||||
|     lookup_value_regex = ".*" |  | ||||||
|     authentication_classes = [authentication.SignatureAuthentication] |     authentication_classes = [authentication.SignatureAuthentication] | ||||||
|     permission_classes = [] |     permission_classes = [] | ||||||
|     renderer_classes = [renderers.ActivityPubRenderer] |     renderer_classes = [renderers.ActivityPubRenderer] | ||||||
|  | @ -43,6 +37,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV | ||||||
| 
 | 
 | ||||||
|     @detail_route(methods=["get", "post"]) |     @detail_route(methods=["get", "post"]) | ||||||
|     def inbox(self, request, *args, **kwargs): |     def inbox(self, request, *args, **kwargs): | ||||||
|  |         actor = self.get_object() | ||||||
|  |         if request.method.lower() == "post" and request.actor is None: | ||||||
|  |             raise exceptions.AuthenticationFailed( | ||||||
|  |                 "You need a valid signature to send an activity" | ||||||
|  |             ) | ||||||
|  |         if request.method.lower() == "post": | ||||||
|  |             activity.receive( | ||||||
|  |                 activity=request.data, on_behalf_of=request.actor, recipient=actor | ||||||
|  |             ) | ||||||
|         return response.Response({}, status=200) |         return response.Response({}, status=200) | ||||||
| 
 | 
 | ||||||
|     @detail_route(methods=["get", "post"]) |     @detail_route(methods=["get", "post"]) | ||||||
|  | @ -143,161 +146,64 @@ class WellKnownViewSet(viewsets.GenericViewSet): | ||||||
|         return serializers.ActorWebfingerSerializer(actor).data |         return serializers.ActorWebfingerSerializer(actor).data | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): | def has_library_access(request, library): | ||||||
|     authentication_classes = [authentication.SignatureAuthentication] |     if library.privacy_level == "everyone": | ||||||
|     permission_classes = [permissions.LibraryFollower] |         return True | ||||||
|     renderer_classes = [renderers.ActivityPubRenderer] |     if request.user.is_authenticated and request.user.is_superuser: | ||||||
|  |         return True | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         actor = request.actor | ||||||
|  |     except AttributeError: | ||||||
|  |         return False | ||||||
|  | 
 | ||||||
|  |     return library.received_follows.filter(actor=actor, approved=True).exists() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MusicLibraryViewSet( | ||||||
|  |     FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet | ||||||
|  | ): | ||||||
|  |     authentication_classes = [authentication.SignatureAuthentication] | ||||||
|  |     permission_classes = [] | ||||||
|  |     renderer_classes = [renderers.ActivityPubRenderer] | ||||||
|  |     serializer_class = serializers.PaginatedCollectionSerializer | ||||||
|  |     queryset = music_models.Library.objects.all().select_related("actor") | ||||||
|  |     lookup_field = "uuid" | ||||||
|  | 
 | ||||||
|  |     def retrieve(self, request, *args, **kwargs): | ||||||
|  |         lb = self.get_object() | ||||||
| 
 | 
 | ||||||
|     def list(self, request, *args, **kwargs): |  | ||||||
|         page = request.GET.get("page") |  | ||||||
|         library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|         qs = ( |  | ||||||
|             music_models.TrackFile.objects.order_by("-creation_date") |  | ||||||
|             .select_related("track__artist", "track__album__artist") |  | ||||||
|             .filter(library_track__isnull=True) |  | ||||||
|         ) |  | ||||||
|         if page is None: |  | ||||||
|         conf = { |         conf = { | ||||||
|                 "id": utils.full_url(reverse("federation:music:files-list")), |             "id": lb.get_federation_id(), | ||||||
|                 "page_size": preferences.get("federation__collection_page_size"), |             "actor": lb.actor, | ||||||
|                 "items": qs, |             "name": lb.name, | ||||||
|  |             "summary": lb.description, | ||||||
|  |             "items": lb.files.order_by("-creation_date"), | ||||||
|             "item_serializer": serializers.AudioSerializer, |             "item_serializer": serializers.AudioSerializer, | ||||||
|                 "actor": library, |  | ||||||
|         } |         } | ||||||
|             serializer = serializers.PaginatedCollectionSerializer(conf) |         page = request.GET.get("page") | ||||||
|  |         if page is None: | ||||||
|  |             serializer = serializers.LibrarySerializer(lb) | ||||||
|             data = serializer.data |             data = serializer.data | ||||||
|         else: |         else: | ||||||
|  |             # if actor is requesting a specific page, we ensure library is public | ||||||
|  |             # or readable by the actor | ||||||
|  |             if not has_library_access(request, lb): | ||||||
|  |                 raise exceptions.AuthenticationFailed( | ||||||
|  |                     "You do not have access to this library" | ||||||
|  |                 ) | ||||||
|             try: |             try: | ||||||
|                 page_number = int(page) |                 page_number = int(page) | ||||||
|             except Exception: |             except Exception: | ||||||
|                 return response.Response({"page": ["Invalid page number"]}, status=400) |                 return response.Response({"page": ["Invalid page number"]}, status=400) | ||||||
|             p = paginator.Paginator( |             conf["page_size"] = preferences.get("federation__collection_page_size") | ||||||
|                 qs, preferences.get("federation__collection_page_size") |             p = paginator.Paginator(conf["items"], conf["page_size"]) | ||||||
|             ) |  | ||||||
|             try: |             try: | ||||||
|                 page = p.page(page_number) |                 page = p.page(page_number) | ||||||
|                 conf = { |                 conf["page"] = page | ||||||
|                     "id": utils.full_url(reverse("federation:music:files-list")), |  | ||||||
|                     "page": page, |  | ||||||
|                     "item_serializer": serializers.AudioSerializer, |  | ||||||
|                     "actor": library, |  | ||||||
|                 } |  | ||||||
|                 serializer = serializers.CollectionPageSerializer(conf) |                 serializer = serializers.CollectionPageSerializer(conf) | ||||||
|                 data = serializer.data |                 data = serializer.data | ||||||
|             except paginator.EmptyPage: |             except paginator.EmptyPage: | ||||||
|                 return response.Response(status=404) |                 return response.Response(status=404) | ||||||
| 
 | 
 | ||||||
|         return response.Response(data) |         return response.Response(data) | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class LibraryViewSet( |  | ||||||
|     mixins.RetrieveModelMixin, |  | ||||||
|     mixins.UpdateModelMixin, |  | ||||||
|     mixins.ListModelMixin, |  | ||||||
|     viewsets.GenericViewSet, |  | ||||||
| ): |  | ||||||
|     permission_classes = (HasUserPermission,) |  | ||||||
|     required_permissions = ["federation"] |  | ||||||
|     queryset = models.Library.objects.all().select_related("actor", "follow") |  | ||||||
|     lookup_field = "uuid" |  | ||||||
|     filter_class = filters.LibraryFilter |  | ||||||
|     serializer_class = serializers.APILibrarySerializer |  | ||||||
|     ordering_fields = ( |  | ||||||
|         "id", |  | ||||||
|         "creation_date", |  | ||||||
|         "fetched_date", |  | ||||||
|         "actor__domain", |  | ||||||
|         "tracks_count", |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["get"]) |  | ||||||
|     def fetch(self, request, *args, **kwargs): |  | ||||||
|         account = request.GET.get("account") |  | ||||||
|         if not account: |  | ||||||
|             return response.Response({"account": "This field is mandatory"}, status=400) |  | ||||||
| 
 |  | ||||||
|         data = library.scan_from_account_name(account) |  | ||||||
|         return response.Response(data) |  | ||||||
| 
 |  | ||||||
|     @detail_route(methods=["post"]) |  | ||||||
|     def scan(self, request, *args, **kwargs): |  | ||||||
|         library = self.get_object() |  | ||||||
|         serializer = serializers.APILibraryScanSerializer(data=request.data) |  | ||||||
|         serializer.is_valid(raise_exception=True) |  | ||||||
|         result = tasks.scan_library.delay( |  | ||||||
|             library_id=library.pk, until=serializer.validated_data.get("until") |  | ||||||
|         ) |  | ||||||
|         return response.Response({"task": result.id}) |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["get"]) |  | ||||||
|     def following(self, request, *args, **kwargs): |  | ||||||
|         library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|         queryset = ( |  | ||||||
|             models.Follow.objects.filter(actor=library_actor) |  | ||||||
|             .select_related("actor", "target") |  | ||||||
|             .order_by("-creation_date") |  | ||||||
|         ) |  | ||||||
|         filterset = filters.FollowFilter(request.GET, queryset=queryset) |  | ||||||
|         final_qs = filterset.qs |  | ||||||
|         serializer = serializers.APIFollowSerializer(final_qs, many=True) |  | ||||||
|         data = {"results": serializer.data, "count": len(final_qs)} |  | ||||||
|         return response.Response(data) |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["get", "patch"]) |  | ||||||
|     def followers(self, request, *args, **kwargs): |  | ||||||
|         if request.method.lower() == "patch": |  | ||||||
|             serializer = serializers.APILibraryFollowUpdateSerializer(data=request.data) |  | ||||||
|             serializer.is_valid(raise_exception=True) |  | ||||||
|             follow = serializer.save() |  | ||||||
|             return response.Response(serializers.APIFollowSerializer(follow).data) |  | ||||||
| 
 |  | ||||||
|         library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|         queryset = ( |  | ||||||
|             models.Follow.objects.filter(target=library_actor) |  | ||||||
|             .select_related("actor", "target") |  | ||||||
|             .order_by("-creation_date") |  | ||||||
|         ) |  | ||||||
|         filterset = filters.FollowFilter(request.GET, queryset=queryset) |  | ||||||
|         final_qs = filterset.qs |  | ||||||
|         serializer = serializers.APIFollowSerializer(final_qs, many=True) |  | ||||||
|         data = {"results": serializer.data, "count": len(final_qs)} |  | ||||||
|         return response.Response(data) |  | ||||||
| 
 |  | ||||||
|     @transaction.atomic |  | ||||||
|     def create(self, request, *args, **kwargs): |  | ||||||
|         serializer = serializers.APILibraryCreateSerializer(data=request.data) |  | ||||||
|         serializer.is_valid(raise_exception=True) |  | ||||||
|         serializer.save() |  | ||||||
|         return response.Response(serializer.data, status=201) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class LibraryTrackViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): |  | ||||||
|     permission_classes = (HasUserPermission,) |  | ||||||
|     required_permissions = ["federation"] |  | ||||||
|     queryset = ( |  | ||||||
|         models.LibraryTrack.objects.all() |  | ||||||
|         .select_related("library__actor", "library__follow", "local_track_file") |  | ||||||
|         .prefetch_related("import_jobs") |  | ||||||
|     ) |  | ||||||
|     filter_class = filters.LibraryTrackFilter |  | ||||||
|     serializer_class = serializers.APILibraryTrackSerializer |  | ||||||
|     ordering_fields = ( |  | ||||||
|         "id", |  | ||||||
|         "artist_name", |  | ||||||
|         "title", |  | ||||||
|         "album_title", |  | ||||||
|         "creation_date", |  | ||||||
|         "modification_date", |  | ||||||
|         "fetched_date", |  | ||||||
|         "published_date", |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["post"]) |  | ||||||
|     def action(self, request, *args, **kwargs): |  | ||||||
|         queryset = models.LibraryTrack.objects.filter(local_track_file__isnull=True) |  | ||||||
|         serializer = serializers.LibraryTrackActionSerializer( |  | ||||||
|             request.data, queryset=queryset, context={"submitted_by": request.user} |  | ||||||
|         ) |  | ||||||
|         serializer.is_valid(raise_exception=True) |  | ||||||
|         result = serializer.save() |  | ||||||
|         return response.Response(result, status=200) |  | ||||||
|  |  | ||||||
|  | @ -1,9 +1,12 @@ | ||||||
| from rest_framework import mixins, viewsets | from rest_framework import mixins, viewsets | ||||||
| from rest_framework.permissions import IsAuthenticatedOrReadOnly | from rest_framework.permissions import IsAuthenticatedOrReadOnly | ||||||
| 
 | 
 | ||||||
|  | from django.db.models import Prefetch | ||||||
|  | 
 | ||||||
| from funkwhale_api.activity import record | from funkwhale_api.activity import record | ||||||
| from funkwhale_api.common import fields, permissions | from funkwhale_api.common import fields, permissions | ||||||
| 
 | from funkwhale_api.music.models import Track | ||||||
|  | from funkwhale_api.music import utils as music_utils | ||||||
| from . import models, serializers | from . import models, serializers | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -15,11 +18,7 @@ class ListeningViewSet( | ||||||
| ): | ): | ||||||
| 
 | 
 | ||||||
|     serializer_class = serializers.ListeningSerializer |     serializer_class = serializers.ListeningSerializer | ||||||
|     queryset = ( |     queryset = models.Listening.objects.all().select_related("user") | ||||||
|         models.Listening.objects.all() |  | ||||||
|         .select_related("track__artist", "track__album__artist", "user") |  | ||||||
|         .prefetch_related("track__files") |  | ||||||
|     ) |  | ||||||
|     permission_classes = [ |     permission_classes = [ | ||||||
|         permissions.ConditionalAuthentication, |         permissions.ConditionalAuthentication, | ||||||
|         permissions.OwnerPermission, |         permissions.OwnerPermission, | ||||||
|  | @ -39,9 +38,13 @@ class ListeningViewSet( | ||||||
| 
 | 
 | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|         queryset = super().get_queryset() |         queryset = super().get_queryset() | ||||||
|         return queryset.filter( |         queryset = queryset.filter( | ||||||
|             fields.privacy_level_query(self.request.user, "user__privacy_level") |             fields.privacy_level_query(self.request.user, "user__privacy_level") | ||||||
|         ) |         ) | ||||||
|  |         tracks = Track.objects.annotate_playable_by_actor( | ||||||
|  |             music_utils.get_actor_from_request(self.request) | ||||||
|  |         ).select_related("artist", "album__artist") | ||||||
|  |         return queryset.prefetch_related(Prefetch("track", queryset=tracks)) | ||||||
| 
 | 
 | ||||||
|     def get_serializer_context(self): |     def get_serializer_context(self): | ||||||
|         context = super().get_serializer_context() |         context = super().get_serializer_context() | ||||||
|  |  | ||||||
|  | @ -18,7 +18,7 @@ class ManageTrackFileFilterSet(filters.FilterSet): | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = music_models.TrackFile |         model = music_models.TrackFile | ||||||
|         fields = ["q", "track__album", "track__artist", "track", "library_track"] |         fields = ["q", "track__album", "track__artist", "track"] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ManageUserFilterSet(filters.FilterSet): | class ManageUserFilterSet(filters.FilterSet): | ||||||
|  |  | ||||||
|  | @ -59,7 +59,6 @@ class ManageTrackFileSerializer(serializers.ModelSerializer): | ||||||
|             "bitrate", |             "bitrate", | ||||||
|             "size", |             "size", | ||||||
|             "path", |             "path", | ||||||
|             "library_track", |  | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -15,7 +15,7 @@ class ManageTrackFileViewSet( | ||||||
| ): | ): | ||||||
|     queryset = ( |     queryset = ( | ||||||
|         music_models.TrackFile.objects.all() |         music_models.TrackFile.objects.all() | ||||||
|         .select_related("track__artist", "track__album__artist", "library_track") |         .select_related("track__artist", "track__album__artist") | ||||||
|         .order_by("-id") |         .order_by("-id") | ||||||
|     ) |     ) | ||||||
|     serializer_class = serializers.ManageTrackFileSerializer |     serializer_class = serializers.ManageTrackFileSerializer | ||||||
|  |  | ||||||
|  | @ -65,6 +65,7 @@ class TrackFileAdmin(admin.ModelAdmin): | ||||||
|         "mimetype", |         "mimetype", | ||||||
|         "size", |         "size", | ||||||
|         "bitrate", |         "bitrate", | ||||||
|  |         "import_status", | ||||||
|     ] |     ] | ||||||
|     list_select_related = ["track"] |     list_select_related = ["track"] | ||||||
|     search_fields = [ |     search_fields = [ | ||||||
|  | @ -74,4 +75,12 @@ class TrackFileAdmin(admin.ModelAdmin): | ||||||
|         "track__album__title", |         "track__album__title", | ||||||
|         "track__artist__name", |         "track__artist__name", | ||||||
|     ] |     ] | ||||||
|     list_filter = ["mimetype"] |     list_filter = ["mimetype", "import_status", "library__privacy_level"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @admin.register(models.Library) | ||||||
|  | class LibraryAdmin(admin.ModelAdmin): | ||||||
|  |     list_display = ["id", "name", "actor", "uuid", "privacy_level", "creation_date"] | ||||||
|  |     list_select_related = True | ||||||
|  |     search_fields = ["actor__username", "name", "description"] | ||||||
|  |     list_filter = ["privacy_level"] | ||||||
|  |  | ||||||
|  | @ -3,8 +3,8 @@ import os | ||||||
| import factory | import factory | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.factories import ManyToManyFromList, registry | from funkwhale_api.factories import ManyToManyFromList, registry | ||||||
| from funkwhale_api.federation.factories import LibraryTrackFactory | from funkwhale_api.federation import factories as federation_factories | ||||||
| from funkwhale_api.users.factories import UserFactory | 
 | ||||||
| 
 | 
 | ||||||
| SAMPLES_PATH = os.path.join( | SAMPLES_PATH = os.path.join( | ||||||
|     os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), |     os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), | ||||||
|  | @ -51,6 +51,7 @@ class TrackFactory(factory.django.DjangoModelFactory): | ||||||
| @registry.register | @registry.register | ||||||
| class TrackFileFactory(factory.django.DjangoModelFactory): | class TrackFileFactory(factory.django.DjangoModelFactory): | ||||||
|     track = factory.SubFactory(TrackFactory) |     track = factory.SubFactory(TrackFactory) | ||||||
|  |     library = factory.SubFactory(federation_factories.MusicLibraryFactory) | ||||||
|     audio_file = factory.django.FileField( |     audio_file = factory.django.FileField( | ||||||
|         from_path=os.path.join(SAMPLES_PATH, "test.ogg") |         from_path=os.path.join(SAMPLES_PATH, "test.ogg") | ||||||
|     ) |     ) | ||||||
|  | @ -58,67 +59,13 @@ class TrackFileFactory(factory.django.DjangoModelFactory): | ||||||
|     bitrate = None |     bitrate = None | ||||||
|     size = None |     size = None | ||||||
|     duration = None |     duration = None | ||||||
|  |     mimetype = "audio/ogg" | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = "music.TrackFile" |         model = "music.TrackFile" | ||||||
| 
 | 
 | ||||||
|     class Params: |     class Params: | ||||||
|         in_place = factory.Trait(audio_file=None) |         in_place = factory.Trait(audio_file=None) | ||||||
|         federation = factory.Trait( |  | ||||||
|             audio_file=None, |  | ||||||
|             library_track=factory.SubFactory(LibraryTrackFactory), |  | ||||||
|             mimetype=factory.LazyAttribute(lambda o: o.library_track.audio_mimetype), |  | ||||||
|             source=factory.LazyAttribute(lambda o: o.library_track.audio_url), |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @registry.register |  | ||||||
| class ImportBatchFactory(factory.django.DjangoModelFactory): |  | ||||||
|     submitted_by = factory.SubFactory(UserFactory) |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = "music.ImportBatch" |  | ||||||
| 
 |  | ||||||
|     class Params: |  | ||||||
|         federation = factory.Trait(submitted_by=None, source="federation") |  | ||||||
|         finished = factory.Trait(status="finished") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @registry.register |  | ||||||
| class ImportJobFactory(factory.django.DjangoModelFactory): |  | ||||||
|     batch = factory.SubFactory(ImportBatchFactory) |  | ||||||
|     source = factory.Faker("url") |  | ||||||
|     mbid = factory.Faker("uuid4") |  | ||||||
|     replace_if_duplicate = False |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = "music.ImportJob" |  | ||||||
| 
 |  | ||||||
|     class Params: |  | ||||||
|         federation = factory.Trait( |  | ||||||
|             mbid=None, |  | ||||||
|             library_track=factory.SubFactory(LibraryTrackFactory), |  | ||||||
|             batch=factory.SubFactory(ImportBatchFactory, federation=True), |  | ||||||
|         ) |  | ||||||
|         finished = factory.Trait( |  | ||||||
|             status="finished", track_file=factory.SubFactory(TrackFileFactory) |  | ||||||
|         ) |  | ||||||
|         in_place = factory.Trait(status="finished", audio_file=None) |  | ||||||
|         with_audio_file = factory.Trait( |  | ||||||
|             status="finished", |  | ||||||
|             audio_file=factory.django.FileField( |  | ||||||
|                 from_path=os.path.join(SAMPLES_PATH, "test.ogg") |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @registry.register(name="music.FileImportJob") |  | ||||||
| class FileImportJobFactory(ImportJobFactory): |  | ||||||
|     source = "file://" |  | ||||||
|     mbid = None |  | ||||||
|     audio_file = factory.django.FileField( |  | ||||||
|         from_path=os.path.join(SAMPLES_PATH, "test.ogg") |  | ||||||
|     ) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @registry.register | @registry.register | ||||||
|  |  | ||||||
|  | @ -1,85 +1,97 @@ | ||||||
| from django.db.models import Count |  | ||||||
| from django_filters import rest_framework as filters | from django_filters import rest_framework as filters | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import fields | from funkwhale_api.common import fields | ||||||
|  | from funkwhale_api.common import search | ||||||
| 
 | 
 | ||||||
| from . import models | from . import models | ||||||
|  | from . import utils | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ArtistFilter(filters.FilterSet): | class ArtistFilter(filters.FilterSet): | ||||||
|     q = fields.SearchFilter(search_fields=["name"]) |     q = fields.SearchFilter(search_fields=["name"]) | ||||||
|     listenable = filters.BooleanFilter(name="_", method="filter_listenable") |     playable = filters.BooleanFilter(name="_", method="filter_playable") | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Artist |         model = models.Artist | ||||||
|         fields = { |         fields = { | ||||||
|             "name": ["exact", "iexact", "startswith", "icontains"], |             "name": ["exact", "iexact", "startswith", "icontains"], | ||||||
|             "listenable": "exact", |             "playable": "exact", | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|     def filter_listenable(self, queryset, name, value): |     def filter_playable(self, queryset, name, value): | ||||||
|         queryset = queryset.annotate(files_count=Count("albums__tracks__files")) |         actor = utils.get_actor_from_request(self.request) | ||||||
|         if value: |         return queryset.playable_by(actor, value) | ||||||
|             return queryset.filter(files_count__gt=0) |  | ||||||
|         else: |  | ||||||
|             return queryset.filter(files_count=0) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TrackFilter(filters.FilterSet): | class TrackFilter(filters.FilterSet): | ||||||
|     q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) |     q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) | ||||||
|     listenable = filters.BooleanFilter(name="_", method="filter_listenable") |     playable = filters.BooleanFilter(name="_", method="filter_playable") | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Track |         model = models.Track | ||||||
|         fields = { |         fields = { | ||||||
|             "title": ["exact", "iexact", "startswith", "icontains"], |             "title": ["exact", "iexact", "startswith", "icontains"], | ||||||
|             "listenable": ["exact"], |             "playable": ["exact"], | ||||||
|             "artist": ["exact"], |             "artist": ["exact"], | ||||||
|             "album": ["exact"], |             "album": ["exact"], | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|     def filter_listenable(self, queryset, name, value): |     def filter_playable(self, queryset, name, value): | ||||||
|         queryset = queryset.annotate(files_count=Count("files")) |         actor = utils.get_actor_from_request(self.request) | ||||||
|         if value: |         return queryset.playable_by(actor, value) | ||||||
|             return queryset.filter(files_count__gt=0) |  | ||||||
|         else: |  | ||||||
|             return queryset.filter(files_count=0) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ImportBatchFilter(filters.FilterSet): | class TrackFileFilter(filters.FilterSet): | ||||||
|     q = fields.SearchFilter(search_fields=["submitted_by__username", "source"]) |     library = filters.CharFilter("library__uuid") | ||||||
|  |     track = filters.UUIDFilter("track__uuid") | ||||||
|  |     track_artist = filters.UUIDFilter("track__artist__uuid") | ||||||
|  |     album_artist = filters.UUIDFilter("track__album__artist__uuid") | ||||||
|  |     library = filters.UUIDFilter("library__uuid") | ||||||
|  |     playable = filters.BooleanFilter(name="_", method="filter_playable") | ||||||
|  |     q = fields.SmartSearchFilter( | ||||||
|  |         config=search.SearchConfig( | ||||||
|  |             search_fields={ | ||||||
|  |                 "track_artist": {"to": "track__artist__name"}, | ||||||
|  |                 "album_artist": {"to": "track__album__artist__name"}, | ||||||
|  |                 "album": {"to": "track__album__title"}, | ||||||
|  |                 "title": {"to": "track__title"}, | ||||||
|  |             }, | ||||||
|  |             filter_fields={ | ||||||
|  |                 "artist": {"to": "track__artist__name__iexact"}, | ||||||
|  |                 "mimetype": {"to": "mimetype"}, | ||||||
|  |                 "album": {"to": "track__album__title__iexact"}, | ||||||
|  |                 "title": {"to": "track__title__iexact"}, | ||||||
|  |                 "status": {"to": "import_status"}, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.ImportBatch |         model = models.TrackFile | ||||||
|         fields = {"status": ["exact"], "source": ["exact"], "submitted_by": ["exact"]} |         fields = [ | ||||||
|  |             "playable", | ||||||
|  |             "import_status", | ||||||
|  |             "mimetype", | ||||||
|  |             "track", | ||||||
|  |             "track_artist", | ||||||
|  |             "album_artist", | ||||||
|  |             "library", | ||||||
|  |             "import_reference", | ||||||
|  |         ] | ||||||
| 
 | 
 | ||||||
| 
 |     def filter_playable(self, queryset, name, value): | ||||||
| class ImportJobFilter(filters.FilterSet): |         actor = utils.get_actor_from_request(self.request) | ||||||
|     q = fields.SearchFilter(search_fields=["batch__submitted_by__username", "source"]) |         return queryset.playable_by(actor, value) | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.ImportJob |  | ||||||
|         fields = { |  | ||||||
|             "batch": ["exact"], |  | ||||||
|             "batch__status": ["exact"], |  | ||||||
|             "batch__source": ["exact"], |  | ||||||
|             "batch__submitted_by": ["exact"], |  | ||||||
|             "status": ["exact"], |  | ||||||
|             "source": ["exact"], |  | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class AlbumFilter(filters.FilterSet): | class AlbumFilter(filters.FilterSet): | ||||||
|     listenable = filters.BooleanFilter(name="_", method="filter_listenable") |     playable = filters.BooleanFilter(name="_", method="filter_playable") | ||||||
|     q = fields.SearchFilter(search_fields=["title", "artist__name" "source"]) |     q = fields.SearchFilter(search_fields=["title", "artist__name" "source"]) | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Album |         model = models.Album | ||||||
|         fields = ["listenable", "q", "artist"] |         fields = ["playable", "q", "artist"] | ||||||
| 
 | 
 | ||||||
|     def filter_listenable(self, queryset, name, value): |     def filter_playable(self, queryset, name, value): | ||||||
|         queryset = queryset.annotate(files_count=Count("tracks__files")) |         actor = utils.get_actor_from_request(self.request) | ||||||
|         if value: |         return queryset.playable_by(actor, value) | ||||||
|             return queryset.filter(files_count__gt=0) |  | ||||||
|         else: |  | ||||||
|             return queryset.filter(files_count=0) |  | ||||||
|  |  | ||||||
|  | @ -5,14 +5,12 @@ from django.db import migrations, models | ||||||
| 
 | 
 | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [("music", "0027_auto_20180515_1808")] | ||||||
|         ('music', '0027_auto_20180515_1808'), |  | ||||||
|     ] |  | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
|         migrations.AddField( |         migrations.AddField( | ||||||
|             model_name='importjob', |             model_name="importjob", | ||||||
|             name='replace_if_duplicate', |             name="replace_if_duplicate", | ||||||
|             field=models.BooleanField(default=False), |             field=models.BooleanField(default=False), | ||||||
|         ), |         ) | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  | @ -0,0 +1,109 @@ | ||||||
|  | # Generated by Django 2.0.7 on 2018-08-07 17:48 | ||||||
|  | 
 | ||||||
|  | from django.db import migrations, models | ||||||
|  | import django.db.models.deletion | ||||||
|  | import django.utils.timezone | ||||||
|  | import uuid | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  | 
 | ||||||
|  |     dependencies = [ | ||||||
|  |         ("federation", "0007_auto_20180807_1748"), | ||||||
|  |         ("music", "0028_importjob_replace_if_duplicate"), | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="Library", | ||||||
|  |             fields=[ | ||||||
|  |                 ( | ||||||
|  |                     "id", | ||||||
|  |                     models.AutoField( | ||||||
|  |                         auto_created=True, | ||||||
|  |                         primary_key=True, | ||||||
|  |                         serialize=False, | ||||||
|  |                         verbose_name="ID", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ("fid", models.URLField(db_index=True, max_length=500, unique=True)), | ||||||
|  |                 ("url", models.URLField(blank=True, max_length=500, null=True)), | ||||||
|  |                 ( | ||||||
|  |                     "uuid", | ||||||
|  |                     models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), | ||||||
|  |                 ), | ||||||
|  |                 ("followers_url", models.URLField(max_length=500)), | ||||||
|  |                 ( | ||||||
|  |                     "creation_date", | ||||||
|  |                     models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |                 ), | ||||||
|  |                 ("name", models.CharField(max_length=100)), | ||||||
|  |                 ( | ||||||
|  |                     "description", | ||||||
|  |                     models.TextField(blank=True, max_length=5000, null=True), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "privacy_level", | ||||||
|  |                     models.CharField( | ||||||
|  |                         choices=[ | ||||||
|  |                             ("me", "Only me"), | ||||||
|  |                             ("instance", "Everyone on my instance, and my followers"), | ||||||
|  |                             ( | ||||||
|  |                                 "everyone", | ||||||
|  |                                 "Everyone, including people on other instances", | ||||||
|  |                             ), | ||||||
|  |                         ], | ||||||
|  |                         default="me", | ||||||
|  |                         max_length=25, | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "actor", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                         related_name="libraries", | ||||||
|  |                         to="federation.Actor", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |             options={"abstract": False}, | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="importjob", | ||||||
|  |             name="audio_file_size", | ||||||
|  |             field=models.IntegerField(blank=True, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="importbatch", | ||||||
|  |             name="import_request", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  |                 related_name="import_batches", | ||||||
|  |                 to="requests.ImportRequest", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="importbatch", | ||||||
|  |             name="library", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="import_batches", | ||||||
|  |                 to="music.Library", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="library", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="files", | ||||||
|  |                 to="music.Library", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -0,0 +1,152 @@ | ||||||
|  | # Generated by Django 2.0.8 on 2018-08-25 14:11 | ||||||
|  | 
 | ||||||
|  | import django.contrib.postgres.fields.jsonb | ||||||
|  | import django.core.serializers.json | ||||||
|  | from django.db import migrations, models | ||||||
|  | import django.db.models.deletion | ||||||
|  | import django.utils.timezone | ||||||
|  | import funkwhale_api.music.models | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  | 
 | ||||||
|  |     dependencies = [ | ||||||
|  |         ("federation", "0009_auto_20180822_1956"), | ||||||
|  |         ("music", "0029_auto_20180807_1748"), | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="LibraryScan", | ||||||
|  |             fields=[ | ||||||
|  |                 ( | ||||||
|  |                     "id", | ||||||
|  |                     models.AutoField( | ||||||
|  |                         auto_created=True, | ||||||
|  |                         primary_key=True, | ||||||
|  |                         serialize=False, | ||||||
|  |                         verbose_name="ID", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ("total_files", models.PositiveIntegerField(default=0)), | ||||||
|  |                 ("processed_files", models.PositiveIntegerField(default=0)), | ||||||
|  |                 ("errored_files", models.PositiveIntegerField(default=0)), | ||||||
|  |                 ("status", models.CharField(default="pending", max_length=25)), | ||||||
|  |                 ( | ||||||
|  |                     "creation_date", | ||||||
|  |                     models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |                 ), | ||||||
|  |                 ("modification_date", models.DateTimeField(blank=True, null=True)), | ||||||
|  |                 ( | ||||||
|  |                     "actor", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         blank=True, | ||||||
|  |                         null=True, | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                         to="federation.Actor", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |         migrations.RemoveField(model_name="trackfile", name="library_track"), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="library", | ||||||
|  |             name="files_count", | ||||||
|  |             field=models.PositiveIntegerField(default=0), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="fid", | ||||||
|  |             field=models.URLField(blank=True, max_length=500, null=True, unique=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="import_date", | ||||||
|  |             field=models.DateTimeField(blank=True, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="import_details", | ||||||
|  |             field=django.contrib.postgres.fields.jsonb.JSONField( | ||||||
|  |                 default=funkwhale_api.music.models.empty_dict, | ||||||
|  |                 encoder=django.core.serializers.json.DjangoJSONEncoder, | ||||||
|  |                 max_length=50000, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="import_metadata", | ||||||
|  |             field=django.contrib.postgres.fields.jsonb.JSONField( | ||||||
|  |                 default=funkwhale_api.music.models.empty_dict, | ||||||
|  |                 encoder=django.core.serializers.json.DjangoJSONEncoder, | ||||||
|  |                 max_length=50000, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="import_reference", | ||||||
|  |             field=models.CharField( | ||||||
|  |                 default=funkwhale_api.music.models.get_import_reference, max_length=50 | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="import_status", | ||||||
|  |             field=models.CharField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("pending", "Pending"), | ||||||
|  |                     ("finished", "Finished"), | ||||||
|  |                     ("errored", "Errored"), | ||||||
|  |                     ("skipped", "Skipped"), | ||||||
|  |                 ], | ||||||
|  |                 default="pending", | ||||||
|  |                 max_length=25, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="metadata", | ||||||
|  |             field=django.contrib.postgres.fields.jsonb.JSONField( | ||||||
|  |                 default=funkwhale_api.music.models.empty_dict, | ||||||
|  |                 encoder=django.core.serializers.json.DjangoJSONEncoder, | ||||||
|  |                 max_length=50000, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="album", | ||||||
|  |             name="release_date", | ||||||
|  |             field=models.DateField(blank=True, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="audio_file", | ||||||
|  |             field=models.FileField( | ||||||
|  |                 max_length=255, upload_to=funkwhale_api.music.models.get_file_path | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="source", | ||||||
|  |             field=models.CharField(blank=True, max_length=500, null=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="trackfile", | ||||||
|  |             name="track", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="files", | ||||||
|  |                 to="music.Track", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="libraryscan", | ||||||
|  |             name="library", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                 related_name="scans", | ||||||
|  |                 to="music.Library", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  | @ -1,13 +1,14 @@ | ||||||
|  | import datetime | ||||||
| import os | import os | ||||||
| import shutil |  | ||||||
| import tempfile | import tempfile | ||||||
| import uuid | import uuid | ||||||
| 
 | 
 | ||||||
| import markdown | import markdown | ||||||
| import pendulum | import pendulum | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.core.files import File | from django.contrib.postgres.fields import JSONField | ||||||
| from django.core.files.base import ContentFile | from django.core.files.base import ContentFile | ||||||
|  | from django.core.serializers.json import DjangoJSONEncoder | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models.signals import post_save | from django.db.models.signals import post_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
|  | @ -18,12 +19,18 @@ from taggit.managers import TaggableManager | ||||||
| from versatileimagefield.fields import VersatileImageField | from versatileimagefield.fields import VersatileImageField | ||||||
| from versatileimagefield.image_warmer import VersatileImageFieldWarmer | from versatileimagefield.image_warmer import VersatileImageFieldWarmer | ||||||
| 
 | 
 | ||||||
| from funkwhale_api import downloader, musicbrainz | from funkwhale_api import musicbrainz | ||||||
|  | from funkwhale_api.common import fields | ||||||
|  | from funkwhale_api.common import utils as common_utils | ||||||
|  | from funkwhale_api.federation import models as federation_models | ||||||
| from funkwhale_api.federation import utils as federation_utils | from funkwhale_api.federation import utils as federation_utils | ||||||
| 
 |  | ||||||
| from . import importers, metadata, utils | from . import importers, metadata, utils | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def empty_dict(): | ||||||
|  |     return {} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class APIModelMixin(models.Model): | class APIModelMixin(models.Model): | ||||||
|     mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True) |     mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True) | ||||||
|     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) |     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) | ||||||
|  | @ -89,6 +96,23 @@ class ArtistQuerySet(models.QuerySet): | ||||||
|             models.Prefetch("albums", queryset=Album.objects.with_tracks_count()) |             models.Prefetch("albums", queryset=Album.objects.with_tracks_count()) | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     def annotate_playable_by_actor(self, actor): | ||||||
|  |         tracks = ( | ||||||
|  |             Track.objects.playable_by(actor) | ||||||
|  |             .filter(artist=models.OuterRef("id")) | ||||||
|  |             .order_by("id") | ||||||
|  |             .values("id")[:1] | ||||||
|  |         ) | ||||||
|  |         subquery = models.Subquery(tracks) | ||||||
|  |         return self.annotate(is_playable_by_actor=subquery) | ||||||
|  | 
 | ||||||
|  |     def playable_by(self, actor, include=True): | ||||||
|  |         tracks = Track.objects.playable_by(actor, include) | ||||||
|  |         if include: | ||||||
|  |             return self.filter(tracks__in=tracks) | ||||||
|  |         else: | ||||||
|  |             return self.exclude(tracks__in=tracks) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class Artist(APIModelMixin): | class Artist(APIModelMixin): | ||||||
|     name = models.CharField(max_length=255) |     name = models.CharField(max_length=255) | ||||||
|  | @ -140,6 +164,23 @@ class AlbumQuerySet(models.QuerySet): | ||||||
|     def with_tracks_count(self): |     def with_tracks_count(self): | ||||||
|         return self.annotate(_tracks_count=models.Count("tracks")) |         return self.annotate(_tracks_count=models.Count("tracks")) | ||||||
| 
 | 
 | ||||||
|  |     def annotate_playable_by_actor(self, actor): | ||||||
|  |         tracks = ( | ||||||
|  |             Track.objects.playable_by(actor) | ||||||
|  |             .filter(album=models.OuterRef("id")) | ||||||
|  |             .order_by("id") | ||||||
|  |             .values("id")[:1] | ||||||
|  |         ) | ||||||
|  |         subquery = models.Subquery(tracks) | ||||||
|  |         return self.annotate(is_playable_by_actor=subquery) | ||||||
|  | 
 | ||||||
|  |     def playable_by(self, actor, include=True): | ||||||
|  |         tracks = Track.objects.playable_by(actor, include) | ||||||
|  |         if include: | ||||||
|  |             return self.filter(tracks__in=tracks) | ||||||
|  |         else: | ||||||
|  |             return self.exclude(tracks__in=tracks) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class Album(APIModelMixin): | class Album(APIModelMixin): | ||||||
|     title = models.CharField(max_length=255) |     title = models.CharField(max_length=255) | ||||||
|  | @ -287,11 +328,24 @@ class Lyrics(models.Model): | ||||||
| 
 | 
 | ||||||
| class TrackQuerySet(models.QuerySet): | class TrackQuerySet(models.QuerySet): | ||||||
|     def for_nested_serialization(self): |     def for_nested_serialization(self): | ||||||
|         return ( |         return self.select_related().select_related("album__artist", "artist") | ||||||
|             self.select_related() | 
 | ||||||
|             .select_related("album__artist", "artist") |     def annotate_playable_by_actor(self, actor): | ||||||
|             .prefetch_related("files") |         files = ( | ||||||
|  |             TrackFile.objects.playable_by(actor) | ||||||
|  |             .filter(track=models.OuterRef("id")) | ||||||
|  |             .order_by("id") | ||||||
|  |             .values("id")[:1] | ||||||
|         ) |         ) | ||||||
|  |         subquery = models.Subquery(files) | ||||||
|  |         return self.annotate(is_playable_by_actor=subquery) | ||||||
|  | 
 | ||||||
|  |     def playable_by(self, actor, include=True): | ||||||
|  |         files = TrackFile.objects.playable_by(actor, include) | ||||||
|  |         if include: | ||||||
|  |             return self.filter(files__in=files) | ||||||
|  |         else: | ||||||
|  |             return self.exclude(files__in=files) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_artist(release_list): | def get_artist(release_list): | ||||||
|  | @ -423,12 +477,65 @@ class Track(APIModelMixin): | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     @property | ||||||
|  |     def listen_url(self): | ||||||
|  |         return reverse("api:v1:listen-detail", kwargs={"uuid": self.uuid}) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TrackFileQuerySet(models.QuerySet): | ||||||
|  |     def playable_by(self, actor, include=True): | ||||||
|  |         if actor is None: | ||||||
|  |             libraries = Library.objects.filter(privacy_level="everyone") | ||||||
|  | 
 | ||||||
|  |         else: | ||||||
|  |             me_query = models.Q(privacy_level="me", actor=actor) | ||||||
|  |             instance_query = models.Q( | ||||||
|  |                 privacy_level="instance", actor__domain=actor.domain | ||||||
|  |             ) | ||||||
|  |             libraries = Library.objects.filter( | ||||||
|  |                 me_query | instance_query | models.Q(privacy_level="everyone") | ||||||
|  |             ) | ||||||
|  |         if include: | ||||||
|  |             return self.filter(library__in=libraries) | ||||||
|  |         return self.exclude(library__in=libraries) | ||||||
|  | 
 | ||||||
|  |     def local(self, include=True): | ||||||
|  |         return self.exclude(library__actor__user__isnull=include) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | TRACK_FILE_IMPORT_STATUS_CHOICES = ( | ||||||
|  |     ("pending", "Pending"), | ||||||
|  |     ("finished", "Finished"), | ||||||
|  |     ("errored", "Errored"), | ||||||
|  |     ("skipped", "Skipped"), | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_file_path(instance, filename): | ||||||
|  |     if instance.library.actor.is_local: | ||||||
|  |         return common_utils.ChunkedPath("tracks")(instance, filename) | ||||||
|  |     else: | ||||||
|  |         # we cache remote tracks in a different directory | ||||||
|  |         return common_utils.ChunkedPath("federation_cache/tracks")(instance, filename) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_import_reference(): | ||||||
|  |     return str(uuid.uuid4()) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class TrackFile(models.Model): | class TrackFile(models.Model): | ||||||
|  |     fid = models.URLField(unique=True, max_length=500, null=True, blank=True) | ||||||
|     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) |     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) | ||||||
|     track = models.ForeignKey(Track, related_name="files", on_delete=models.CASCADE) |     track = models.ForeignKey( | ||||||
|     audio_file = models.FileField(upload_to="tracks/%Y/%m/%d", max_length=255) |         Track, related_name="files", on_delete=models.CASCADE, null=True, blank=True | ||||||
|     source = models.URLField(null=True, blank=True, max_length=500) |     ) | ||||||
|  |     audio_file = models.FileField(upload_to=get_file_path, max_length=255) | ||||||
|  |     source = models.CharField( | ||||||
|  |         # URL validators are not flexible enough for our file:// and upload:// schemes | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |         max_length=500, | ||||||
|  |     ) | ||||||
|     creation_date = models.DateTimeField(default=timezone.now) |     creation_date = models.DateTimeField(default=timezone.now) | ||||||
|     modification_date = models.DateTimeField(auto_now=True) |     modification_date = models.DateTimeField(auto_now=True) | ||||||
|     accessed_date = models.DateTimeField(null=True, blank=True) |     accessed_date = models.DateTimeField(null=True, blank=True) | ||||||
|  | @ -437,35 +544,69 @@ class TrackFile(models.Model): | ||||||
|     bitrate = models.IntegerField(null=True, blank=True) |     bitrate = models.IntegerField(null=True, blank=True) | ||||||
|     acoustid_track_id = models.UUIDField(null=True, blank=True) |     acoustid_track_id = models.UUIDField(null=True, blank=True) | ||||||
|     mimetype = models.CharField(null=True, blank=True, max_length=200) |     mimetype = models.CharField(null=True, blank=True, max_length=200) | ||||||
| 
 |     library = models.ForeignKey( | ||||||
|     library_track = models.OneToOneField( |         "library", null=True, blank=True, related_name="files", on_delete=models.CASCADE | ||||||
|         "federation.LibraryTrack", |  | ||||||
|         related_name="local_track_file", |  | ||||||
|         on_delete=models.CASCADE, |  | ||||||
|         null=True, |  | ||||||
|         blank=True, |  | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     def download_file(self): |     # metadata from federation | ||||||
|         # import the track file, since there is not any |     metadata = JSONField( | ||||||
|         # we create a tmp dir for the download |         default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder | ||||||
|         tmp_dir = tempfile.mkdtemp() |  | ||||||
|         data = downloader.download(self.source, target_directory=tmp_dir) |  | ||||||
|         self.duration = data.get("duration", None) |  | ||||||
|         self.audio_file.save( |  | ||||||
|             os.path.basename(data["audio_file_path"]), |  | ||||||
|             File(open(data["audio_file_path"], "rb")), |  | ||||||
|     ) |     ) | ||||||
|         shutil.rmtree(tmp_dir) |     import_date = models.DateTimeField(null=True, blank=True) | ||||||
|         return self.audio_file |     # optionnal metadata provided during import | ||||||
|  |     import_metadata = JSONField( | ||||||
|  |         default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder | ||||||
|  |     ) | ||||||
|  |     # status / error details for the import | ||||||
|  |     import_status = models.CharField( | ||||||
|  |         default="pending", choices=TRACK_FILE_IMPORT_STATUS_CHOICES, max_length=25 | ||||||
|  |     ) | ||||||
|  |     # a short reference provided by the client to group multiple files | ||||||
|  |     # in the same import | ||||||
|  |     import_reference = models.CharField(max_length=50, default=get_import_reference) | ||||||
|  | 
 | ||||||
|  |     # optionnal metadata about import results (error messages, etc.) | ||||||
|  |     import_details = JSONField( | ||||||
|  |         default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     objects = TrackFileQuerySet.as_manager() | ||||||
|  | 
 | ||||||
|  |     def download_audio_from_remote(self, user): | ||||||
|  |         from funkwhale_api.common import session | ||||||
|  |         from funkwhale_api.federation import signing | ||||||
|  | 
 | ||||||
|  |         if user.is_authenticated and user.actor: | ||||||
|  |             auth = signing.get_auth(user.actor.private_key, user.actor.private_key_id) | ||||||
|  |         else: | ||||||
|  |             auth = None | ||||||
|  | 
 | ||||||
|  |         remote_response = session.get_session().get( | ||||||
|  |             self.source, | ||||||
|  |             auth=auth, | ||||||
|  |             stream=True, | ||||||
|  |             timeout=20, | ||||||
|  |             verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, | ||||||
|  |         ) | ||||||
|  |         with remote_response as r: | ||||||
|  |             remote_response.raise_for_status() | ||||||
|  |             extension = utils.get_ext_from_type(self.mimetype) | ||||||
|  |             title = " - ".join( | ||||||
|  |                 [self.track.title, self.track.album.title, self.track.artist.name] | ||||||
|  |             ) | ||||||
|  |             filename = "{}.{}".format(title, extension) | ||||||
|  |             tmp_file = tempfile.TemporaryFile() | ||||||
|  |             for chunk in r.iter_content(chunk_size=512): | ||||||
|  |                 tmp_file.write(chunk) | ||||||
|  |             self.audio_file.save(filename, tmp_file, save=False) | ||||||
|  |             self.save(update_fields=["audio_file"]) | ||||||
|  | 
 | ||||||
|  |     def get_federation_id(self): | ||||||
|  |         if self.fid: | ||||||
|  |             return self.fid | ||||||
| 
 | 
 | ||||||
|     def get_federation_url(self): |  | ||||||
|         return federation_utils.full_url("/federation/music/file/{}".format(self.uuid)) |         return federation_utils.full_url("/federation/music/file/{}".format(self.uuid)) | ||||||
| 
 | 
 | ||||||
|     @property |  | ||||||
|     def path(self): |  | ||||||
|         return reverse("api:v1:trackfiles-serve", kwargs={"pk": self.pk}) |  | ||||||
| 
 |  | ||||||
|     @property |     @property | ||||||
|     def filename(self): |     def filename(self): | ||||||
|         return "{}.{}".format(self.track.full_name, self.extension) |         return "{}.{}".format(self.track.full_name, self.extension) | ||||||
|  | @ -483,37 +624,30 @@ class TrackFile(models.Model): | ||||||
|         if self.source.startswith("file://"): |         if self.source.startswith("file://"): | ||||||
|             return os.path.getsize(self.source.replace("file://", "", 1)) |             return os.path.getsize(self.source.replace("file://", "", 1)) | ||||||
| 
 | 
 | ||||||
|         if self.library_track and self.library_track.audio_file: |  | ||||||
|             return self.library_track.audio_file.size |  | ||||||
| 
 |  | ||||||
|     def get_audio_file(self): |     def get_audio_file(self): | ||||||
|         if self.audio_file: |         if self.audio_file: | ||||||
|             return self.audio_file.open() |             return self.audio_file.open() | ||||||
|         if self.source.startswith("file://"): |         if self.source.startswith("file://"): | ||||||
|             return open(self.source.replace("file://", "", 1), "rb") |             return open(self.source.replace("file://", "", 1), "rb") | ||||||
|         if self.library_track and self.library_track.audio_file: |  | ||||||
|             return self.library_track.audio_file.open() |  | ||||||
| 
 | 
 | ||||||
|     def set_audio_data(self): |     def get_audio_data(self): | ||||||
|         audio_file = self.get_audio_file() |         audio_file = self.get_audio_file() | ||||||
|         if audio_file: |         if not audio_file: | ||||||
|             with audio_file as f: |             return | ||||||
|                 audio_data = utils.get_audio_file_data(f) |         audio_data = utils.get_audio_file_data(audio_file) | ||||||
|         if not audio_data: |         if not audio_data: | ||||||
|             return |             return | ||||||
|             self.duration = int(audio_data["length"]) |         return { | ||||||
|             self.bitrate = audio_data["bitrate"] |             "duration": int(audio_data["length"]), | ||||||
|             self.size = self.get_file_size() |             "bitrate": audio_data["bitrate"], | ||||||
|         else: |             "size": self.get_file_size(), | ||||||
|             lt = self.library_track |         } | ||||||
|             if lt: |  | ||||||
|                 self.duration = lt.get_metadata("length") |  | ||||||
|                 self.size = lt.get_metadata("size") |  | ||||||
|                 self.bitrate = lt.get_metadata("bitrate") |  | ||||||
| 
 | 
 | ||||||
|     def save(self, **kwargs): |     def save(self, **kwargs): | ||||||
|         if not self.mimetype and self.audio_file: |         if not self.mimetype and self.audio_file: | ||||||
|             self.mimetype = utils.guess_mimetype(self.audio_file) |             self.mimetype = utils.guess_mimetype(self.audio_file) | ||||||
|  |         if not self.size and self.audio_file: | ||||||
|  |             self.size = self.audio_file.size | ||||||
|         return super().save(**kwargs) |         return super().save(**kwargs) | ||||||
| 
 | 
 | ||||||
|     def get_metadata(self): |     def get_metadata(self): | ||||||
|  | @ -522,6 +656,10 @@ class TrackFile(models.Model): | ||||||
|             return |             return | ||||||
|         return metadata.Metadata(audio_file) |         return metadata.Metadata(audio_file) | ||||||
| 
 | 
 | ||||||
|  |     @property | ||||||
|  |     def listen_url(self): | ||||||
|  |         return self.track.listen_url + "?file={}".format(self.uuid) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| IMPORT_STATUS_CHOICES = ( | IMPORT_STATUS_CHOICES = ( | ||||||
|     ("pending", "Pending"), |     ("pending", "Pending"), | ||||||
|  | @ -559,6 +697,13 @@ class ImportBatch(models.Model): | ||||||
|         blank=True, |         blank=True, | ||||||
|         on_delete=models.SET_NULL, |         on_delete=models.SET_NULL, | ||||||
|     ) |     ) | ||||||
|  |     library = models.ForeignKey( | ||||||
|  |         "Library", | ||||||
|  |         related_name="import_batches", | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         ordering = ["-creation_date"] |         ordering = ["-creation_date"] | ||||||
|  | @ -577,7 +722,7 @@ class ImportBatch(models.Model): | ||||||
| 
 | 
 | ||||||
|             tasks.import_batch_notify_followers.delay(import_batch_id=self.pk) |             tasks.import_batch_notify_followers.delay(import_batch_id=self.pk) | ||||||
| 
 | 
 | ||||||
|     def get_federation_url(self): |     def get_federation_id(self): | ||||||
|         return federation_utils.full_url( |         return federation_utils.full_url( | ||||||
|             "/federation/music/import/batch/{}".format(self.uuid) |             "/federation/music/import/batch/{}".format(self.uuid) | ||||||
|         ) |         ) | ||||||
|  | @ -609,10 +754,100 @@ class ImportJob(models.Model): | ||||||
|         null=True, |         null=True, | ||||||
|         blank=True, |         blank=True, | ||||||
|     ) |     ) | ||||||
|  |     audio_file_size = models.IntegerField(null=True, blank=True) | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         ordering = ("id",) |         ordering = ("id",) | ||||||
| 
 | 
 | ||||||
|  |     def save(self, **kwargs): | ||||||
|  |         if self.audio_file and not self.audio_file_size: | ||||||
|  |             self.audio_file_size = self.audio_file.size | ||||||
|  |         return super().save(**kwargs) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | LIBRARY_PRIVACY_LEVEL_CHOICES = [ | ||||||
|  |     (k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers" | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibraryQuerySet(models.QuerySet): | ||||||
|  |     def with_follows(self, actor): | ||||||
|  |         return self.prefetch_related( | ||||||
|  |             models.Prefetch( | ||||||
|  |                 "received_follows", | ||||||
|  |                 queryset=federation_models.LibraryFollow.objects.filter(actor=actor), | ||||||
|  |                 to_attr="_follows", | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Library(federation_models.FederationMixin): | ||||||
|  |     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) | ||||||
|  |     actor = models.ForeignKey( | ||||||
|  |         "federation.Actor", related_name="libraries", on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  |     followers_url = models.URLField(max_length=500) | ||||||
|  |     creation_date = models.DateTimeField(default=timezone.now) | ||||||
|  |     name = models.CharField(max_length=100) | ||||||
|  |     description = models.TextField(max_length=5000, null=True, blank=True) | ||||||
|  |     privacy_level = models.CharField( | ||||||
|  |         choices=LIBRARY_PRIVACY_LEVEL_CHOICES, default="me", max_length=25 | ||||||
|  |     ) | ||||||
|  |     files_count = models.PositiveIntegerField(default=0) | ||||||
|  |     objects = LibraryQuerySet.as_manager() | ||||||
|  | 
 | ||||||
|  |     def get_federation_id(self): | ||||||
|  |         return federation_utils.full_url( | ||||||
|  |             reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid}) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def save(self, **kwargs): | ||||||
|  |         if not self.pk and not self.fid and self.actor.is_local: | ||||||
|  |             self.fid = self.get_federation_id() | ||||||
|  |             self.followers_url = self.fid + "/followers" | ||||||
|  | 
 | ||||||
|  |         return super().save(**kwargs) | ||||||
|  | 
 | ||||||
|  |     def should_autoapprove_follow(self, actor): | ||||||
|  |         if self.privacy_level == "everyone": | ||||||
|  |             return True | ||||||
|  |         if self.privacy_level == "instance" and actor.is_local: | ||||||
|  |             return True | ||||||
|  |         return False | ||||||
|  | 
 | ||||||
|  |     def schedule_scan(self): | ||||||
|  |         latest_scan = self.scans.order_by("-creation_date").first() | ||||||
|  |         delay_between_scans = datetime.timedelta(seconds=3600 * 24) | ||||||
|  |         now = timezone.now() | ||||||
|  |         if latest_scan and latest_scan.creation_date + delay_between_scans > now: | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         scan = self.scans.create(total_files=self.files_count) | ||||||
|  |         from . import tasks | ||||||
|  | 
 | ||||||
|  |         common_utils.on_commit(tasks.start_library_scan.delay, library_scan_id=scan.pk) | ||||||
|  |         return scan | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | SCAN_STATUS = [ | ||||||
|  |     ("pending", "pending"), | ||||||
|  |     ("scanning", "scanning"), | ||||||
|  |     ("finished", "finished"), | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibraryScan(models.Model): | ||||||
|  |     actor = models.ForeignKey( | ||||||
|  |         "federation.Actor", null=True, blank=True, on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  |     library = models.ForeignKey(Library, related_name="scans", on_delete=models.CASCADE) | ||||||
|  |     total_files = models.PositiveIntegerField(default=0) | ||||||
|  |     processed_files = models.PositiveIntegerField(default=0) | ||||||
|  |     errored_files = models.PositiveIntegerField(default=0) | ||||||
|  |     status = models.CharField(default="pending", max_length=25) | ||||||
|  |     creation_date = models.DateTimeField(default=timezone.now) | ||||||
|  |     modification_date = models.DateTimeField(null=True, blank=True) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @receiver(post_save, sender=ImportJob) | @receiver(post_save, sender=ImportJob) | ||||||
| def update_batch_status(sender, instance, **kwargs): | def update_batch_status(sender, instance, **kwargs): | ||||||
|  |  | ||||||
|  | @ -1,24 +0,0 @@ | ||||||
| 
 |  | ||||||
| from rest_framework.permissions import BasePermission |  | ||||||
| 
 |  | ||||||
| from funkwhale_api.common import preferences |  | ||||||
| from funkwhale_api.federation import actors, models |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Listen(BasePermission): |  | ||||||
|     def has_permission(self, request, view): |  | ||||||
|         if not preferences.get("common__api_authentication_required"): |  | ||||||
|             return True |  | ||||||
| 
 |  | ||||||
|         user = getattr(request, "user", None) |  | ||||||
|         if user and user.is_authenticated: |  | ||||||
|             return True |  | ||||||
| 
 |  | ||||||
|         actor = getattr(request, "actor", None) |  | ||||||
|         if actor is None: |  | ||||||
|             return False |  | ||||||
| 
 |  | ||||||
|         library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|         return models.Follow.objects.filter( |  | ||||||
|             target=library, actor=actor, approved=True |  | ||||||
|         ).exists() |  | ||||||
|  | @ -1,12 +1,13 @@ | ||||||
| from django.db.models import Q | from django.db import transaction | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
| from taggit.models import Tag | from taggit.models import Tag | ||||||
| from versatileimagefield.serializers import VersatileImageFieldSerializer | from versatileimagefield.serializers import VersatileImageFieldSerializer | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.activity import serializers as activity_serializers | from funkwhale_api.activity import serializers as activity_serializers | ||||||
| from funkwhale_api.users.serializers import UserBasicSerializer | from funkwhale_api.common import serializers as common_serializers | ||||||
|  | from funkwhale_api.common import utils as common_utils | ||||||
| 
 | 
 | ||||||
| from . import models, tasks | from . import filters, models, tasks | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") | cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") | ||||||
|  | @ -15,6 +16,7 @@ cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") | ||||||
| class ArtistAlbumSerializer(serializers.ModelSerializer): | class ArtistAlbumSerializer(serializers.ModelSerializer): | ||||||
|     tracks_count = serializers.SerializerMethodField() |     tracks_count = serializers.SerializerMethodField() | ||||||
|     cover = cover_field |     cover = cover_field | ||||||
|  |     is_playable = serializers.SerializerMethodField() | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Album |         model = models.Album | ||||||
|  | @ -27,11 +29,18 @@ class ArtistAlbumSerializer(serializers.ModelSerializer): | ||||||
|             "cover", |             "cover", | ||||||
|             "creation_date", |             "creation_date", | ||||||
|             "tracks_count", |             "tracks_count", | ||||||
|  |             "is_playable", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     def get_tracks_count(self, o): |     def get_tracks_count(self, o): | ||||||
|         return o._tracks_count |         return o._tracks_count | ||||||
| 
 | 
 | ||||||
|  |     def get_is_playable(self, obj): | ||||||
|  |         try: | ||||||
|  |             return bool(obj.is_playable_by_actor) | ||||||
|  |         except AttributeError: | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class ArtistWithAlbumsSerializer(serializers.ModelSerializer): | class ArtistWithAlbumsSerializer(serializers.ModelSerializer): | ||||||
|     albums = ArtistAlbumSerializer(many=True, read_only=True) |     albums = ArtistAlbumSerializer(many=True, read_only=True) | ||||||
|  | @ -41,30 +50,6 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer): | ||||||
|         fields = ("id", "mbid", "name", "creation_date", "albums") |         fields = ("id", "mbid", "name", "creation_date", "albums") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TrackFileSerializer(serializers.ModelSerializer): |  | ||||||
|     path = serializers.SerializerMethodField() |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.TrackFile |  | ||||||
|         fields = ( |  | ||||||
|             "id", |  | ||||||
|             "path", |  | ||||||
|             "source", |  | ||||||
|             "filename", |  | ||||||
|             "mimetype", |  | ||||||
|             "track", |  | ||||||
|             "duration", |  | ||||||
|             "mimetype", |  | ||||||
|             "bitrate", |  | ||||||
|             "size", |  | ||||||
|         ) |  | ||||||
|         read_only_fields = ["duration", "mimetype", "bitrate", "size"] |  | ||||||
| 
 |  | ||||||
|     def get_path(self, o): |  | ||||||
|         url = o.path |  | ||||||
|         return url |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ArtistSimpleSerializer(serializers.ModelSerializer): | class ArtistSimpleSerializer(serializers.ModelSerializer): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Artist |         model = models.Artist | ||||||
|  | @ -72,8 +57,9 @@ class ArtistSimpleSerializer(serializers.ModelSerializer): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class AlbumTrackSerializer(serializers.ModelSerializer): | class AlbumTrackSerializer(serializers.ModelSerializer): | ||||||
|     files = TrackFileSerializer(many=True, read_only=True) |  | ||||||
|     artist = ArtistSimpleSerializer(read_only=True) |     artist = ArtistSimpleSerializer(read_only=True) | ||||||
|  |     is_playable = serializers.SerializerMethodField() | ||||||
|  |     listen_url = serializers.SerializerMethodField() | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Track |         model = models.Track | ||||||
|  | @ -84,15 +70,26 @@ class AlbumTrackSerializer(serializers.ModelSerializer): | ||||||
|             "album", |             "album", | ||||||
|             "artist", |             "artist", | ||||||
|             "creation_date", |             "creation_date", | ||||||
|             "files", |  | ||||||
|             "position", |             "position", | ||||||
|  |             "is_playable", | ||||||
|  |             "listen_url", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     def get_is_playable(self, obj): | ||||||
|  |         try: | ||||||
|  |             return bool(obj.is_playable_by_actor) | ||||||
|  |         except AttributeError: | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
|  |     def get_listen_url(self, obj): | ||||||
|  |         return obj.listen_url | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class AlbumSerializer(serializers.ModelSerializer): | class AlbumSerializer(serializers.ModelSerializer): | ||||||
|     tracks = serializers.SerializerMethodField() |     tracks = serializers.SerializerMethodField() | ||||||
|     artist = ArtistSimpleSerializer(read_only=True) |     artist = ArtistSimpleSerializer(read_only=True) | ||||||
|     cover = cover_field |     cover = cover_field | ||||||
|  |     is_playable = serializers.SerializerMethodField() | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Album |         model = models.Album | ||||||
|  | @ -105,6 +102,7 @@ class AlbumSerializer(serializers.ModelSerializer): | ||||||
|             "release_date", |             "release_date", | ||||||
|             "cover", |             "cover", | ||||||
|             "creation_date", |             "creation_date", | ||||||
|  |             "is_playable", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     def get_tracks(self, o): |     def get_tracks(self, o): | ||||||
|  | @ -114,6 +112,12 @@ class AlbumSerializer(serializers.ModelSerializer): | ||||||
|         ) |         ) | ||||||
|         return AlbumTrackSerializer(ordered_tracks, many=True).data |         return AlbumTrackSerializer(ordered_tracks, many=True).data | ||||||
| 
 | 
 | ||||||
|  |     def get_is_playable(self, obj): | ||||||
|  |         try: | ||||||
|  |             return any([bool(t.is_playable_by_actor) for t in obj.tracks.all()]) | ||||||
|  |         except AttributeError: | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class TrackAlbumSerializer(serializers.ModelSerializer): | class TrackAlbumSerializer(serializers.ModelSerializer): | ||||||
|     artist = ArtistSimpleSerializer(read_only=True) |     artist = ArtistSimpleSerializer(read_only=True) | ||||||
|  | @ -133,10 +137,11 @@ class TrackAlbumSerializer(serializers.ModelSerializer): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TrackSerializer(serializers.ModelSerializer): | class TrackSerializer(serializers.ModelSerializer): | ||||||
|     files = TrackFileSerializer(many=True, read_only=True) |  | ||||||
|     artist = ArtistSimpleSerializer(read_only=True) |     artist = ArtistSimpleSerializer(read_only=True) | ||||||
|     album = TrackAlbumSerializer(read_only=True) |     album = TrackAlbumSerializer(read_only=True) | ||||||
|     lyrics = serializers.SerializerMethodField() |     lyrics = serializers.SerializerMethodField() | ||||||
|  |     is_playable = serializers.SerializerMethodField() | ||||||
|  |     listen_url = serializers.SerializerMethodField() | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Track |         model = models.Track | ||||||
|  | @ -147,14 +152,146 @@ class TrackSerializer(serializers.ModelSerializer): | ||||||
|             "album", |             "album", | ||||||
|             "artist", |             "artist", | ||||||
|             "creation_date", |             "creation_date", | ||||||
|             "files", |  | ||||||
|             "position", |             "position", | ||||||
|             "lyrics", |             "lyrics", | ||||||
|  |             "is_playable", | ||||||
|  |             "listen_url", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     def get_lyrics(self, obj): |     def get_lyrics(self, obj): | ||||||
|         return obj.get_lyrics_url() |         return obj.get_lyrics_url() | ||||||
| 
 | 
 | ||||||
|  |     def get_listen_url(self, obj): | ||||||
|  |         return obj.listen_url | ||||||
|  | 
 | ||||||
|  |     def get_is_playable(self, obj): | ||||||
|  |         try: | ||||||
|  |             return bool(obj.is_playable_by_actor) | ||||||
|  |         except AttributeError: | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class LibraryForOwnerSerializer(serializers.ModelSerializer): | ||||||
|  |     files_count = serializers.SerializerMethodField() | ||||||
|  |     size = serializers.SerializerMethodField() | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = models.Library | ||||||
|  |         fields = [ | ||||||
|  |             "uuid", | ||||||
|  |             "fid", | ||||||
|  |             "name", | ||||||
|  |             "description", | ||||||
|  |             "privacy_level", | ||||||
|  |             "files_count", | ||||||
|  |             "size", | ||||||
|  |             "creation_date", | ||||||
|  |         ] | ||||||
|  |         read_only_fields = ["fid", "uuid", "creation_date", "actor"] | ||||||
|  | 
 | ||||||
|  |     def get_files_count(self, o): | ||||||
|  |         return getattr(o, "_files_count", o.files_count) | ||||||
|  | 
 | ||||||
|  |     def get_size(self, o): | ||||||
|  |         return getattr(o, "_size", 0) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TrackFileSerializer(serializers.ModelSerializer): | ||||||
|  |     track = TrackSerializer(required=False, allow_null=True) | ||||||
|  |     library = common_serializers.RelatedField( | ||||||
|  |         "uuid", | ||||||
|  |         LibraryForOwnerSerializer(), | ||||||
|  |         required=True, | ||||||
|  |         filters=lambda context: {"actor": context["user"].actor}, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = models.TrackFile | ||||||
|  |         fields = [ | ||||||
|  |             "uuid", | ||||||
|  |             "filename", | ||||||
|  |             "creation_date", | ||||||
|  |             "mimetype", | ||||||
|  |             "track", | ||||||
|  |             "library", | ||||||
|  |             "duration", | ||||||
|  |             "mimetype", | ||||||
|  |             "bitrate", | ||||||
|  |             "size", | ||||||
|  |             "import_date", | ||||||
|  |             "import_status", | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |         read_only_fields = [ | ||||||
|  |             "uuid", | ||||||
|  |             "creation_date", | ||||||
|  |             "duration", | ||||||
|  |             "mimetype", | ||||||
|  |             "bitrate", | ||||||
|  |             "size", | ||||||
|  |             "track", | ||||||
|  |             "import_date", | ||||||
|  |             "import_status", | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TrackFileForOwnerSerializer(TrackFileSerializer): | ||||||
|  |     class Meta(TrackFileSerializer.Meta): | ||||||
|  |         fields = TrackFileSerializer.Meta.fields + [ | ||||||
|  |             "import_details", | ||||||
|  |             "import_metadata", | ||||||
|  |             "import_reference", | ||||||
|  |             "metadata", | ||||||
|  |             "source", | ||||||
|  |             "audio_file", | ||||||
|  |         ] | ||||||
|  |         write_only_fields = ["audio_file"] | ||||||
|  |         read_only_fields = TrackFileSerializer.Meta.read_only_fields + [ | ||||||
|  |             "import_details", | ||||||
|  |             "import_metadata", | ||||||
|  |             "metadata", | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |     def to_representation(self, obj): | ||||||
|  |         r = super().to_representation(obj) | ||||||
|  |         if "audio_file" in r: | ||||||
|  |             del r["audio_file"] | ||||||
|  |         return r | ||||||
|  | 
 | ||||||
|  |     def validate(self, validated_data): | ||||||
|  |         if "audio_file" in validated_data: | ||||||
|  |             self.validate_upload_quota(validated_data["audio_file"]) | ||||||
|  | 
 | ||||||
|  |         return super().validate(validated_data) | ||||||
|  | 
 | ||||||
|  |     def validate_upload_quota(self, f): | ||||||
|  |         quota_status = self.context["user"].get_quota_status() | ||||||
|  |         if (f.size / 1000 / 1000) > quota_status["remaining"]: | ||||||
|  |             raise serializers.ValidationError("upload_quota_reached") | ||||||
|  | 
 | ||||||
|  |         return f | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TrackFileActionSerializer(common_serializers.ActionSerializer): | ||||||
|  |     actions = [ | ||||||
|  |         common_serializers.Action("delete", allow_all=True), | ||||||
|  |         common_serializers.Action("relaunch_import", allow_all=True), | ||||||
|  |     ] | ||||||
|  |     filterset_class = filters.TrackFileFilter | ||||||
|  |     pk_field = "uuid" | ||||||
|  | 
 | ||||||
|  |     @transaction.atomic | ||||||
|  |     def handle_delete(self, objects): | ||||||
|  |         return objects.delete() | ||||||
|  | 
 | ||||||
|  |     @transaction.atomic | ||||||
|  |     def handle_relaunch_import(self, objects): | ||||||
|  |         qs = objects.exclude(import_status="finished") | ||||||
|  |         pks = list(qs.values_list("id", flat=True)) | ||||||
|  |         qs.update(import_status="pending") | ||||||
|  |         for pk in pks: | ||||||
|  |             common_utils.on_commit(tasks.import_track_file.delay, track_file_id=pk) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class TagSerializer(serializers.ModelSerializer): | class TagSerializer(serializers.ModelSerializer): | ||||||
|     class Meta: |     class Meta: | ||||||
|  | @ -176,40 +313,6 @@ class LyricsSerializer(serializers.ModelSerializer): | ||||||
|         fields = ("id", "work", "content", "content_rendered") |         fields = ("id", "work", "content", "content_rendered") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ImportJobSerializer(serializers.ModelSerializer): |  | ||||||
|     track_file = TrackFileSerializer(read_only=True) |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.ImportJob |  | ||||||
|         fields = ("id", "mbid", "batch", "source", "status", "track_file", "audio_file") |  | ||||||
|         read_only_fields = ("status", "track_file") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ImportBatchSerializer(serializers.ModelSerializer): |  | ||||||
|     submitted_by = UserBasicSerializer(read_only=True) |  | ||||||
| 
 |  | ||||||
|     class Meta: |  | ||||||
|         model = models.ImportBatch |  | ||||||
|         fields = ( |  | ||||||
|             "id", |  | ||||||
|             "submitted_by", |  | ||||||
|             "source", |  | ||||||
|             "status", |  | ||||||
|             "creation_date", |  | ||||||
|             "import_request", |  | ||||||
|         ) |  | ||||||
|         read_only_fields = ("creation_date", "submitted_by", "source") |  | ||||||
| 
 |  | ||||||
|     def to_representation(self, instance): |  | ||||||
|         repr = super().to_representation(instance) |  | ||||||
|         try: |  | ||||||
|             repr["job_count"] = instance.job_count |  | ||||||
|         except AttributeError: |  | ||||||
|             # Queryset was not annotated |  | ||||||
|             pass |  | ||||||
|         return repr |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class TrackActivitySerializer(activity_serializers.ModelSerializer): | class TrackActivitySerializer(activity_serializers.ModelSerializer): | ||||||
|     type = serializers.SerializerMethodField() |     type = serializers.SerializerMethodField() | ||||||
|     name = serializers.CharField(source="title") |     name = serializers.CharField(source="title") | ||||||
|  | @ -222,33 +325,3 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): | ||||||
| 
 | 
 | ||||||
|     def get_type(self, obj): |     def get_type(self, obj): | ||||||
|         return "Audio" |         return "Audio" | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ImportJobRunSerializer(serializers.Serializer): |  | ||||||
|     jobs = serializers.PrimaryKeyRelatedField( |  | ||||||
|         many=True, |  | ||||||
|         queryset=models.ImportJob.objects.filter(status__in=["pending", "errored"]), |  | ||||||
|     ) |  | ||||||
|     batches = serializers.PrimaryKeyRelatedField( |  | ||||||
|         many=True, queryset=models.ImportBatch.objects.all() |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     def validate(self, validated_data): |  | ||||||
|         jobs = validated_data["jobs"] |  | ||||||
|         batches_ids = [b.pk for b in validated_data["batches"]] |  | ||||||
|         query = Q(batch__pk__in=batches_ids) |  | ||||||
|         query |= Q(pk__in=[j.id for j in jobs]) |  | ||||||
|         queryset = ( |  | ||||||
|             models.ImportJob.objects.filter(query) |  | ||||||
|             .filter(status__in=["pending", "errored"]) |  | ||||||
|             .distinct() |  | ||||||
|         ) |  | ||||||
|         validated_data["_jobs"] = queryset |  | ||||||
|         return validated_data |  | ||||||
| 
 |  | ||||||
|     def create(self, validated_data): |  | ||||||
|         ids = validated_data["_jobs"].values_list("id", flat=True) |  | ||||||
|         validated_data["_jobs"].update(status="pending") |  | ||||||
|         for id in ids: |  | ||||||
|             tasks.import_job_run.delay(import_job_id=id) |  | ||||||
|         return {"jobs": list(ids)} |  | ||||||
|  |  | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | import django.dispatch | ||||||
|  | 
 | ||||||
|  | track_file_import_status_updated = django.dispatch.Signal( | ||||||
|  |     providing_args=["old_status", "new_status", "track_file"] | ||||||
|  | ) | ||||||
|  | @ -1,20 +1,27 @@ | ||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
| 
 | 
 | ||||||
| from django.conf import settings | from django.utils import timezone | ||||||
| from django.core.files.base import ContentFile | from django.db import transaction | ||||||
| from musicbrainzngs import ResponseError | from django.db.models import F | ||||||
|  | from django.dispatch import receiver | ||||||
| 
 | 
 | ||||||
|  | from musicbrainzngs import ResponseError | ||||||
|  | from requests.exceptions import RequestException | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.common import channels | ||||||
| from funkwhale_api.common import preferences | from funkwhale_api.common import preferences | ||||||
| from funkwhale_api.federation import activity, actors | from funkwhale_api.federation import activity, actors | ||||||
| from funkwhale_api.federation import serializers as federation_serializers | from funkwhale_api.federation import library as lb | ||||||
|  | from funkwhale_api.federation import library as federation_serializers | ||||||
| from funkwhale_api.providers.acoustid import get_acoustid_client | from funkwhale_api.providers.acoustid import get_acoustid_client | ||||||
| from funkwhale_api.providers.audiofile import tasks as audiofile_tasks |  | ||||||
| from funkwhale_api.taskapp import celery | from funkwhale_api.taskapp import celery | ||||||
| 
 | 
 | ||||||
| from . import lyrics as lyrics_utils | from . import lyrics as lyrics_utils | ||||||
| from . import models | from . import models | ||||||
| from . import utils as music_utils | from . import metadata | ||||||
|  | from . import signals | ||||||
|  | from . import serializers | ||||||
| 
 | 
 | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
| 
 | 
 | ||||||
|  | @ -34,8 +41,7 @@ def set_acoustid_on_track_file(track_file): | ||||||
|         return update(result["id"]) |         return update(result["id"]) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def import_track_from_remote(library_track): | def import_track_from_remote(metadata): | ||||||
|     metadata = library_track.metadata |  | ||||||
|     try: |     try: | ||||||
|         track_mbid = metadata["recording"]["musicbrainz_id"] |         track_mbid = metadata["recording"]["musicbrainz_id"] | ||||||
|         assert track_mbid  # for null/empty values |         assert track_mbid  # for null/empty values | ||||||
|  | @ -52,7 +58,7 @@ def import_track_from_remote(library_track): | ||||||
|     else: |     else: | ||||||
|         album, _ = models.Album.get_or_create_from_api(mbid=album_mbid) |         album, _ = models.Album.get_or_create_from_api(mbid=album_mbid) | ||||||
|         return models.Track.get_or_create_from_title( |         return models.Track.get_or_create_from_title( | ||||||
|             library_track.title, artist=album.artist, album=album |             metadata["title"], artist=album.artist, album=album | ||||||
|         )[0] |         )[0] | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
|  | @ -63,130 +69,23 @@ def import_track_from_remote(library_track): | ||||||
|     else: |     else: | ||||||
|         artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid) |         artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid) | ||||||
|         album, _ = models.Album.get_or_create_from_title( |         album, _ = models.Album.get_or_create_from_title( | ||||||
|             library_track.album_title, artist=artist |             metadata["album_title"], artist=artist | ||||||
|         ) |         ) | ||||||
|         return models.Track.get_or_create_from_title( |         return models.Track.get_or_create_from_title( | ||||||
|             library_track.title, artist=artist, album=album |             metadata["title"], artist=artist, album=album | ||||||
|         )[0] |         )[0] | ||||||
| 
 | 
 | ||||||
|     # worst case scenario, we have absolutely no way to link to a |     # worst case scenario, we have absolutely no way to link to a | ||||||
|     # musicbrainz resource, we rely on the name/titles |     # musicbrainz resource, we rely on the name/titles | ||||||
|     artist, _ = models.Artist.get_or_create_from_name(library_track.artist_name) |     artist, _ = models.Artist.get_or_create_from_name(metadata["artist_name"]) | ||||||
|     album, _ = models.Album.get_or_create_from_title( |     album, _ = models.Album.get_or_create_from_title( | ||||||
|         library_track.album_title, artist=artist |         metadata["album_title"], artist=artist | ||||||
|     ) |     ) | ||||||
|     return models.Track.get_or_create_from_title( |     return models.Track.get_or_create_from_title( | ||||||
|         library_track.title, artist=artist, album=album |         metadata["title"], artist=artist, album=album | ||||||
|     )[0] |     )[0] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def _do_import(import_job, use_acoustid=False): |  | ||||||
|     logger.info("[Import Job %s] starting job", import_job.pk) |  | ||||||
|     from_file = bool(import_job.audio_file) |  | ||||||
|     mbid = import_job.mbid |  | ||||||
|     replace = import_job.replace_if_duplicate |  | ||||||
|     acoustid_track_id = None |  | ||||||
|     duration = None |  | ||||||
|     track = None |  | ||||||
|     # use_acoustid = use_acoustid and preferences.get('providers_acoustid__api_key') |  | ||||||
|     # Acoustid is not reliable, we disable it for now. |  | ||||||
|     use_acoustid = False |  | ||||||
|     if not mbid and use_acoustid and from_file: |  | ||||||
|         # we try to deduce mbid from acoustid |  | ||||||
|         client = get_acoustid_client() |  | ||||||
|         match = client.get_best_match(import_job.audio_file.path) |  | ||||||
|         if match: |  | ||||||
|             duration = match["recordings"][0]["duration"] |  | ||||||
|             mbid = match["recordings"][0]["id"] |  | ||||||
|             acoustid_track_id = match["id"] |  | ||||||
|     if mbid: |  | ||||||
|         logger.info( |  | ||||||
|             "[Import Job %s] importing track from musicbrainz recording %s", |  | ||||||
|             import_job.pk, |  | ||||||
|             str(mbid), |  | ||||||
|         ) |  | ||||||
|         track, _ = models.Track.get_or_create_from_api(mbid=mbid) |  | ||||||
|     elif import_job.audio_file: |  | ||||||
|         logger.info( |  | ||||||
|             "[Import Job %s] importing track from uploaded track data at %s", |  | ||||||
|             import_job.pk, |  | ||||||
|             import_job.audio_file.path, |  | ||||||
|         ) |  | ||||||
|         track = audiofile_tasks.import_track_data_from_path(import_job.audio_file.path) |  | ||||||
|     elif import_job.library_track: |  | ||||||
|         logger.info( |  | ||||||
|             "[Import Job %s] importing track from federated library track %s", |  | ||||||
|             import_job.pk, |  | ||||||
|             import_job.library_track.pk, |  | ||||||
|         ) |  | ||||||
|         track = import_track_from_remote(import_job.library_track) |  | ||||||
|     elif import_job.source.startswith("file://"): |  | ||||||
|         tf_path = import_job.source.replace("file://", "", 1) |  | ||||||
|         logger.info( |  | ||||||
|             "[Import Job %s] importing track from local track data at %s", |  | ||||||
|             import_job.pk, |  | ||||||
|             tf_path, |  | ||||||
|         ) |  | ||||||
|         track = audiofile_tasks.import_track_data_from_path(tf_path) |  | ||||||
|     else: |  | ||||||
|         raise ValueError( |  | ||||||
|             "Not enough data to process import, " |  | ||||||
|             "add a mbid, an audio file or a library track" |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     track_file = None |  | ||||||
|     if replace: |  | ||||||
|         logger.info("[Import Job %s] deleting existing audio file", import_job.pk) |  | ||||||
|         track.files.all().delete() |  | ||||||
|     elif track.files.count() > 0: |  | ||||||
|         logger.info( |  | ||||||
|             "[Import Job %s] skipping, we already have a file for this track", |  | ||||||
|             import_job.pk, |  | ||||||
|         ) |  | ||||||
|         if import_job.audio_file: |  | ||||||
|             import_job.audio_file.delete() |  | ||||||
|         import_job.status = "skipped" |  | ||||||
|         import_job.save() |  | ||||||
|         return |  | ||||||
| 
 |  | ||||||
|     track_file = track_file or models.TrackFile(track=track, source=import_job.source) |  | ||||||
|     track_file.acoustid_track_id = acoustid_track_id |  | ||||||
|     if from_file: |  | ||||||
|         track_file.audio_file = ContentFile(import_job.audio_file.read()) |  | ||||||
|         track_file.audio_file.name = import_job.audio_file.name |  | ||||||
|         track_file.duration = duration |  | ||||||
|     elif import_job.library_track: |  | ||||||
|         track_file.library_track = import_job.library_track |  | ||||||
|         track_file.mimetype = import_job.library_track.audio_mimetype |  | ||||||
|         if import_job.library_track.library.download_files: |  | ||||||
|             raise NotImplementedError() |  | ||||||
|         else: |  | ||||||
|             # no downloading, we hotlink |  | ||||||
|             pass |  | ||||||
|     elif not import_job.audio_file and not import_job.source.startswith("file://"): |  | ||||||
|         # not an inplace import, and we have a source, so let's download it |  | ||||||
|         logger.info("[Import Job %s] downloading audio file from remote", import_job.pk) |  | ||||||
|         track_file.download_file() |  | ||||||
|     elif not import_job.audio_file and import_job.source.startswith("file://"): |  | ||||||
|         # in place import, we set mimetype from extension |  | ||||||
|         path, ext = os.path.splitext(import_job.source) |  | ||||||
|         track_file.mimetype = music_utils.get_type_from_ext(ext) |  | ||||||
|     track_file.set_audio_data() |  | ||||||
|     track_file.save() |  | ||||||
|     # if no cover is set on track album, we try to update it as well: |  | ||||||
|     if not track.album.cover: |  | ||||||
|         logger.info("[Import Job %s] retrieving album cover", import_job.pk) |  | ||||||
|         update_album_cover(track.album, track_file) |  | ||||||
|     import_job.status = "finished" |  | ||||||
|     import_job.track_file = track_file |  | ||||||
|     if import_job.audio_file: |  | ||||||
|         # it's imported on the track, we don't need it anymore |  | ||||||
|         import_job.audio_file.delete() |  | ||||||
|     import_job.save() |  | ||||||
|     logger.info("[Import Job %s] job finished", import_job.pk) |  | ||||||
|     return track_file |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def update_album_cover(album, track_file, replace=False): | def update_album_cover(album, track_file, replace=False): | ||||||
|     if album.cover and not replace: |     if album.cover and not replace: | ||||||
|         return |         return | ||||||
|  | @ -240,37 +139,6 @@ def get_cover_from_fs(dir_path): | ||||||
|                 return {"mimetype": m, "content": c.read()} |                 return {"mimetype": m, "content": c.read()} | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @celery.app.task(name="ImportJob.run", bind=True) |  | ||||||
| @celery.require_instance( |  | ||||||
|     models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job" |  | ||||||
| ) |  | ||||||
| def import_job_run(self, import_job, use_acoustid=False): |  | ||||||
|     def mark_errored(exc): |  | ||||||
|         logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc)) |  | ||||||
|         import_job.status = "errored" |  | ||||||
|         import_job.save(update_fields=["status"]) |  | ||||||
| 
 |  | ||||||
|     try: |  | ||||||
|         tf = _do_import(import_job, use_acoustid=use_acoustid) |  | ||||||
|         return tf.pk if tf else None |  | ||||||
|     except Exception as exc: |  | ||||||
|         if not settings.DEBUG: |  | ||||||
|             try: |  | ||||||
|                 self.retry(exc=exc, countdown=30, max_retries=3) |  | ||||||
|             except Exception: |  | ||||||
|                 mark_errored(exc) |  | ||||||
|                 raise |  | ||||||
|         mark_errored(exc) |  | ||||||
|         raise |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @celery.app.task(name="ImportBatch.run") |  | ||||||
| @celery.require_instance(models.ImportBatch, "import_batch") |  | ||||||
| def import_batch_run(import_batch): |  | ||||||
|     for job_id in import_batch.jobs.order_by("id").values_list("id", flat=True): |  | ||||||
|         import_job_run.delay(import_job_id=job_id) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @celery.app.task(name="Lyrics.fetch_content") | @celery.app.task(name="Lyrics.fetch_content") | ||||||
| @celery.require_instance(models.Lyrics, "lyrics") | @celery.require_instance(models.Lyrics, "lyrics") | ||||||
| def fetch_content(lyrics): | def fetch_content(lyrics): | ||||||
|  | @ -301,7 +169,7 @@ def import_batch_notify_followers(import_batch): | ||||||
|     collection = federation_serializers.CollectionSerializer( |     collection = federation_serializers.CollectionSerializer( | ||||||
|         { |         { | ||||||
|             "actor": library_actor, |             "actor": library_actor, | ||||||
|             "id": import_batch.get_federation_url(), |             "id": import_batch.get_federation_id(), | ||||||
|             "items": track_files, |             "items": track_files, | ||||||
|             "item_serializer": federation_serializers.AudioSerializer, |             "item_serializer": federation_serializers.AudioSerializer, | ||||||
|         } |         } | ||||||
|  | @ -312,9 +180,266 @@ def import_batch_notify_followers(import_batch): | ||||||
|                 "type": "Create", |                 "type": "Create", | ||||||
|                 "id": collection["id"], |                 "id": collection["id"], | ||||||
|                 "object": collection, |                 "object": collection, | ||||||
|                 "actor": library_actor.url, |                 "actor": library_actor.fid, | ||||||
|                 "to": [f.url], |                 "to": [f.url], | ||||||
|             } |             } | ||||||
|         ).data |         ).data | ||||||
| 
 | 
 | ||||||
|         activity.deliver(create, on_behalf_of=library_actor, to=[f.url]) |         activity.deliver(create, on_behalf_of=library_actor, to=[f.url]) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @celery.app.task( | ||||||
|  |     name="music.start_library_scan", | ||||||
|  |     retry_backoff=60, | ||||||
|  |     max_retries=5, | ||||||
|  |     autoretry_for=[RequestException], | ||||||
|  | ) | ||||||
|  | @celery.require_instance( | ||||||
|  |     models.LibraryScan.objects.select_related().filter(status="pending"), "library_scan" | ||||||
|  | ) | ||||||
|  | def start_library_scan(library_scan): | ||||||
|  |     data = lb.get_library_data(library_scan.library.fid, actor=library_scan.actor) | ||||||
|  |     library_scan.modification_date = timezone.now() | ||||||
|  |     library_scan.status = "scanning" | ||||||
|  |     library_scan.total_files = data["totalItems"] | ||||||
|  |     library_scan.save(update_fields=["status", "modification_date", "total_files"]) | ||||||
|  |     scan_library_page.delay(library_scan_id=library_scan.pk, page_url=data["first"]) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @celery.app.task( | ||||||
|  |     name="music.scan_library_page", | ||||||
|  |     retry_backoff=60, | ||||||
|  |     max_retries=5, | ||||||
|  |     autoretry_for=[RequestException], | ||||||
|  | ) | ||||||
|  | @celery.require_instance( | ||||||
|  |     models.LibraryScan.objects.select_related().filter(status="scanning"), | ||||||
|  |     "library_scan", | ||||||
|  | ) | ||||||
|  | def scan_library_page(library_scan, page_url): | ||||||
|  |     data = lb.get_library_page(library_scan.library, page_url, library_scan.actor) | ||||||
|  |     tfs = [] | ||||||
|  | 
 | ||||||
|  |     for item_serializer in data["items"]: | ||||||
|  |         tf = item_serializer.save(library=library_scan.library) | ||||||
|  |         if tf.import_status == "pending" and not tf.track: | ||||||
|  |             # this track is not matched to any musicbrainz or other musical | ||||||
|  |             # metadata | ||||||
|  |             import_track_file.delay(track_file_id=tf.pk) | ||||||
|  |         tfs.append(tf) | ||||||
|  | 
 | ||||||
|  |     library_scan.processed_files = F("processed_files") + len(tfs) | ||||||
|  |     library_scan.modification_date = timezone.now() | ||||||
|  |     update_fields = ["modification_date", "processed_files"] | ||||||
|  | 
 | ||||||
|  |     next_page = data.get("next") | ||||||
|  |     fetch_next = next_page and next_page != page_url | ||||||
|  | 
 | ||||||
|  |     if not fetch_next: | ||||||
|  |         update_fields.append("status") | ||||||
|  |         library_scan.status = "finished" | ||||||
|  |     library_scan.save(update_fields=update_fields) | ||||||
|  | 
 | ||||||
|  |     if fetch_next: | ||||||
|  |         scan_library_page.delay(library_scan_id=library_scan.pk, page_url=next_page) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def getter(data, *keys): | ||||||
|  |     if not data: | ||||||
|  |         return | ||||||
|  |     v = data | ||||||
|  |     for k in keys: | ||||||
|  |         v = v.get(k) | ||||||
|  | 
 | ||||||
|  |     return v | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TrackFileImportError(ValueError): | ||||||
|  |     def __init__(self, code): | ||||||
|  |         self.code = code | ||||||
|  |         super().__init__(code) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def fail_import(track_file, error_code): | ||||||
|  |     old_status = track_file.import_status | ||||||
|  |     track_file.import_status = "errored" | ||||||
|  |     track_file.import_details = {"error_code": error_code} | ||||||
|  |     track_file.import_date = timezone.now() | ||||||
|  |     track_file.save(update_fields=["import_details", "import_status", "import_date"]) | ||||||
|  |     signals.track_file_import_status_updated.send( | ||||||
|  |         old_status=old_status, | ||||||
|  |         new_status=track_file.import_status, | ||||||
|  |         track_file=track_file, | ||||||
|  |         sender=None, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @celery.app.task(name="music.import_track_file") | ||||||
|  | @celery.require_instance( | ||||||
|  |     models.TrackFile.objects.filter(import_status="pending").select_related( | ||||||
|  |         "library__actor__user" | ||||||
|  |     ), | ||||||
|  |     "track_file", | ||||||
|  | ) | ||||||
|  | def import_track_file(track_file): | ||||||
|  |     data = track_file.import_metadata or {} | ||||||
|  |     old_status = track_file.import_status | ||||||
|  |     try: | ||||||
|  |         track = get_track_from_import_metadata(track_file.import_metadata or {}) | ||||||
|  |         if not track and track_file.audio_file: | ||||||
|  |             # easy ways did not work. Now we have to be smart and use | ||||||
|  |             # metadata from the file itself if any | ||||||
|  |             track = import_track_data_from_file(track_file.audio_file.file, hints=data) | ||||||
|  |         if not track and track_file.metadata: | ||||||
|  |             # we can try to import using federation metadata | ||||||
|  |             track = import_track_from_remote(track_file.metadata) | ||||||
|  |     except TrackFileImportError as e: | ||||||
|  |         return fail_import(track_file, e.code) | ||||||
|  |     except Exception: | ||||||
|  |         fail_import(track_file, "unknown_error") | ||||||
|  |         raise | ||||||
|  |     # under some situations, we want to skip the import ( | ||||||
|  |     # for instance if the user already owns the files) | ||||||
|  |     owned_duplicates = get_owned_duplicates(track_file, track) | ||||||
|  |     track_file.track = track | ||||||
|  | 
 | ||||||
|  |     if owned_duplicates: | ||||||
|  |         track_file.import_status = "skipped" | ||||||
|  |         track_file.import_details = { | ||||||
|  |             "code": "already_imported_in_owned_libraries", | ||||||
|  |             "duplicates": list(owned_duplicates), | ||||||
|  |         } | ||||||
|  |         track_file.import_date = timezone.now() | ||||||
|  |         track_file.save( | ||||||
|  |             update_fields=["import_details", "import_status", "import_date", "track"] | ||||||
|  |         ) | ||||||
|  |         signals.track_file_import_status_updated.send( | ||||||
|  |             old_status=old_status, | ||||||
|  |             new_status=track_file.import_status, | ||||||
|  |             track_file=track_file, | ||||||
|  |             sender=None, | ||||||
|  |         ) | ||||||
|  |         return | ||||||
|  | 
 | ||||||
|  |     # all is good, let's finalize the import | ||||||
|  |     audio_data = track_file.get_audio_data() | ||||||
|  |     if audio_data: | ||||||
|  |         track_file.duration = audio_data["duration"] | ||||||
|  |         track_file.size = audio_data["size"] | ||||||
|  |         track_file.bitrate = audio_data["bitrate"] | ||||||
|  |     track_file.import_status = "finished" | ||||||
|  |     track_file.import_date = timezone.now() | ||||||
|  |     track_file.save( | ||||||
|  |         update_fields=[ | ||||||
|  |             "track", | ||||||
|  |             "import_status", | ||||||
|  |             "import_date", | ||||||
|  |             "size", | ||||||
|  |             "duration", | ||||||
|  |             "bitrate", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     signals.track_file_import_status_updated.send( | ||||||
|  |         old_status=old_status, | ||||||
|  |         new_status=track_file.import_status, | ||||||
|  |         track_file=track_file, | ||||||
|  |         sender=None, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     if not track.album.cover: | ||||||
|  |         update_album_cover(track.album, track_file) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_track_from_import_metadata(data): | ||||||
|  |     track_mbid = getter(data, "track", "mbid") | ||||||
|  |     track_uuid = getter(data, "track", "uuid") | ||||||
|  | 
 | ||||||
|  |     if track_mbid: | ||||||
|  |         # easiest case: there is a MBID provided in the import_metadata | ||||||
|  |         return models.Track.get_or_create_from_api(mbid=track_mbid)[0] | ||||||
|  |     if track_uuid: | ||||||
|  |         # another easy case, we have a reference to a uuid of a track that | ||||||
|  |         # already exists in our database | ||||||
|  |         try: | ||||||
|  |             return models.Track.objects.get(uuid=track_uuid) | ||||||
|  |         except models.Track.DoesNotExist: | ||||||
|  |             raise TrackFileImportError(code="track_uuid_not_found") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_owned_duplicates(track_file, track): | ||||||
|  |     """ | ||||||
|  |     Ensure we skip duplicate tracks to avoid wasting user/instance storage | ||||||
|  |     """ | ||||||
|  |     owned_libraries = track_file.library.actor.libraries.all() | ||||||
|  |     return ( | ||||||
|  |         models.TrackFile.objects.filter( | ||||||
|  |             track__isnull=False, library__in=owned_libraries, track=track | ||||||
|  |         ) | ||||||
|  |         .exclude(pk=track_file.pk) | ||||||
|  |         .values_list("uuid", flat=True) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @transaction.atomic | ||||||
|  | def import_track_data_from_file(file, hints={}): | ||||||
|  |     data = metadata.Metadata(file) | ||||||
|  |     album = None | ||||||
|  |     track_mbid = data.get("musicbrainz_recordingid", None) | ||||||
|  |     album_mbid = data.get("musicbrainz_albumid", None) | ||||||
|  | 
 | ||||||
|  |     if album_mbid and track_mbid: | ||||||
|  |         # to gain performance and avoid additional mb lookups, | ||||||
|  |         # we import from the release data, which is already cached | ||||||
|  |         return models.Track.get_or_create_from_release(album_mbid, track_mbid)[0] | ||||||
|  |     elif track_mbid: | ||||||
|  |         return models.Track.get_or_create_from_api(track_mbid)[0] | ||||||
|  |     elif album_mbid: | ||||||
|  |         album = models.Album.get_or_create_from_api(album_mbid)[0] | ||||||
|  | 
 | ||||||
|  |     artist = album.artist if album else None | ||||||
|  |     artist_mbid = data.get("musicbrainz_artistid", None) | ||||||
|  |     if not artist: | ||||||
|  |         if artist_mbid: | ||||||
|  |             artist = models.Artist.get_or_create_from_api(artist_mbid)[0] | ||||||
|  |         else: | ||||||
|  |             artist = models.Artist.objects.get_or_create( | ||||||
|  |                 name__iexact=data.get("artist"), defaults={"name": data.get("artist")} | ||||||
|  |             )[0] | ||||||
|  | 
 | ||||||
|  |     release_date = data.get("date", default=None) | ||||||
|  |     if not album: | ||||||
|  |         album = models.Album.objects.get_or_create( | ||||||
|  |             title__iexact=data.get("album"), | ||||||
|  |             artist=artist, | ||||||
|  |             defaults={"title": data.get("album"), "release_date": release_date}, | ||||||
|  |         )[0] | ||||||
|  |     position = data.get("track_number", default=None) | ||||||
|  |     track = models.Track.objects.get_or_create( | ||||||
|  |         title__iexact=data.get("title"), | ||||||
|  |         album=album, | ||||||
|  |         defaults={"title": data.get("title"), "position": position}, | ||||||
|  |     )[0] | ||||||
|  |     return track | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @receiver(signals.track_file_import_status_updated) | ||||||
|  | def broadcast_import_status_update_to_owner( | ||||||
|  |     old_status, new_status, track_file, **kwargs | ||||||
|  | ): | ||||||
|  |     user = track_file.library.actor.get_user() | ||||||
|  |     if not user: | ||||||
|  |         return | ||||||
|  |     group = "user.{}.imports".format(user.pk) | ||||||
|  |     channels.group_send( | ||||||
|  |         group, | ||||||
|  |         { | ||||||
|  |             "type": "event.send", | ||||||
|  |             "text": "", | ||||||
|  |             "data": { | ||||||
|  |                 "type": "import.status_updated", | ||||||
|  |                 "track_file": serializers.TrackFileForOwnerSerializer(track_file).data, | ||||||
|  |                 "old_status": old_status, | ||||||
|  |                 "new_status": new_status, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | @ -58,3 +58,13 @@ def get_audio_file_data(f): | ||||||
|     d["length"] = data.info.length |     d["length"] = data.info.length | ||||||
| 
 | 
 | ||||||
|     return d |     return d | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_actor_from_request(request): | ||||||
|  |     actor = None | ||||||
|  |     if hasattr(request, "actor"): | ||||||
|  |         actor = request.actor | ||||||
|  |     elif request.user.is_authenticated: | ||||||
|  |         actor = request.user.actor | ||||||
|  | 
 | ||||||
|  |     return actor | ||||||
|  |  | ||||||
|  | @ -1,32 +1,25 @@ | ||||||
| import json |  | ||||||
| import logging | import logging | ||||||
| import urllib | import urllib | ||||||
| 
 | 
 | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.core.exceptions import ObjectDoesNotExist |  | ||||||
| from django.db import transaction | from django.db import transaction | ||||||
| from django.db.models import Count | from django.db.models import Count, Prefetch, Sum | ||||||
| from django.db.models.functions import Length | from django.db.models.functions import Length | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from musicbrainzngs import ResponseError | 
 | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
|  | from rest_framework import permissions | ||||||
| from rest_framework import settings as rest_settings | from rest_framework import settings as rest_settings | ||||||
| from rest_framework import views, viewsets | from rest_framework import views, viewsets | ||||||
| from rest_framework.decorators import detail_route, list_route | from rest_framework.decorators import detail_route, list_route | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from taggit.models import Tag | from taggit.models import Tag | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import utils as funkwhale_utils | from funkwhale_api.common import utils as common_utils | ||||||
| from funkwhale_api.common.permissions import ConditionalAuthentication | from funkwhale_api.common import permissions as common_permissions | ||||||
| from funkwhale_api.federation.authentication import SignatureAuthentication | from funkwhale_api.federation.authentication import SignatureAuthentication | ||||||
| from funkwhale_api.federation.models import LibraryTrack |  | ||||||
| from funkwhale_api.musicbrainz import api |  | ||||||
| from funkwhale_api.requests.models import ImportRequest |  | ||||||
| from funkwhale_api.users.permissions import HasUserPermission |  | ||||||
| 
 | 
 | ||||||
| from . import filters, importers, models | from . import filters, models, serializers, tasks, utils | ||||||
| from . import permissions as music_permissions |  | ||||||
| from . import serializers, tasks, utils |  | ||||||
| 
 | 
 | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
| 
 | 
 | ||||||
|  | @ -41,107 +34,65 @@ class TagViewSetMixin(object): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ArtistViewSet(viewsets.ReadOnlyModelViewSet): | class ArtistViewSet(viewsets.ReadOnlyModelViewSet): | ||||||
|     queryset = models.Artist.objects.with_albums() |     queryset = models.Artist.objects.all() | ||||||
|     serializer_class = serializers.ArtistWithAlbumsSerializer |     serializer_class = serializers.ArtistWithAlbumsSerializer | ||||||
|     permission_classes = [ConditionalAuthentication] |     permission_classes = [common_permissions.ConditionalAuthentication] | ||||||
|     filter_class = filters.ArtistFilter |     filter_class = filters.ArtistFilter | ||||||
|     ordering_fields = ("id", "name", "creation_date") |     ordering_fields = ("id", "name", "creation_date") | ||||||
| 
 | 
 | ||||||
|  |     def get_queryset(self): | ||||||
|  |         queryset = super().get_queryset() | ||||||
|  |         albums = models.Album.objects.with_tracks_count() | ||||||
|  |         return queryset.prefetch_related(Prefetch("albums", queryset=albums)).distinct() | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class AlbumViewSet(viewsets.ReadOnlyModelViewSet): | class AlbumViewSet(viewsets.ReadOnlyModelViewSet): | ||||||
|     queryset = ( |     queryset = ( | ||||||
|         models.Album.objects.all() |         models.Album.objects.all().order_by("artist", "release_date").select_related() | ||||||
|         .order_by("artist", "release_date") |  | ||||||
|         .select_related() |  | ||||||
|         .prefetch_related("tracks__artist", "tracks__files") |  | ||||||
|     ) |     ) | ||||||
|     serializer_class = serializers.AlbumSerializer |     serializer_class = serializers.AlbumSerializer | ||||||
|     permission_classes = [ConditionalAuthentication] |     permission_classes = [common_permissions.ConditionalAuthentication] | ||||||
|     ordering_fields = ("creation_date", "release_date", "title") |     ordering_fields = ("creation_date", "release_date", "title") | ||||||
|     filter_class = filters.AlbumFilter |     filter_class = filters.AlbumFilter | ||||||
| 
 | 
 | ||||||
|  |     def get_queryset(self): | ||||||
|  |         queryset = super().get_queryset() | ||||||
|  |         tracks = models.Track.objects.annotate_playable_by_actor( | ||||||
|  |             utils.get_actor_from_request(self.request) | ||||||
|  |         ).select_related("artist") | ||||||
|  |         qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks)) | ||||||
|  |         return qs.distinct() | ||||||
| 
 | 
 | ||||||
| class ImportBatchViewSet( | 
 | ||||||
|  | class LibraryViewSet( | ||||||
|     mixins.CreateModelMixin, |     mixins.CreateModelMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|  |     mixins.UpdateModelMixin, | ||||||
|  |     mixins.DestroyModelMixin, | ||||||
|     viewsets.GenericViewSet, |     viewsets.GenericViewSet, | ||||||
| ): | ): | ||||||
|  |     lookup_field = "uuid" | ||||||
|     queryset = ( |     queryset = ( | ||||||
|         models.ImportBatch.objects.select_related() |         models.Library.objects.all() | ||||||
|         .order_by("-creation_date") |         .order_by("-creation_date") | ||||||
|         .annotate(job_count=Count("jobs")) |         .annotate(_files_count=Count("files")) | ||||||
|  |         .annotate(_size=Sum("files__size")) | ||||||
|     ) |     ) | ||||||
|     serializer_class = serializers.ImportBatchSerializer |     serializer_class = serializers.LibraryForOwnerSerializer | ||||||
|     permission_classes = (HasUserPermission,) |     permission_classes = [ | ||||||
|     required_permissions = ["library", "upload"] |         permissions.IsAuthenticated, | ||||||
|     permission_operator = "or" |         common_permissions.OwnerPermission, | ||||||
|     filter_class = filters.ImportBatchFilter |     ] | ||||||
| 
 |     owner_field = "actor.user" | ||||||
|     def perform_create(self, serializer): |     owner_checks = ["read", "write"] | ||||||
|         serializer.save(submitted_by=self.request.user) |  | ||||||
| 
 | 
 | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|         qs = super().get_queryset() |         qs = super().get_queryset() | ||||||
|         # if user do not have library permission, we limit to their |         return qs.filter(actor=self.request.user.actor) | ||||||
|         # own jobs |  | ||||||
|         if not self.request.user.has_permissions("library"): |  | ||||||
|             qs = qs.filter(submitted_by=self.request.user) |  | ||||||
|         return qs |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class ImportJobViewSet( |  | ||||||
|     mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet |  | ||||||
| ): |  | ||||||
|     queryset = models.ImportJob.objects.all().select_related() |  | ||||||
|     serializer_class = serializers.ImportJobSerializer |  | ||||||
|     permission_classes = (HasUserPermission,) |  | ||||||
|     required_permissions = ["library", "upload"] |  | ||||||
|     permission_operator = "or" |  | ||||||
|     filter_class = filters.ImportJobFilter |  | ||||||
| 
 |  | ||||||
|     def get_queryset(self): |  | ||||||
|         qs = super().get_queryset() |  | ||||||
|         # if user do not have library permission, we limit to their |  | ||||||
|         # own jobs |  | ||||||
|         if not self.request.user.has_permissions("library"): |  | ||||||
|             qs = qs.filter(batch__submitted_by=self.request.user) |  | ||||||
|         return qs |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["get"]) |  | ||||||
|     def stats(self, request, *args, **kwargs): |  | ||||||
|         if not request.user.has_permissions("library"): |  | ||||||
|             return Response(status=403) |  | ||||||
|         qs = models.ImportJob.objects.all() |  | ||||||
|         filterset = filters.ImportJobFilter(request.GET, queryset=qs) |  | ||||||
|         qs = filterset.qs |  | ||||||
|         qs = qs.values("status").order_by("status") |  | ||||||
|         qs = qs.annotate(status_count=Count("status")) |  | ||||||
| 
 |  | ||||||
|         data = {} |  | ||||||
|         for row in qs: |  | ||||||
|             data[row["status"]] = row["status_count"] |  | ||||||
| 
 |  | ||||||
|         for s, _ in models.IMPORT_STATUS_CHOICES: |  | ||||||
|             data.setdefault(s, 0) |  | ||||||
| 
 |  | ||||||
|         data["count"] = sum([v for v in data.values()]) |  | ||||||
|         return Response(data) |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["post"]) |  | ||||||
|     def run(self, request, *args, **kwargs): |  | ||||||
|         serializer = serializers.ImportJobRunSerializer(data=request.data) |  | ||||||
|         serializer.is_valid(raise_exception=True) |  | ||||||
|         payload = serializer.save() |  | ||||||
| 
 |  | ||||||
|         return Response(payload) |  | ||||||
| 
 | 
 | ||||||
|     def perform_create(self, serializer): |     def perform_create(self, serializer): | ||||||
|         source = "file://" + serializer.validated_data["audio_file"].name |         serializer.save(actor=self.request.user.actor) | ||||||
|         serializer.save(source=source) |  | ||||||
|         funkwhale_utils.on_commit( |  | ||||||
|             tasks.import_job_run.delay, import_job_id=serializer.instance.pk |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): | class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): | ||||||
|  | @ -151,14 +102,13 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): | ||||||
| 
 | 
 | ||||||
|     queryset = models.Track.objects.all().for_nested_serialization() |     queryset = models.Track.objects.all().for_nested_serialization() | ||||||
|     serializer_class = serializers.TrackSerializer |     serializer_class = serializers.TrackSerializer | ||||||
|     permission_classes = [ConditionalAuthentication] |     permission_classes = [common_permissions.ConditionalAuthentication] | ||||||
|     filter_class = filters.TrackFilter |     filter_class = filters.TrackFilter | ||||||
|     ordering_fields = ( |     ordering_fields = ( | ||||||
|         "creation_date", |         "creation_date", | ||||||
|         "title", |         "title", | ||||||
|         "album__title", |  | ||||||
|         "album__release_date", |         "album__release_date", | ||||||
|         "position", |         "size", | ||||||
|         "artist__name", |         "artist__name", | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  | @ -169,7 +119,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): | ||||||
|         if user.is_authenticated and filter_favorites == "true": |         if user.is_authenticated and filter_favorites == "true": | ||||||
|             queryset = queryset.filter(track_favorites__user=user) |             queryset = queryset.filter(track_favorites__user=user) | ||||||
| 
 | 
 | ||||||
|         return queryset |         queryset = queryset.annotate_playable_by_actor( | ||||||
|  |             utils.get_actor_from_request(self.request) | ||||||
|  |         ) | ||||||
|  |         return queryset.distinct() | ||||||
| 
 | 
 | ||||||
|     @detail_route(methods=["get"]) |     @detail_route(methods=["get"]) | ||||||
|     @transaction.non_atomic_requests |     @transaction.non_atomic_requests | ||||||
|  | @ -228,40 +181,37 @@ def get_file_path(audio_file): | ||||||
|         return path.encode("utf-8") |         return path.encode("utf-8") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def handle_serve(track_file): | def handle_serve(track_file, user): | ||||||
|     f = track_file |     f = track_file | ||||||
|     # we update the accessed_date |     # we update the accessed_date | ||||||
|     f.accessed_date = timezone.now() |     f.accessed_date = timezone.now() | ||||||
|     f.save(update_fields=["accessed_date"]) |     f.save(update_fields=["accessed_date"]) | ||||||
| 
 | 
 | ||||||
|     mt = f.mimetype |     if f.audio_file: | ||||||
|     audio_file = f.audio_file |         file_path = get_file_path(f.audio_file) | ||||||
|     try: | 
 | ||||||
|         library_track = f.library_track |     elif f.source and ( | ||||||
|     except ObjectDoesNotExist: |         f.source.startswith("http://") or f.source.startswith("https://") | ||||||
|         library_track = None |     ): | ||||||
|     if library_track and not audio_file: |  | ||||||
|         if not library_track.audio_file: |  | ||||||
|         # we need to populate from cache |         # we need to populate from cache | ||||||
|         with transaction.atomic(): |         with transaction.atomic(): | ||||||
|             # why the transaction/select_for_update? |             # why the transaction/select_for_update? | ||||||
|             # this is because browsers may send multiple requests |             # this is because browsers may send multiple requests | ||||||
|             # in a short time range, for partial content, |             # in a short time range, for partial content, | ||||||
|             # thus resulting in multiple downloads from the remote |             # thus resulting in multiple downloads from the remote | ||||||
|                 qs = LibraryTrack.objects.select_for_update() |             qs = f.__class__.objects.select_for_update() | ||||||
|                 library_track = qs.get(pk=library_track.pk) |             f = qs.get(pk=f.pk) | ||||||
|                 library_track.download_audio() |             f.download_audio_from_remote(user=user) | ||||||
|             track_file.library_track = library_track |         data = f.get_audio_data() | ||||||
|             track_file.set_audio_data() |         if data: | ||||||
|             track_file.save(update_fields=["bitrate", "duration", "size"]) |             f.duration = data["duration"] | ||||||
| 
 |             f.size = data["size"] | ||||||
|         audio_file = library_track.audio_file |             f.bitrate = data["bitrate"] | ||||||
|         file_path = get_file_path(audio_file) |             f.save(update_fields=["bitrate", "duration", "size"]) | ||||||
|         mt = library_track.audio_mimetype |         file_path = get_file_path(f.audio_file) | ||||||
|     elif audio_file: |  | ||||||
|         file_path = get_file_path(audio_file) |  | ||||||
|     elif f.source and f.source.startswith("file://"): |     elif f.source and f.source.startswith("file://"): | ||||||
|         file_path = get_file_path(f.source.replace("file://", "", 1)) |         file_path = get_file_path(f.source.replace("file://", "", 1)) | ||||||
|  |     mt = f.mimetype | ||||||
|     if mt: |     if mt: | ||||||
|         response = Response(content_type=mt) |         response = Response(content_type=mt) | ||||||
|     else: |     else: | ||||||
|  | @ -278,39 +228,93 @@ def handle_serve(track_file): | ||||||
|     return response |     return response | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): | class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): | ||||||
|     queryset = ( |     queryset = models.Track.objects.all() | ||||||
|         models.TrackFile.objects.all() |     serializer_class = serializers.TrackSerializer | ||||||
|         .select_related("track__artist", "track__album") |  | ||||||
|         .order_by("-id") |  | ||||||
|     ) |  | ||||||
|     serializer_class = serializers.TrackFileSerializer |  | ||||||
|     authentication_classes = ( |     authentication_classes = ( | ||||||
|         rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES |         rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES | ||||||
|         + [SignatureAuthentication] |         + [SignatureAuthentication] | ||||||
|     ) |     ) | ||||||
|     permission_classes = [music_permissions.Listen] |     permission_classes = [common_permissions.ConditionalAuthentication] | ||||||
|  |     lookup_field = "uuid" | ||||||
| 
 | 
 | ||||||
|     @detail_route(methods=["get"]) |     def retrieve(self, request, *args, **kwargs): | ||||||
|     def serve(self, request, *args, **kwargs): |         track = self.get_object() | ||||||
|         queryset = models.TrackFile.objects.select_related( |         actor = utils.get_actor_from_request(request) | ||||||
|             "library_track", "track__album__artist", "track__artist" |         queryset = track.files.select_related("track__album__artist", "track__artist") | ||||||
|         ) |         explicit_file = request.GET.get("file") | ||||||
|         try: |         if explicit_file: | ||||||
|             return handle_serve(queryset.get(pk=kwargs["pk"])) |             queryset = queryset.filter(uuid=explicit_file) | ||||||
|         except models.TrackFile.DoesNotExist: |         queryset = queryset.playable_by(actor) | ||||||
|  |         tf = queryset.first() | ||||||
|  |         if not tf: | ||||||
|             return Response(status=404) |             return Response(status=404) | ||||||
| 
 | 
 | ||||||
|  |         return handle_serve(tf, user=request.user) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TrackFileViewSet( | ||||||
|  |     mixins.ListModelMixin, | ||||||
|  |     mixins.CreateModelMixin, | ||||||
|  |     mixins.RetrieveModelMixin, | ||||||
|  |     mixins.DestroyModelMixin, | ||||||
|  |     viewsets.GenericViewSet, | ||||||
|  | ): | ||||||
|  |     lookup_field = "uuid" | ||||||
|  |     queryset = ( | ||||||
|  |         models.TrackFile.objects.all() | ||||||
|  |         .order_by("-creation_date") | ||||||
|  |         .select_related("library", "track__artist", "track__album__artist") | ||||||
|  |     ) | ||||||
|  |     serializer_class = serializers.TrackFileForOwnerSerializer | ||||||
|  |     permission_classes = [ | ||||||
|  |         permissions.IsAuthenticated, | ||||||
|  |         common_permissions.OwnerPermission, | ||||||
|  |     ] | ||||||
|  |     owner_field = "library.actor.user" | ||||||
|  |     owner_checks = ["read", "write"] | ||||||
|  |     filter_class = filters.TrackFileFilter | ||||||
|  |     ordering_fields = ( | ||||||
|  |         "creation_date", | ||||||
|  |         "import_date", | ||||||
|  |         "bitrate", | ||||||
|  |         "size", | ||||||
|  |         "artist__name", | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     def get_queryset(self): | ||||||
|  |         qs = super().get_queryset() | ||||||
|  |         return qs.filter(library__actor=self.request.user.actor) | ||||||
|  | 
 | ||||||
|  |     @list_route(methods=["post"]) | ||||||
|  |     def action(self, request, *args, **kwargs): | ||||||
|  |         queryset = self.get_queryset() | ||||||
|  |         serializer = serializers.TrackFileActionSerializer( | ||||||
|  |             request.data, queryset=queryset | ||||||
|  |         ) | ||||||
|  |         serializer.is_valid(raise_exception=True) | ||||||
|  |         result = serializer.save() | ||||||
|  |         return Response(result, status=200) | ||||||
|  | 
 | ||||||
|  |     def get_serializer_context(self): | ||||||
|  |         context = super().get_serializer_context() | ||||||
|  |         context["user"] = self.request.user | ||||||
|  |         return context | ||||||
|  | 
 | ||||||
|  |     def perform_create(self, serializer): | ||||||
|  |         tf = serializer.save() | ||||||
|  |         common_utils.on_commit(tasks.import_track_file.delay, track_file_id=tf.pk) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class TagViewSet(viewsets.ReadOnlyModelViewSet): | class TagViewSet(viewsets.ReadOnlyModelViewSet): | ||||||
|     queryset = Tag.objects.all().order_by("name") |     queryset = Tag.objects.all().order_by("name") | ||||||
|     serializer_class = serializers.TagSerializer |     serializer_class = serializers.TagSerializer | ||||||
|     permission_classes = [ConditionalAuthentication] |     permission_classes = [common_permissions.ConditionalAuthentication] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Search(views.APIView): | class Search(views.APIView): | ||||||
|     max_results = 3 |     max_results = 3 | ||||||
|     permission_classes = [ConditionalAuthentication] |     permission_classes = [common_permissions.ConditionalAuthentication] | ||||||
| 
 | 
 | ||||||
|     def get(self, request, *args, **kwargs): |     def get(self, request, *args, **kwargs): | ||||||
|         query = request.GET["query"] |         query = request.GET["query"] | ||||||
|  | @ -340,7 +344,6 @@ class Search(views.APIView): | ||||||
|             models.Track.objects.all() |             models.Track.objects.all() | ||||||
|             .filter(query_obj) |             .filter(query_obj) | ||||||
|             .select_related("artist", "album__artist") |             .select_related("artist", "album__artist") | ||||||
|             .prefetch_related("files") |  | ||||||
|         )[: self.max_results] |         )[: self.max_results] | ||||||
| 
 | 
 | ||||||
|     def get_albums(self, query): |     def get_albums(self, query): | ||||||
|  | @ -350,7 +353,7 @@ class Search(views.APIView): | ||||||
|             models.Album.objects.all() |             models.Album.objects.all() | ||||||
|             .filter(query_obj) |             .filter(query_obj) | ||||||
|             .select_related() |             .select_related() | ||||||
|             .prefetch_related("tracks__files") |             .prefetch_related("tracks") | ||||||
|         )[: self.max_results] |         )[: self.max_results] | ||||||
| 
 | 
 | ||||||
|     def get_artists(self, query): |     def get_artists(self, query): | ||||||
|  | @ -372,99 +375,3 @@ class Search(views.APIView): | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         return qs.filter(query_obj)[: self.max_results] |         return qs.filter(query_obj)[: self.max_results] | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class SubmitViewSet(viewsets.ViewSet): |  | ||||||
|     queryset = models.ImportBatch.objects.none() |  | ||||||
|     permission_classes = (HasUserPermission,) |  | ||||||
|     required_permissions = ["library"] |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["post"]) |  | ||||||
|     @transaction.non_atomic_requests |  | ||||||
|     def single(self, request, *args, **kwargs): |  | ||||||
|         try: |  | ||||||
|             models.Track.objects.get(mbid=request.POST["mbid"]) |  | ||||||
|             return Response({}) |  | ||||||
|         except models.Track.DoesNotExist: |  | ||||||
|             pass |  | ||||||
|         batch = models.ImportBatch.objects.create(submitted_by=request.user) |  | ||||||
|         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, status=201) |  | ||||||
| 
 |  | ||||||
|     def get_import_request(self, data): |  | ||||||
|         try: |  | ||||||
|             raw = data["importRequest"] |  | ||||||
|         except KeyError: |  | ||||||
|             return |  | ||||||
| 
 |  | ||||||
|         pk = int(raw) |  | ||||||
|         try: |  | ||||||
|             return ImportRequest.objects.get(pk=pk) |  | ||||||
|         except ImportRequest.DoesNotExist: |  | ||||||
|             pass |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["post"]) |  | ||||||
|     @transaction.non_atomic_requests |  | ||||||
|     def album(self, request, *args, **kwargs): |  | ||||||
|         data = json.loads(request.body.decode("utf-8")) |  | ||||||
|         import_request = self.get_import_request(data) |  | ||||||
|         import_data, batch = self._import_album( |  | ||||||
|             data, request, batch=None, import_request=import_request |  | ||||||
|         ) |  | ||||||
|         return Response(import_data) |  | ||||||
| 
 |  | ||||||
|     @transaction.atomic |  | ||||||
|     def _import_album(self, data, request, batch=None, import_request=None): |  | ||||||
|         # we import the whole album here to prevent race conditions that occurs |  | ||||||
|         # when using get_or_create_from_api in tasks |  | ||||||
|         album_data = api.releases.get( |  | ||||||
|             id=data["releaseId"], includes=models.Album.api_includes |  | ||||||
|         )["release"] |  | ||||||
|         cleaned_data = models.Album.clean_musicbrainz_data(album_data) |  | ||||||
|         album = importers.load( |  | ||||||
|             models.Album, cleaned_data, album_data, import_hooks=[models.import_tracks] |  | ||||||
|         ) |  | ||||||
|         try: |  | ||||||
|             album.get_image() |  | ||||||
|         except ResponseError: |  | ||||||
|             pass |  | ||||||
|         if not batch: |  | ||||||
|             batch = models.ImportBatch.objects.create( |  | ||||||
|                 submitted_by=request.user, import_request=import_request |  | ||||||
|             ) |  | ||||||
|         for row in data["tracks"]: |  | ||||||
|             try: |  | ||||||
|                 models.TrackFile.objects.get(track__mbid=row["mbid"]) |  | ||||||
|             except models.TrackFile.DoesNotExist: |  | ||||||
|                 job = models.ImportJob.objects.create( |  | ||||||
|                     mbid=row["mbid"], batch=batch, source=row["source"] |  | ||||||
|                 ) |  | ||||||
|                 funkwhale_utils.on_commit( |  | ||||||
|                     tasks.import_job_run.delay, import_job_id=job.pk |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|         serializer = serializers.ImportBatchSerializer(batch) |  | ||||||
|         return serializer.data, batch |  | ||||||
| 
 |  | ||||||
|     @list_route(methods=["post"]) |  | ||||||
|     @transaction.non_atomic_requests |  | ||||||
|     def artist(self, request, *args, **kwargs): |  | ||||||
|         data = json.loads(request.body.decode("utf-8")) |  | ||||||
|         import_request = self.get_import_request(data) |  | ||||||
|         artist_data = api.artists.get(id=data["artistId"])["artist"] |  | ||||||
|         cleaned_data = models.Artist.clean_musicbrainz_data(artist_data) |  | ||||||
|         importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[]) |  | ||||||
| 
 |  | ||||||
|         import_data = [] |  | ||||||
|         batch = None |  | ||||||
|         for row in data["albums"]: |  | ||||||
|             row_data, batch = self._import_album( |  | ||||||
|                 row, request, batch=batch, import_request=import_request |  | ||||||
|             ) |  | ||||||
|             import_data.append(row_data) |  | ||||||
| 
 |  | ||||||
|         return Response(import_data[0]) |  | ||||||
|  |  | ||||||
|  | @ -8,7 +8,7 @@ from . import models | ||||||
| 
 | 
 | ||||||
| class PlaylistFilter(filters.FilterSet): | class PlaylistFilter(filters.FilterSet): | ||||||
|     q = filters.CharFilter(name="_", method="filter_q") |     q = filters.CharFilter(name="_", method="filter_q") | ||||||
|     listenable = filters.BooleanFilter(name="_", method="filter_listenable") |     playable = filters.BooleanFilter(name="_", method="filter_playable") | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Playlist |         model = models.Playlist | ||||||
|  | @ -16,10 +16,10 @@ class PlaylistFilter(filters.FilterSet): | ||||||
|             "user": ["exact"], |             "user": ["exact"], | ||||||
|             "name": ["exact", "icontains"], |             "name": ["exact", "icontains"], | ||||||
|             "q": "exact", |             "q": "exact", | ||||||
|             "listenable": "exact", |             "playable": "exact", | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|     def filter_listenable(self, queryset, name, value): |     def filter_playable(self, queryset, name, value): | ||||||
|         queryset = queryset.annotate(plts_count=Count("playlist_tracks")) |         queryset = queryset.annotate(plts_count=Count("playlist_tracks")) | ||||||
|         if value: |         if value: | ||||||
|             return queryset.filter(plts_count__gt=0) |             return queryset.filter(plts_count__gt=0) | ||||||
|  |  | ||||||
|  | @ -1,45 +0,0 @@ | ||||||
| from django.db import transaction |  | ||||||
| 
 |  | ||||||
| from funkwhale_api.music import metadata, models |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @transaction.atomic |  | ||||||
| def import_track_data_from_path(path): |  | ||||||
|     data = metadata.Metadata(path) |  | ||||||
|     album = None |  | ||||||
|     track_mbid = data.get("musicbrainz_recordingid", None) |  | ||||||
|     album_mbid = data.get("musicbrainz_albumid", None) |  | ||||||
| 
 |  | ||||||
|     if album_mbid and track_mbid: |  | ||||||
|         # to gain performance and avoid additional mb lookups, |  | ||||||
|         # we import from the release data, which is already cached |  | ||||||
|         return models.Track.get_or_create_from_release(album_mbid, track_mbid)[0] |  | ||||||
|     elif track_mbid: |  | ||||||
|         return models.Track.get_or_create_from_api(track_mbid)[0] |  | ||||||
|     elif album_mbid: |  | ||||||
|         album = models.Album.get_or_create_from_api(album_mbid)[0] |  | ||||||
| 
 |  | ||||||
|     artist = album.artist if album else None |  | ||||||
|     artist_mbid = data.get("musicbrainz_artistid", None) |  | ||||||
|     if not artist: |  | ||||||
|         if artist_mbid: |  | ||||||
|             artist = models.Artist.get_or_create_from_api(artist_mbid)[0] |  | ||||||
|         else: |  | ||||||
|             artist = models.Artist.objects.get_or_create( |  | ||||||
|                 name__iexact=data.get("artist"), defaults={"name": data.get("artist")} |  | ||||||
|             )[0] |  | ||||||
| 
 |  | ||||||
|     release_date = data.get("date", default=None) |  | ||||||
|     if not album: |  | ||||||
|         album = models.Album.objects.get_or_create( |  | ||||||
|             title__iexact=data.get("album"), |  | ||||||
|             artist=artist, |  | ||||||
|             defaults={"title": data.get("album"), "release_date": release_date}, |  | ||||||
|         )[0] |  | ||||||
|     position = data.get("track_number", default=None) |  | ||||||
|     track = models.Track.objects.get_or_create( |  | ||||||
|         title__iexact=data.get("title"), |  | ||||||
|         album=album, |  | ||||||
|         defaults={"title": data.get("title"), "position": position}, |  | ||||||
|     )[0] |  | ||||||
|     return track |  | ||||||
|  | @ -1,16 +1,10 @@ | ||||||
| from django.conf.urls import include, url | from django.conf.urls import include, url | ||||||
| 
 | 
 | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     url( |  | ||||||
|         r"^youtube/", |  | ||||||
|         include( |  | ||||||
|             ("funkwhale_api.providers.youtube.urls", "youtube"), namespace="youtube" |  | ||||||
|         ), |  | ||||||
|     ), |  | ||||||
|     url( |     url( | ||||||
|         r"^musicbrainz/", |         r"^musicbrainz/", | ||||||
|         include( |         include( | ||||||
|             ("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz" |             ("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz" | ||||||
|         ), |         ), | ||||||
|     ), |     ) | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | @ -1,80 +0,0 @@ | ||||||
| import threading |  | ||||||
| 
 |  | ||||||
| from apiclient.discovery import build |  | ||||||
| from dynamic_preferences.registries import global_preferences_registry as registry |  | ||||||
| 
 |  | ||||||
| YOUTUBE_API_SERVICE_NAME = "youtube" |  | ||||||
| YOUTUBE_API_VERSION = "v3" |  | ||||||
| VIDEO_BASE_URL = "https://www.youtube.com/watch?v={0}" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def _do_search(query): |  | ||||||
|     manager = registry.manager() |  | ||||||
|     youtube = build( |  | ||||||
|         YOUTUBE_API_SERVICE_NAME, |  | ||||||
|         YOUTUBE_API_VERSION, |  | ||||||
|         developerKey=manager["providers_youtube__api_key"], |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     return youtube.search().list(q=query, part="id,snippet", maxResults=25).execute() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class Client(object): |  | ||||||
|     def search(self, query): |  | ||||||
|         search_response = _do_search(query) |  | ||||||
|         videos = [] |  | ||||||
|         for search_result in search_response.get("items", []): |  | ||||||
|             if search_result["id"]["kind"] == "youtube#video": |  | ||||||
|                 search_result["full_url"] = VIDEO_BASE_URL.format( |  | ||||||
|                     search_result["id"]["videoId"] |  | ||||||
|                 ) |  | ||||||
|                 videos.append(search_result) |  | ||||||
|         return videos |  | ||||||
| 
 |  | ||||||
|     def search_multiple(self, queries): |  | ||||||
|         results = {} |  | ||||||
| 
 |  | ||||||
|         def search(key, query): |  | ||||||
|             results[key] = self.search(query) |  | ||||||
| 
 |  | ||||||
|         threads = [ |  | ||||||
|             threading.Thread(target=search, args=(key, query)) |  | ||||||
|             for key, query in queries.items() |  | ||||||
|         ] |  | ||||||
|         for thread in threads: |  | ||||||
|             thread.start() |  | ||||||
|         for thread in threads: |  | ||||||
|             thread.join() |  | ||||||
| 
 |  | ||||||
|         return results |  | ||||||
| 
 |  | ||||||
|     def to_funkwhale(self, result): |  | ||||||
|         """ |  | ||||||
|         We convert youtube results to something more generic. |  | ||||||
| 
 |  | ||||||
|         { |  | ||||||
|             "id": "video id", |  | ||||||
|             "type": "youtube#video", |  | ||||||
|             "url": "https://www.youtube.com/watch?v=id", |  | ||||||
|             "description": "description", |  | ||||||
|             "channelId": "Channel id", |  | ||||||
|             "title": "Title", |  | ||||||
|             "channelTitle": "channel Title", |  | ||||||
|             "publishedAt": "2012-08-22T18:41:03.000Z", |  | ||||||
|             "cover": "http://coverurl" |  | ||||||
|         } |  | ||||||
|         """ |  | ||||||
|         return { |  | ||||||
|             "id": result["id"]["videoId"], |  | ||||||
|             "url": "https://www.youtube.com/watch?v={}".format(result["id"]["videoId"]), |  | ||||||
|             "type": result["id"]["kind"], |  | ||||||
|             "title": result["snippet"]["title"], |  | ||||||
|             "description": result["snippet"]["description"], |  | ||||||
|             "channelId": result["snippet"]["channelId"], |  | ||||||
|             "channelTitle": result["snippet"]["channelTitle"], |  | ||||||
|             "publishedAt": result["snippet"]["publishedAt"], |  | ||||||
|             "cover": result["snippet"]["thumbnails"]["high"]["url"], |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| client = Client() |  | ||||||
|  | @ -1,16 +0,0 @@ | ||||||
| from django import forms |  | ||||||
| from dynamic_preferences.registries import global_preferences_registry |  | ||||||
| from dynamic_preferences.types import Section, StringPreference |  | ||||||
| 
 |  | ||||||
| youtube = Section("providers_youtube") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @global_preferences_registry.register |  | ||||||
| class APIKey(StringPreference): |  | ||||||
|     section = youtube |  | ||||||
|     name = "api_key" |  | ||||||
|     default = "CHANGEME" |  | ||||||
|     verbose_name = "YouTube API key" |  | ||||||
|     help_text = "The API key used to query YouTube. Get one at https://console.developers.google.com/." |  | ||||||
|     widget = forms.PasswordInput |  | ||||||
|     field_kwargs = {"required": False} |  | ||||||
|  | @ -1,8 +0,0 @@ | ||||||
| from django.conf.urls import url |  | ||||||
| 
 |  | ||||||
| from .views import APISearch, APISearchs |  | ||||||
| 
 |  | ||||||
| urlpatterns = [ |  | ||||||
|     url(r"^search/$", APISearch.as_view(), name="search"), |  | ||||||
|     url(r"^searchs/$", APISearchs.as_view(), name="searchs"), |  | ||||||
| ] |  | ||||||
|  | @ -1,27 +0,0 @@ | ||||||
| from rest_framework.response import Response |  | ||||||
| from rest_framework.views import APIView |  | ||||||
| 
 |  | ||||||
| from funkwhale_api.common.permissions import ConditionalAuthentication |  | ||||||
| 
 |  | ||||||
| from .client import client |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class APISearch(APIView): |  | ||||||
|     permission_classes = [ConditionalAuthentication] |  | ||||||
| 
 |  | ||||||
|     def get(self, request, *args, **kwargs): |  | ||||||
|         results = client.search(request.GET["query"]) |  | ||||||
|         return Response([client.to_funkwhale(result) for result in results]) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class APISearchs(APIView): |  | ||||||
|     permission_classes = [ConditionalAuthentication] |  | ||||||
| 
 |  | ||||||
|     def post(self, request, *args, **kwargs): |  | ||||||
|         results = client.search_multiple(request.data) |  | ||||||
|         return Response( |  | ||||||
|             { |  | ||||||
|                 key: [client.to_funkwhale(result) for result in group] |  | ||||||
|                 for key, group in results.items() |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|  | @ -177,13 +177,11 @@ class SubsonicViewSet(viewsets.GenericViewSet): | ||||||
|     @find_object(music_models.Track.objects.all()) |     @find_object(music_models.Track.objects.all()) | ||||||
|     def stream(self, request, *args, **kwargs): |     def stream(self, request, *args, **kwargs): | ||||||
|         track = kwargs.pop("obj") |         track = kwargs.pop("obj") | ||||||
|         queryset = track.files.select_related( |         queryset = track.files.select_related("track__album__artist", "track__artist") | ||||||
|             "library_track", "track__album__artist", "track__artist" |  | ||||||
|         ) |  | ||||||
|         track_file = queryset.first() |         track_file = queryset.first() | ||||||
|         if not track_file: |         if not track_file: | ||||||
|             return response.Response(status=404) |             return response.Response(status=404) | ||||||
|         return music_views.handle_serve(track_file) |         return music_views.handle_serve(track_file=track_file, user=request.user) | ||||||
| 
 | 
 | ||||||
|     @list_route(methods=["get", "post"], url_name="star", url_path="star") |     @list_route(methods=["get", "post"], url_name="star", url_path="star") | ||||||
|     @find_object(music_models.Track.objects.all()) |     @find_object(music_models.Track.objects.all()) | ||||||
|  |  | ||||||
|  | @ -2,20 +2,29 @@ | ||||||
| from __future__ import absolute_import | from __future__ import absolute_import | ||||||
| 
 | 
 | ||||||
| import functools | import functools | ||||||
|  | import traceback as tb | ||||||
| import os | import os | ||||||
| 
 | import logging | ||||||
| from celery import Celery | import celery.app.task | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | logger = logging.getLogger("celery") | ||||||
|  | 
 | ||||||
| if not settings.configured: | if not settings.configured: | ||||||
|     # set the default Django settings module for the 'celery' program. |     # set the default Django settings module for the 'celery' program. | ||||||
|     os.environ.setdefault( |     os.environ.setdefault( | ||||||
|         "DJANGO_SETTINGS_MODULE", "config.settings.local" |         "DJANGO_SETTINGS_MODULE", "config.settings.local" | ||||||
|     )  # pragma: no cover |     )  # pragma: no cover | ||||||
| 
 | 
 | ||||||
|  | app = celery.Celery("funkwhale_api") | ||||||
| 
 | 
 | ||||||
| app = Celery("funkwhale_api") | 
 | ||||||
|  | @celery.signals.task_failure.connect | ||||||
|  | def process_failure(sender, task_id, exception, args, kwargs, traceback, einfo, **kw): | ||||||
|  |     print("[celery] Error during task {}: {}".format(task_id, einfo.exception)) | ||||||
|  |     tb.print_exc() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class CeleryConfig(AppConfig): | class CeleryConfig(AppConfig): | ||||||
|  |  | ||||||
|  | @ -28,3 +28,13 @@ class DefaultPermissions(common_preferences.StringListPreference): | ||||||
|     help_text = "A list of default preferences to give to all registered users." |     help_text = "A list of default preferences to give to all registered users." | ||||||
|     choices = [(k, c["label"]) for k, c in models.PERMISSIONS_CONFIGURATION.items()] |     choices = [(k, c["label"]) for k, c in models.PERMISSIONS_CONFIGURATION.items()] | ||||||
|     field_kwargs = {"choices": choices, "required": False} |     field_kwargs = {"choices": choices, "required": False} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @global_preferences_registry.register | ||||||
|  | class UploadQuota(types.IntPreference): | ||||||
|  |     show_in_api = True | ||||||
|  |     section = users | ||||||
|  |     name = "upload_quota" | ||||||
|  |     default = 1000 | ||||||
|  |     verbose_name = "Upload quota" | ||||||
|  |     help_text = "Default upload quota applied to each users, in MB. This can be overrided on a per-user basis." | ||||||
|  |  | ||||||
|  | @ -5,19 +5,21 @@ from django.db import migrations, models | ||||||
| 
 | 
 | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [("users", "0007_auto_20180524_2009")] | ||||||
|         ('users', '0007_auto_20180524_2009'), |  | ||||||
|     ] |  | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
|         migrations.AddField( |         migrations.AddField( | ||||||
|             model_name='user', |             model_name="user", | ||||||
|             name='last_activity', |             name="last_activity", | ||||||
|             field=models.DateTimeField(blank=True, default=None, null=True), |             field=models.DateTimeField(blank=True, default=None, null=True), | ||||||
|         ), |         ), | ||||||
|         migrations.AlterField( |         migrations.AlterField( | ||||||
|             model_name='user', |             model_name="user", | ||||||
|             name='permission_library', |             name="permission_library", | ||||||
|             field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'), |             field=models.BooleanField( | ||||||
|  |                 default=False, | ||||||
|  |                 help_text="Manage library, delete files, tracks, artists, albums...", | ||||||
|  |                 verbose_name="Manage library", | ||||||
|  |             ), | ||||||
|         ), |         ), | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  | @ -8,24 +8,46 @@ import django.utils.timezone | ||||||
| 
 | 
 | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [("users", "0008_auto_20180617_1531")] | ||||||
|         ('users', '0008_auto_20180617_1531'), |  | ||||||
|     ] |  | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
|         migrations.CreateModel( |         migrations.CreateModel( | ||||||
|             name='Invitation', |             name="Invitation", | ||||||
|             fields=[ |             fields=[ | ||||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |                 ( | ||||||
|                 ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), |                     "id", | ||||||
|                 ('expiration_date', models.DateTimeField()), |                     models.AutoField( | ||||||
|                 ('code', models.CharField(max_length=50, unique=True)), |                         auto_created=True, | ||||||
|                 ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)), |                         primary_key=True, | ||||||
|  |                         serialize=False, | ||||||
|  |                         verbose_name="ID", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "creation_date", | ||||||
|  |                     models.DateTimeField(default=django.utils.timezone.now), | ||||||
|  |                 ), | ||||||
|  |                 ("expiration_date", models.DateTimeField()), | ||||||
|  |                 ("code", models.CharField(max_length=50, unique=True)), | ||||||
|  |                 ( | ||||||
|  |                     "owner", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                         related_name="invitations", | ||||||
|  |                         to=settings.AUTH_USER_MODEL, | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|             ], |             ], | ||||||
|         ), |         ), | ||||||
|         migrations.AddField( |         migrations.AddField( | ||||||
|             model_name='user', |             model_name="user", | ||||||
|             name='invitation', |             name="invitation", | ||||||
|             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'), |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  |                 related_name="users", | ||||||
|  |                 to="users.Invitation", | ||||||
|  |             ), | ||||||
|         ), |         ), | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  | @ -7,14 +7,22 @@ import funkwhale_api.common.validators | ||||||
| 
 | 
 | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [("users", "0009_auto_20180619_2024")] | ||||||
|         ('users', '0009_auto_20180619_2024'), |  | ||||||
|     ] |  | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
|         migrations.AddField( |         migrations.AddField( | ||||||
|             model_name='user', |             model_name="user", | ||||||
|             name='avatar', |             name="avatar", | ||||||
|             field=models.ImageField(blank=True, max_length=150, null=True, upload_to=funkwhale_api.common.utils.ChunkedPath('users/avatars'), validators=[funkwhale_api.common.validators.ImageDimensionsValidator(max_height=400, max_width=400, min_height=50, min_width=50)]), |             field=models.ImageField( | ||||||
|  |                 blank=True, | ||||||
|  |                 max_length=150, | ||||||
|  |                 null=True, | ||||||
|  |                 upload_to=funkwhale_api.common.utils.ChunkedPath("users/avatars"), | ||||||
|  |                 validators=[ | ||||||
|  |                     funkwhale_api.common.validators.ImageDimensionsValidator( | ||||||
|  |                         max_height=400, max_width=400, min_height=50, min_width=50 | ||||||
|  |                     ) | ||||||
|  |                 ], | ||||||
|             ), |             ), | ||||||
|  |         ) | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  | @ -10,19 +10,41 @@ import versatileimagefield.fields | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| 
 | 
 | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ('federation', '0006_auto_20180521_1702'), |         ("federation", "0006_auto_20180521_1702"), | ||||||
|         ('users', '0010_user_avatar'), |         ("users", "0010_user_avatar"), | ||||||
|     ] |     ] | ||||||
| 
 | 
 | ||||||
|     operations = [ |     operations = [ | ||||||
|         migrations.AddField( |         migrations.AddField( | ||||||
|             model_name='user', |             model_name="user", | ||||||
|             name='actor', |             name="actor", | ||||||
|             field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='federation.Actor'), |             field=models.OneToOneField( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  |                 related_name="user", | ||||||
|  |                 to="federation.Actor", | ||||||
|  |             ), | ||||||
|         ), |         ), | ||||||
|         migrations.AlterField( |         migrations.AlterField( | ||||||
|             model_name='user', |             model_name="user", | ||||||
|             name='avatar', |             name="avatar", | ||||||
|             field=versatileimagefield.fields.VersatileImageField(blank=True, max_length=150, null=True, upload_to=funkwhale_api.common.utils.ChunkedPath('users/avatars', preserve_file_name=False), validators=[funkwhale_api.common.validators.ImageDimensionsValidator(min_height=50, min_width=50), funkwhale_api.common.validators.FileValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif'], max_size=2097152)]), |             field=versatileimagefield.fields.VersatileImageField( | ||||||
|  |                 blank=True, | ||||||
|  |                 max_length=150, | ||||||
|  |                 null=True, | ||||||
|  |                 upload_to=funkwhale_api.common.utils.ChunkedPath( | ||||||
|  |                     "users/avatars", preserve_file_name=False | ||||||
|  |                 ), | ||||||
|  |                 validators=[ | ||||||
|  |                     funkwhale_api.common.validators.ImageDimensionsValidator( | ||||||
|  |                         min_height=50, min_width=50 | ||||||
|  |                     ), | ||||||
|  |                     funkwhale_api.common.validators.FileValidator( | ||||||
|  |                         allowed_extensions=["png", "jpg", "jpeg", "gif"], | ||||||
|  |                         max_size=2097152, | ||||||
|  |                     ), | ||||||
|  |                 ], | ||||||
|  |             ), | ||||||
|         ), |         ), | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  | @ -0,0 +1,16 @@ | ||||||
|  | # Generated by Django 2.0.7 on 2018-08-01 16:32 | ||||||
|  | 
 | ||||||
|  | from django.db import migrations, models | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  | 
 | ||||||
|  |     dependencies = [("users", "0011_auto_20180721_1317")] | ||||||
|  | 
 | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="user", | ||||||
|  |             name="upload_quota", | ||||||
|  |             field=models.PositiveIntegerField(blank=True, null=True), | ||||||
|  |         ) | ||||||
|  |     ] | ||||||
|  | @ -122,6 +122,8 @@ class User(AbstractUser): | ||||||
|         blank=True, |         blank=True, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |     upload_quota = models.PositiveIntegerField(null=True, blank=True) | ||||||
|  | 
 | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.username |         return self.username | ||||||
| 
 | 
 | ||||||
|  | @ -182,6 +184,32 @@ class User(AbstractUser): | ||||||
|             self.last_activity = now |             self.last_activity = now | ||||||
|             self.save(update_fields=["last_activity"]) |             self.save(update_fields=["last_activity"]) | ||||||
| 
 | 
 | ||||||
|  |     def create_actor(self): | ||||||
|  |         self.actor = create_actor(self) | ||||||
|  |         self.save(update_fields=["actor"]) | ||||||
|  |         return self.actor | ||||||
|  | 
 | ||||||
|  |     def get_upload_quota(self): | ||||||
|  |         return self.upload_quota or preferences.get("users__upload_quota") | ||||||
|  | 
 | ||||||
|  |     def get_quota_status(self): | ||||||
|  |         data = self.actor.get_current_usage() | ||||||
|  |         max_ = self.get_upload_quota() | ||||||
|  |         return { | ||||||
|  |             "max": max_, | ||||||
|  |             "remaining": max(max_ - (data["total"] / 1000 / 1000), 0), | ||||||
|  |             "current": data["total"] / 1000 / 1000, | ||||||
|  |             "skipped": data["skipped"] / 1000 / 1000, | ||||||
|  |             "pending": data["pending"] / 1000 / 1000, | ||||||
|  |             "finished": data["finished"] / 1000 / 1000, | ||||||
|  |             "errored": data["errored"] / 1000 / 1000, | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     def get_channels_groups(self): | ||||||
|  |         groups = ["imports"] | ||||||
|  | 
 | ||||||
|  |         return ["user.{}.{}".format(self.pk, g) for g in groups] | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def generate_code(length=10): | def generate_code(length=10): | ||||||
|     return "".join( |     return "".join( | ||||||
|  | @ -229,7 +257,7 @@ def create_actor(user): | ||||||
|         "type": "Person", |         "type": "Person", | ||||||
|         "name": user.username, |         "name": user.username, | ||||||
|         "manually_approves_followers": False, |         "manually_approves_followers": False, | ||||||
|         "url": federation_utils.full_url( |         "fid": federation_utils.full_url( | ||||||
|             reverse("federation:actors-detail", kwargs={"preferred_username": username}) |             reverse("federation:actors-detail", kwargs={"preferred_username": username}) | ||||||
|         ), |         ), | ||||||
|         "shared_inbox_url": federation_utils.full_url( |         "shared_inbox_url": federation_utils.full_url( | ||||||
|  |  | ||||||
|  | @ -109,6 +109,16 @@ class UserReadSerializer(serializers.ModelSerializer): | ||||||
|         return o.get_permissions() |         return o.get_permissions() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class MeSerializer(UserReadSerializer): | ||||||
|  |     quota_status = serializers.SerializerMethodField() | ||||||
|  | 
 | ||||||
|  |     class Meta(UserReadSerializer.Meta): | ||||||
|  |         fields = UserReadSerializer.Meta.fields + ["quota_status"] | ||||||
|  | 
 | ||||||
|  |     def get_quota_status(self, o): | ||||||
|  |         return o.get_quota_status() if o.actor else 0 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class PasswordResetSerializer(PRS): | class PasswordResetSerializer(PRS): | ||||||
|     def get_email_options(self): |     def get_email_options(self): | ||||||
|         return {"extra_email_context": {"funkwhale_url": settings.FUNKWHALE_URL}} |         return {"extra_email_context": {"funkwhale_url": settings.FUNKWHALE_URL}} | ||||||
|  |  | ||||||
|  | @ -31,7 +31,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): | ||||||
|     @list_route(methods=["get"]) |     @list_route(methods=["get"]) | ||||||
|     def me(self, request, *args, **kwargs): |     def me(self, request, *args, **kwargs): | ||||||
|         """Return information about the current user""" |         """Return information about the current user""" | ||||||
|         serializer = serializers.UserReadSerializer(request.user) |         serializer = serializers.MeSerializer(request.user) | ||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
| 
 | 
 | ||||||
|     @detail_route(methods=["get", "post", "delete"], url_path="subsonic-token") |     @detail_route(methods=["get", "post", "delete"], url_path="subsonic-token") | ||||||
|  |  | ||||||
|  | @ -42,3 +42,31 @@ def test_django_permissions_to_user_permissions(factories, command): | ||||||
|     assert user2.permission_settings is False |     assert user2.permission_settings is False | ||||||
|     assert user2.permission_library is True |     assert user2.permission_library is True | ||||||
|     assert user2.permission_federation is True |     assert user2.permission_federation is True | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.skip("Refactoring in progress") | ||||||
|  | def test_migrate_to_user_libraries(factories, command): | ||||||
|  |     user1 = factories["users.User"](is_superuser=False, with_actor=True) | ||||||
|  |     user2 = factories["users.User"](is_superuser=True, with_actor=True) | ||||||
|  |     factories["users.User"](is_superuser=True) | ||||||
|  |     no_import_files = factories["music.TrackFile"].create_batch(size=5, library=None) | ||||||
|  |     import_jobs = factories["music.ImportJob"].create_batch( | ||||||
|  |         batch__submitted_by=user1, size=5, finished=True | ||||||
|  |     ) | ||||||
|  |     # we delete libraries that are created automatically | ||||||
|  |     for j in import_jobs: | ||||||
|  |         j.track_file.library = None | ||||||
|  |         j.track_file.save() | ||||||
|  |     scripts.migrate_to_user_libraries.main(command) | ||||||
|  | 
 | ||||||
|  |     # tracks with import jobs are bound to the importer's library | ||||||
|  |     library = user1.actor.libraries.get(name="default") | ||||||
|  |     assert list(library.files.order_by("id").values_list("id", flat=True)) == sorted( | ||||||
|  |         [ij.track_file.pk for ij in import_jobs] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     # tracks without import jobs are bound to first superuser | ||||||
|  |     library = user2.actor.libraries.get(name="default") | ||||||
|  |     assert list(library.files.order_by("id").values_list("id", flat=True)) == sorted( | ||||||
|  |         [tf.pk for tf in no_import_files] | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | @ -1,5 +1,7 @@ | ||||||
|  | import contextlib | ||||||
| import datetime | import datetime | ||||||
| import io | import io | ||||||
|  | import os | ||||||
| import PIL | import PIL | ||||||
| import random | import random | ||||||
| import shutil | import shutil | ||||||
|  | @ -10,6 +12,7 @@ import pytest | ||||||
| import requests_mock | import requests_mock | ||||||
| from django.contrib.auth.models import AnonymousUser | from django.contrib.auth.models import AnonymousUser | ||||||
| from django.core.cache import cache as django_cache | from django.core.cache import cache as django_cache | ||||||
|  | from django.core.files import uploadedfile | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from django.test import client | from django.test import client | ||||||
| from dynamic_preferences.registries import global_preferences_registry | from dynamic_preferences.registries import global_preferences_registry | ||||||
|  | @ -272,3 +275,38 @@ def avatar(): | ||||||
|     f.seek(0) |     f.seek(0) | ||||||
|     yield f |     yield f | ||||||
|     f.close() |     f.close() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture() | ||||||
|  | def audio_file(): | ||||||
|  |     data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "music") | ||||||
|  |     path = os.path.join(data_dir, "test.ogg") | ||||||
|  |     assert os.path.exists(path) | ||||||
|  |     with open(path, "rb") as f: | ||||||
|  |         yield f | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture() | ||||||
|  | def uploaded_audio_file(audio_file): | ||||||
|  |     yield uploadedfile.SimpleUploadedFile( | ||||||
|  |         name=audio_file.name, content=audio_file.read() | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.fixture() | ||||||
|  | def temp_signal(mocker): | ||||||
|  |     """ | ||||||
|  |     Connect a temporary handler to a given signal. This is helpful to validate | ||||||
|  |     a signal is dispatched properly, without mocking. | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|  |     @contextlib.contextmanager | ||||||
|  |     def connect(signal): | ||||||
|  |         stub = mocker.stub() | ||||||
|  |         signal.connect(stub) | ||||||
|  |         try: | ||||||
|  |             yield stub | ||||||
|  |         finally: | ||||||
|  |             signal.disconnect(stub) | ||||||
|  | 
 | ||||||
|  |     return connect | ||||||
|  |  | ||||||
|  | @ -35,6 +35,7 @@ def test_user_can_get_his_favorites(api_request, factories, logged_in_client, cl | ||||||
|             "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), |             "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), | ||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|  |     expected[0]["track"]["is_playable"] = False | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response.data["results"] == expected |     assert response.data["results"] == expected | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,32 +1,7 @@ | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import activity, serializers | import pytest | ||||||
| 
 | 
 | ||||||
| 
 | from funkwhale_api.federation import activity, serializers, tasks | ||||||
| def test_deliver(factories, r_mock, mocker, settings): |  | ||||||
|     settings.CELERY_TASK_ALWAYS_EAGER = True |  | ||||||
|     to = factories["federation.Actor"]() |  | ||||||
|     mocker.patch("funkwhale_api.federation.actors.get_actor", return_value=to) |  | ||||||
|     sender = 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" |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_accept_follow(mocker, factories): | def test_accept_follow(mocker, factories): | ||||||
|  | @ -35,5 +10,125 @@ def test_accept_follow(mocker, factories): | ||||||
|     expected_accept = serializers.AcceptFollowSerializer(follow).data |     expected_accept = serializers.AcceptFollowSerializer(follow).data | ||||||
|     activity.accept_follow(follow) |     activity.accept_follow(follow) | ||||||
|     deliver.assert_called_once_with( |     deliver.assert_called_once_with( | ||||||
|         expected_accept, to=[follow.actor.url], on_behalf_of=follow.target |         expected_accept, to=[follow.actor.fid], on_behalf_of=follow.target | ||||||
|     ) |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker): | ||||||
|  |     mocked_dispatch = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.tasks.dispatch_inbox.delay" | ||||||
|  |     ) | ||||||
|  |     local_actor = factories["users.User"]().create_actor() | ||||||
|  |     remote_actor = factories["federation.Actor"]() | ||||||
|  |     another_actor = factories["federation.Actor"]() | ||||||
|  |     a = { | ||||||
|  |         "@context": [], | ||||||
|  |         "actor": remote_actor.fid, | ||||||
|  |         "type": "Noop", | ||||||
|  |         "id": "https://test.activity", | ||||||
|  |         "to": [local_actor.fid], | ||||||
|  |         "cc": [another_actor.fid, activity.PUBLIC_ADDRESS], | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     copy = activity.receive(activity=a, on_behalf_of=remote_actor) | ||||||
|  | 
 | ||||||
|  |     assert copy.payload == a | ||||||
|  |     assert copy.creation_date >= now | ||||||
|  |     assert copy.actor == remote_actor | ||||||
|  |     assert copy.fid == a["id"] | ||||||
|  |     mocked_dispatch.assert_called_once_with(activity_id=copy.pk) | ||||||
|  | 
 | ||||||
|  |     inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid) | ||||||
|  |     assert inbox_item.is_delivered is False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_receive_invalid_data(factories): | ||||||
|  |     remote_actor = factories["federation.Actor"]() | ||||||
|  |     a = {"@context": [], "actor": remote_actor.fid, "id": "https://test.activity"} | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         activity.receive(activity=a, on_behalf_of=remote_actor) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_receive_actor_mismatch(factories): | ||||||
|  |     remote_actor = factories["federation.Actor"]() | ||||||
|  |     a = { | ||||||
|  |         "@context": [], | ||||||
|  |         "type": "Noop", | ||||||
|  |         "actor": "https://hello", | ||||||
|  |         "id": "https://test.activity", | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         activity.receive(activity=a, on_behalf_of=remote_actor) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_inbox_routing(mocker): | ||||||
|  |     router = activity.InboxRouter() | ||||||
|  | 
 | ||||||
|  |     handler = mocker.stub(name="handler") | ||||||
|  |     router.connect({"type": "Follow"}, handler) | ||||||
|  | 
 | ||||||
|  |     good_message = {"type": "Follow"} | ||||||
|  |     router.dispatch(good_message, context={}) | ||||||
|  | 
 | ||||||
|  |     handler.assert_called_once_with(good_message, context={}) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "route,payload,expected", | ||||||
|  |     [ | ||||||
|  |         ({"type": "Follow"}, {"type": "Follow"}, True), | ||||||
|  |         ({"type": "Follow"}, {"type": "Noop"}, False), | ||||||
|  |         ({"type": "Follow"}, {"type": "Follow", "id": "https://hello"}, True), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_route_matching(route, payload, expected): | ||||||
|  |     assert activity.match_route(route, payload) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_outbox_router_dispatch(mocker, factories, now): | ||||||
|  |     router = activity.OutboxRouter() | ||||||
|  |     recipient = factories["federation.Actor"]() | ||||||
|  |     actor = factories["federation.Actor"]() | ||||||
|  |     r1 = factories["federation.Actor"]() | ||||||
|  |     r2 = factories["federation.Actor"]() | ||||||
|  |     mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit") | ||||||
|  | 
 | ||||||
|  |     def handler(context): | ||||||
|  |         yield { | ||||||
|  |             "payload": { | ||||||
|  |                 "type": "Noop", | ||||||
|  |                 "actor": actor.fid, | ||||||
|  |                 "summary": context["summary"], | ||||||
|  |             }, | ||||||
|  |             "actor": actor, | ||||||
|  |             "to": [r1], | ||||||
|  |             "cc": [r2, activity.PUBLIC_ADDRESS], | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     router.connect({"type": "Noop"}, handler) | ||||||
|  |     activities = router.dispatch({"type": "Noop"}, {"summary": "hello"}) | ||||||
|  |     a = activities[0] | ||||||
|  | 
 | ||||||
|  |     mocked_dispatch.assert_called_once_with( | ||||||
|  |         tasks.dispatch_outbox.delay, activity_id=a.pk | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert a.payload == { | ||||||
|  |         "type": "Noop", | ||||||
|  |         "actor": actor.fid, | ||||||
|  |         "summary": "hello", | ||||||
|  |         "to": [r1.fid], | ||||||
|  |         "cc": [r2.fid, activity.PUBLIC_ADDRESS], | ||||||
|  |     } | ||||||
|  |     assert a.actor == actor | ||||||
|  |     assert a.creation_date >= now | ||||||
|  |     assert a.uuid is not None | ||||||
|  | 
 | ||||||
|  |     for recipient, type in [(r1, "to"), (r2, "cc")]: | ||||||
|  |         item = a.inbox_items.get(actor=recipient) | ||||||
|  |         assert item.is_delivered is False | ||||||
|  |         assert item.last_delivery_date is None | ||||||
|  |         assert item.delivery_attempts == 0 | ||||||
|  |         assert item.type == type | ||||||
|  |  | ||||||
|  | @ -1,12 +1,9 @@ | ||||||
| import pendulum |  | ||||||
| import pytest | import pytest | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| from rest_framework import exceptions | from rest_framework import exceptions | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import actors, models, serializers, utils | from funkwhale_api.federation import actors, models, serializers, utils | ||||||
| from funkwhale_api.music import models as music_models |  | ||||||
| from funkwhale_api.music import tasks as music_tasks |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_actor_fetching(r_mock): | def test_actor_fetching(r_mock): | ||||||
|  | @ -25,8 +22,8 @@ def test_actor_fetching(r_mock): | ||||||
| def test_get_actor(factories, r_mock): | def test_get_actor(factories, r_mock): | ||||||
|     actor = factories["federation.Actor"].build() |     actor = factories["federation.Actor"].build() | ||||||
|     payload = serializers.ActorSerializer(actor).data |     payload = serializers.ActorSerializer(actor).data | ||||||
|     r_mock.get(actor.url, json=payload) |     r_mock.get(actor.fid, json=payload) | ||||||
|     new_actor = actors.get_actor(actor.url) |     new_actor = actors.get_actor(actor.fid) | ||||||
| 
 | 
 | ||||||
|     assert new_actor.pk is not None |     assert new_actor.pk is not None | ||||||
|     assert serializers.ActorSerializer(new_actor).data == payload |     assert serializers.ActorSerializer(new_actor).data == payload | ||||||
|  | @ -36,7 +33,7 @@ def test_get_actor_use_existing(factories, preferences, mocker): | ||||||
|     preferences["federation__actor_fetch_delay"] = 60 |     preferences["federation__actor_fetch_delay"] = 60 | ||||||
|     actor = factories["federation.Actor"]() |     actor = factories["federation.Actor"]() | ||||||
|     get_data = mocker.patch("funkwhale_api.federation.actors.get_actor_data") |     get_data = mocker.patch("funkwhale_api.federation.actors.get_actor_data") | ||||||
|     new_actor = actors.get_actor(actor.url) |     new_actor = actors.get_actor(actor.fid) | ||||||
| 
 | 
 | ||||||
|     assert new_actor == actor |     assert new_actor == actor | ||||||
|     get_data.assert_not_called() |     get_data.assert_not_called() | ||||||
|  | @ -49,46 +46,13 @@ def test_get_actor_refresh(factories, preferences, mocker): | ||||||
|     # actor changed their username in the meantime |     # actor changed their username in the meantime | ||||||
|     payload["preferredUsername"] = "New me" |     payload["preferredUsername"] = "New me" | ||||||
|     mocker.patch("funkwhale_api.federation.actors.get_actor_data", return_value=payload) |     mocker.patch("funkwhale_api.federation.actors.get_actor_data", return_value=payload) | ||||||
|     new_actor = actors.get_actor(actor.url) |     new_actor = actors.get_actor(actor.fid) | ||||||
| 
 | 
 | ||||||
|     assert new_actor == actor |     assert new_actor == actor | ||||||
|     assert new_actor.last_fetch_date > actor.last_fetch_date |     assert new_actor.last_fetch_date > actor.last_fetch_date | ||||||
|     assert new_actor.preferred_username == "New me" |     assert new_actor.preferred_username == "New me" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_get_library(db, settings, mocker): |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.federation.keys.get_key_pair", |  | ||||||
|         return_value=(b"private", b"public"), |  | ||||||
|     ) |  | ||||||
|     expected = { |  | ||||||
|         "preferred_username": "library", |  | ||||||
|         "domain": settings.FEDERATION_HOSTNAME, |  | ||||||
|         "type": "Person", |  | ||||||
|         "name": "{}'s library".format(settings.FEDERATION_HOSTNAME), |  | ||||||
|         "manually_approves_followers": True, |  | ||||||
|         "public_key": "public", |  | ||||||
|         "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"}) |  | ||||||
|         ), |  | ||||||
|         "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(db, mocker, settings): | def test_get_test(db, mocker, settings): | ||||||
|     mocker.patch( |     mocker.patch( | ||||||
|         "funkwhale_api.federation.keys.get_key_pair", |         "funkwhale_api.federation.keys.get_key_pair", | ||||||
|  | @ -101,7 +65,7 @@ def test_get_test(db, mocker, settings): | ||||||
|         "name": "{}'s test account".format(settings.FEDERATION_HOSTNAME), |         "name": "{}'s test account".format(settings.FEDERATION_HOSTNAME), | ||||||
|         "manually_approves_followers": False, |         "manually_approves_followers": False, | ||||||
|         "public_key": "public", |         "public_key": "public", | ||||||
|         "url": utils.full_url( |         "fid": utils.full_url( | ||||||
|             reverse("federation:instance-actors-detail", kwargs={"actor": "test"}) |             reverse("federation:instance-actors-detail", kwargs={"actor": "test"}) | ||||||
|         ), |         ), | ||||||
|         "shared_inbox_url": utils.full_url( |         "shared_inbox_url": utils.full_url( | ||||||
|  | @ -162,7 +126,7 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories): | ||||||
|     now = timezone.now() |     now = timezone.now() | ||||||
|     mocker.patch("django.utils.timezone.now", return_value=now) |     mocker.patch("django.utils.timezone.now", return_value=now) | ||||||
|     data = { |     data = { | ||||||
|         "actor": actor.url, |         "actor": actor.fid, | ||||||
|         "type": "Create", |         "type": "Create", | ||||||
|         "id": "http://test.federation/activity", |         "id": "http://test.federation/activity", | ||||||
|         "object": { |         "object": { | ||||||
|  | @ -180,21 +144,21 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories): | ||||||
|         cc=[], |         cc=[], | ||||||
|         summary=None, |         summary=None, | ||||||
|         sensitive=False, |         sensitive=False, | ||||||
|         attributedTo=test_actor.url, |         attributedTo=test_actor.fid, | ||||||
|         attachment=[], |         attachment=[], | ||||||
|         to=[actor.url], |         to=[actor.fid], | ||||||
|         url="https://{}/activities/note/{}".format( |         url="https://{}/activities/note/{}".format( | ||||||
|             settings.FEDERATION_HOSTNAME, now.timestamp() |             settings.FEDERATION_HOSTNAME, now.timestamp() | ||||||
|         ), |         ), | ||||||
|         tag=[{"href": actor.url, "name": actor.mention_username, "type": "Mention"}], |         tag=[{"href": actor.fid, "name": actor.full_username, "type": "Mention"}], | ||||||
|     ) |     ) | ||||||
|     expected_activity = { |     expected_activity = { | ||||||
|         "@context": serializers.AP_CONTEXT, |         "@context": serializers.AP_CONTEXT, | ||||||
|         "actor": test_actor.url, |         "actor": test_actor.fid, | ||||||
|         "id": "https://{}/activities/note/{}/activity".format( |         "id": "https://{}/activities/note/{}/activity".format( | ||||||
|             settings.FEDERATION_HOSTNAME, now.timestamp() |             settings.FEDERATION_HOSTNAME, now.timestamp() | ||||||
|         ), |         ), | ||||||
|         "to": actor.url, |         "to": actor.fid, | ||||||
|         "type": "Create", |         "type": "Create", | ||||||
|         "published": now.isoformat(), |         "published": now.isoformat(), | ||||||
|         "object": expected_note, |         "object": expected_note, | ||||||
|  | @ -203,14 +167,14 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories): | ||||||
|     actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) |     actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) | ||||||
|     deliver.assert_called_once_with( |     deliver.assert_called_once_with( | ||||||
|         expected_activity, |         expected_activity, | ||||||
|         to=[actor.url], |         to=[actor.fid], | ||||||
|         on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(), |         on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(), | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_getting_actor_instance_persists_in_db(db): | def test_getting_actor_instance_persists_in_db(db): | ||||||
|     test = actors.SYSTEM_ACTORS["test"].get_actor_instance() |     test = actors.SYSTEM_ACTORS["test"].get_actor_instance() | ||||||
|     from_db = models.Actor.objects.get(url=test.url) |     from_db = models.Actor.objects.get(fid=test.fid) | ||||||
| 
 | 
 | ||||||
|     for f in test._meta.fields: |     for f in test._meta.fields: | ||||||
|         assert getattr(from_db, f.name) == getattr(test, f.name) |         assert getattr(from_db, f.name) == getattr(test, f.name) | ||||||
|  | @ -247,17 +211,11 @@ def test_actor_system_conf(username, domain, expected, nodb_factories, settings) | ||||||
|     assert actor.system_conf == expected |     assert actor.system_conf == expected | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize("value", [False, True]) | @pytest.mark.skip("Refactoring in progress") | ||||||
| def test_library_actor_manually_approves_based_on_preference(value, preferences): |  | ||||||
|     preferences["federation__music_needs_approval"] = value |  | ||||||
|     library_conf = actors.SYSTEM_ACTORS["library"] |  | ||||||
|     assert library_conf.manually_approves_followers is value |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_system_actor_handle(mocker, nodb_factories): | def test_system_actor_handle(mocker, nodb_factories): | ||||||
|     handler = mocker.patch("funkwhale_api.federation.actors.TestActor.handle_create") |     handler = mocker.patch("funkwhale_api.federation.actors.TestActor.handle_create") | ||||||
|     actor = nodb_factories["federation.Actor"]() |     actor = nodb_factories["federation.Actor"]() | ||||||
|     activity = nodb_factories["federation.Activity"](type="Create", actor=actor.url) |     activity = nodb_factories["federation.Activity"](type="Create", actor=actor) | ||||||
|     serializer = serializers.ActivitySerializer(data=activity) |     serializer = serializers.ActivitySerializer(data=activity) | ||||||
|     assert serializer.is_valid() |     assert serializer.is_valid() | ||||||
|     actors.SYSTEM_ACTORS["test"].handle(activity, actor) |     actors.SYSTEM_ACTORS["test"].handle(activity, actor) | ||||||
|  | @ -270,10 +228,10 @@ def test_test_actor_handles_follow(settings, mocker, factories): | ||||||
|     accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow") |     accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow") | ||||||
|     test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() |     test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() | ||||||
|     data = { |     data = { | ||||||
|         "actor": actor.url, |         "actor": actor.fid, | ||||||
|         "type": "Follow", |         "type": "Follow", | ||||||
|         "id": "http://test.federation/user#follows/267", |         "id": "http://test.federation/user#follows/267", | ||||||
|         "object": test_actor.url, |         "object": test_actor.fid, | ||||||
|     } |     } | ||||||
|     actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) |     actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) | ||||||
|     follow = models.Follow.objects.get(target=test_actor, approved=True) |     follow = models.Follow.objects.get(target=test_actor, approved=True) | ||||||
|  | @ -282,7 +240,7 @@ def test_test_actor_handles_follow(settings, mocker, factories): | ||||||
|     deliver.assert_called_once_with( |     deliver.assert_called_once_with( | ||||||
|         serializers.FollowSerializer(follow_back).data, |         serializers.FollowSerializer(follow_back).data, | ||||||
|         on_behalf_of=test_actor, |         on_behalf_of=test_actor, | ||||||
|         to=[actor.url], |         to=[actor.fid], | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -299,215 +257,20 @@ def test_test_actor_handles_undo_follow(settings, mocker, factories): | ||||||
|         "@context": serializers.AP_CONTEXT, |         "@context": serializers.AP_CONTEXT, | ||||||
|         "type": "Undo", |         "type": "Undo", | ||||||
|         "id": follow_serializer.data["id"] + "/undo", |         "id": follow_serializer.data["id"] + "/undo", | ||||||
|         "actor": follow.actor.url, |         "actor": follow.actor.fid, | ||||||
|         "object": follow_serializer.data, |         "object": follow_serializer.data, | ||||||
|     } |     } | ||||||
|     expected_undo = { |     expected_undo = { | ||||||
|         "@context": serializers.AP_CONTEXT, |         "@context": serializers.AP_CONTEXT, | ||||||
|         "type": "Undo", |         "type": "Undo", | ||||||
|         "id": reverse_follow_serializer.data["id"] + "/undo", |         "id": reverse_follow_serializer.data["id"] + "/undo", | ||||||
|         "actor": reverse_follow.actor.url, |         "actor": reverse_follow.actor.fid, | ||||||
|         "object": reverse_follow_serializer.data, |         "object": reverse_follow_serializer.data, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor) |     actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor) | ||||||
|     deliver.assert_called_once_with( |     deliver.assert_called_once_with( | ||||||
|         expected_undo, to=[follow.actor.url], on_behalf_of=test_actor |         expected_undo, to=[follow.actor.fid], on_behalf_of=test_actor | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     assert models.Follow.objects.count() == 0 |     assert models.Follow.objects.count() == 0 | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_handles_follow_manual_approval(preferences, mocker, factories): |  | ||||||
|     preferences["federation__music_needs_approval"] = True |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     now = timezone.now() |  | ||||||
|     mocker.patch("django.utils.timezone.now", return_value=now) |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     data = { |  | ||||||
|         "actor": actor.url, |  | ||||||
|         "type": "Follow", |  | ||||||
|         "id": "http://test.federation/user#follows/267", |  | ||||||
|         "object": library_actor.url, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     library_actor.system_conf.post_inbox(data, actor=actor) |  | ||||||
|     follow = library_actor.received_follows.first() |  | ||||||
| 
 |  | ||||||
|     assert follow.actor == actor |  | ||||||
|     assert follow.approved is None |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_handles_follow_auto_approval(preferences, mocker, factories): |  | ||||||
|     preferences["federation__music_needs_approval"] = False |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     mocker.patch("funkwhale_api.federation.activity.accept_follow") |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     data = { |  | ||||||
|         "actor": actor.url, |  | ||||||
|         "type": "Follow", |  | ||||||
|         "id": "http://test.federation/user#follows/267", |  | ||||||
|         "object": library_actor.url, |  | ||||||
|     } |  | ||||||
|     library_actor.system_conf.post_inbox(data, actor=actor) |  | ||||||
| 
 |  | ||||||
|     follow = library_actor.received_follows.first() |  | ||||||
| 
 |  | ||||||
|     assert follow.actor == actor |  | ||||||
|     assert follow.approved is True |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_handles_accept(mocker, factories): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     pending_follow = factories["federation.Follow"]( |  | ||||||
|         actor=library_actor, target=actor, approved=None |  | ||||||
|     ) |  | ||||||
|     serializer = serializers.AcceptFollowSerializer(pending_follow) |  | ||||||
|     library_actor.system_conf.post_inbox(serializer.data, actor=actor) |  | ||||||
| 
 |  | ||||||
|     pending_follow.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     assert pending_follow.approved is True |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_handle_create_audio_no_library(mocker, factories): |  | ||||||
|     # when we receive inbox create audio, we should not do anything |  | ||||||
|     # if we don't have a configured library matching the sender |  | ||||||
|     mocked_create = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.serializers.AudioSerializer.create" |  | ||||||
|     ) |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     data = { |  | ||||||
|         "actor": actor.url, |  | ||||||
|         "type": "Create", |  | ||||||
|         "id": "http://test.federation/audio/create", |  | ||||||
|         "object": { |  | ||||||
|             "id": "https://batch.import", |  | ||||||
|             "type": "Collection", |  | ||||||
|             "totalItems": 2, |  | ||||||
|             "items": factories["federation.Audio"].create_batch(size=2), |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
|     library_actor.system_conf.post_inbox(data, actor=actor) |  | ||||||
| 
 |  | ||||||
|     mocked_create.assert_not_called() |  | ||||||
|     models.LibraryTrack.objects.count() == 0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_handle_create_audio_no_library_enabled(mocker, factories): |  | ||||||
|     # when we receive inbox create audio, we should not do anything |  | ||||||
|     # if we don't have an enabled library |  | ||||||
|     mocked_create = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.serializers.AudioSerializer.create" |  | ||||||
|     ) |  | ||||||
|     disabled_library = factories["federation.Library"](federation_enabled=False) |  | ||||||
|     actor = disabled_library.actor |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     data = { |  | ||||||
|         "actor": actor.url, |  | ||||||
|         "type": "Create", |  | ||||||
|         "id": "http://test.federation/audio/create", |  | ||||||
|         "object": { |  | ||||||
|             "id": "https://batch.import", |  | ||||||
|             "type": "Collection", |  | ||||||
|             "totalItems": 2, |  | ||||||
|             "items": factories["federation.Audio"].create_batch(size=2), |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
|     library_actor.system_conf.post_inbox(data, actor=actor) |  | ||||||
| 
 |  | ||||||
|     mocked_create.assert_not_called() |  | ||||||
|     models.LibraryTrack.objects.count() == 0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_handle_create_audio(mocker, factories): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     remote_library = factories["federation.Library"](federation_enabled=True) |  | ||||||
| 
 |  | ||||||
|     data = { |  | ||||||
|         "actor": remote_library.actor.url, |  | ||||||
|         "type": "Create", |  | ||||||
|         "id": "http://test.federation/audio/create", |  | ||||||
|         "object": { |  | ||||||
|             "id": "https://batch.import", |  | ||||||
|             "type": "Collection", |  | ||||||
|             "totalItems": 2, |  | ||||||
|             "items": factories["federation.Audio"].create_batch(size=2), |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     library_actor.system_conf.post_inbox(data, actor=remote_library.actor) |  | ||||||
| 
 |  | ||||||
|     lts = list(remote_library.tracks.order_by("id")) |  | ||||||
| 
 |  | ||||||
|     assert len(lts) == 2 |  | ||||||
| 
 |  | ||||||
|     for i, a in enumerate(data["object"]["items"]): |  | ||||||
|         lt = lts[i] |  | ||||||
|         assert lt.pk is not None |  | ||||||
|         assert lt.url == a["id"] |  | ||||||
|         assert lt.library == remote_library |  | ||||||
|         assert lt.audio_url == a["url"]["href"] |  | ||||||
|         assert lt.audio_mimetype == a["url"]["mediaType"] |  | ||||||
|         assert lt.metadata == a["metadata"] |  | ||||||
|         assert lt.title == a["metadata"]["recording"]["title"] |  | ||||||
|         assert lt.artist_name == a["metadata"]["artist"]["name"] |  | ||||||
|         assert lt.album_title == a["metadata"]["release"]["title"] |  | ||||||
|         assert lt.published_date == pendulum.parse(a["published"]) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_handle_create_audio_autoimport(mocker, factories): |  | ||||||
|     mocked_import = mocker.patch("funkwhale_api.common.utils.on_commit") |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     remote_library = factories["federation.Library"]( |  | ||||||
|         federation_enabled=True, autoimport=True |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     data = { |  | ||||||
|         "actor": remote_library.actor.url, |  | ||||||
|         "type": "Create", |  | ||||||
|         "id": "http://test.federation/audio/create", |  | ||||||
|         "object": { |  | ||||||
|             "id": "https://batch.import", |  | ||||||
|             "type": "Collection", |  | ||||||
|             "totalItems": 2, |  | ||||||
|             "items": factories["federation.Audio"].create_batch(size=2), |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     library_actor.system_conf.post_inbox(data, actor=remote_library.actor) |  | ||||||
| 
 |  | ||||||
|     lts = list(remote_library.tracks.order_by("id")) |  | ||||||
| 
 |  | ||||||
|     assert len(lts) == 2 |  | ||||||
| 
 |  | ||||||
|     for i, a in enumerate(data["object"]["items"]): |  | ||||||
|         lt = lts[i] |  | ||||||
|         assert lt.pk is not None |  | ||||||
|         assert lt.url == a["id"] |  | ||||||
|         assert lt.library == remote_library |  | ||||||
|         assert lt.audio_url == a["url"]["href"] |  | ||||||
|         assert lt.audio_mimetype == a["url"]["mediaType"] |  | ||||||
|         assert lt.metadata == a["metadata"] |  | ||||||
|         assert lt.title == a["metadata"]["recording"]["title"] |  | ||||||
|         assert lt.artist_name == a["metadata"]["artist"]["name"] |  | ||||||
|         assert lt.album_title == a["metadata"]["release"]["title"] |  | ||||||
|         assert lt.published_date == pendulum.parse(a["published"]) |  | ||||||
| 
 |  | ||||||
|     batch = music_models.ImportBatch.objects.latest("id") |  | ||||||
| 
 |  | ||||||
|     assert batch.jobs.count() == len(lts) |  | ||||||
|     assert batch.source == "federation" |  | ||||||
|     assert batch.submitted_by is None |  | ||||||
| 
 |  | ||||||
|     for i, job in enumerate(batch.jobs.order_by("id")): |  | ||||||
|         lt = lts[i] |  | ||||||
|         assert job.library_track == lt |  | ||||||
|         assert job.mbid == lt.mbid |  | ||||||
|         assert job.source == lt.url |  | ||||||
| 
 |  | ||||||
|         mocked_import.assert_any_call( |  | ||||||
|             music_tasks.import_job_run.delay, import_job_id=job.pk, use_acoustid=False |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  | @ -0,0 +1,53 @@ | ||||||
|  | from funkwhale_api.federation import api_serializers | ||||||
|  | from funkwhale_api.federation import serializers | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_library_serializer(factories): | ||||||
|  |     library = factories["music.Library"](files_count=5678) | ||||||
|  |     expected = { | ||||||
|  |         "fid": library.fid, | ||||||
|  |         "uuid": str(library.uuid), | ||||||
|  |         "actor": serializers.APIActorSerializer(library.actor).data, | ||||||
|  |         "name": library.name, | ||||||
|  |         "description": library.description, | ||||||
|  |         "creation_date": library.creation_date.isoformat().split("+")[0] + "Z", | ||||||
|  |         "files_count": library.files_count, | ||||||
|  |         "privacy_level": library.privacy_level, | ||||||
|  |         "follow": None, | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     serializer = api_serializers.LibrarySerializer(library) | ||||||
|  | 
 | ||||||
|  |     assert serializer.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_library_serializer_with_follow(factories): | ||||||
|  |     library = factories["music.Library"](files_count=5678) | ||||||
|  |     follow = factories["federation.LibraryFollow"](target=library) | ||||||
|  | 
 | ||||||
|  |     setattr(library, "_follows", [follow]) | ||||||
|  |     expected = { | ||||||
|  |         "fid": library.fid, | ||||||
|  |         "uuid": str(library.uuid), | ||||||
|  |         "actor": serializers.APIActorSerializer(library.actor).data, | ||||||
|  |         "name": library.name, | ||||||
|  |         "description": library.description, | ||||||
|  |         "creation_date": library.creation_date.isoformat().split("+")[0] + "Z", | ||||||
|  |         "files_count": library.files_count, | ||||||
|  |         "privacy_level": library.privacy_level, | ||||||
|  |         "follow": api_serializers.NestedLibraryFollowSerializer(follow).data, | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     serializer = api_serializers.LibrarySerializer(library) | ||||||
|  | 
 | ||||||
|  |     assert serializer.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_library_serializer_validates_existing_follow(factories): | ||||||
|  |     follow = factories["federation.LibraryFollow"]() | ||||||
|  |     serializer = api_serializers.LibraryFollowSerializer( | ||||||
|  |         data={"target": follow.target.uuid}, context={"actor": follow.actor} | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert serializer.is_valid() is False | ||||||
|  |     assert "target" in serializer.errors | ||||||
|  | @ -0,0 +1,51 @@ | ||||||
|  | from django.urls import reverse | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.federation import api_serializers | ||||||
|  | from funkwhale_api.federation import serializers | ||||||
|  | from funkwhale_api.federation import views | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_can_list_their_library_follows(factories, logged_in_api_client): | ||||||
|  |     # followed by someont else | ||||||
|  |     factories["federation.LibraryFollow"]() | ||||||
|  |     follow = factories["federation.LibraryFollow"]( | ||||||
|  |         actor__user=logged_in_api_client.user | ||||||
|  |     ) | ||||||
|  |     url = reverse("api:v1:federation:library-follows-list") | ||||||
|  |     response = logged_in_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.data["count"] == 1 | ||||||
|  |     assert response.data["results"][0]["uuid"] == str(follow.uuid) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_can_scan_library_using_url(mocker, factories, logged_in_api_client): | ||||||
|  |     library = factories["music.Library"]() | ||||||
|  |     mocked_retrieve = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.utils.retrieve", return_value=library | ||||||
|  |     ) | ||||||
|  |     url = reverse("api:v1:federation:libraries-scan") | ||||||
|  |     response = logged_in_api_client.post(url, {"fid": library.fid}) | ||||||
|  |     assert mocked_retrieve.call_count == 1 | ||||||
|  |     args = mocked_retrieve.call_args | ||||||
|  |     assert args[0] == (library.fid,) | ||||||
|  |     assert args[1]["queryset"].model == views.MusicLibraryViewSet.queryset.model | ||||||
|  |     assert args[1]["serializer_class"] == serializers.LibrarySerializer | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     assert response.data["results"] == [api_serializers.LibrarySerializer(library).data] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_can_follow_library(factories, logged_in_api_client, mocker): | ||||||
|  |     dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") | ||||||
|  |     actor = logged_in_api_client.user.create_actor() | ||||||
|  |     library = factories["music.Library"]() | ||||||
|  |     url = reverse("api:v1:federation:library-follows-list") | ||||||
|  |     response = logged_in_api_client.post(url, {"target": library.uuid}) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 201 | ||||||
|  | 
 | ||||||
|  |     follow = library.received_follows.latest("id") | ||||||
|  | 
 | ||||||
|  |     assert follow.approved is None | ||||||
|  |     assert follow.actor == actor | ||||||
|  | 
 | ||||||
|  |     dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow}) | ||||||
|  | @ -36,4 +36,4 @@ def test_authenticate(factories, mocker, api_request): | ||||||
| 
 | 
 | ||||||
|     assert user.is_anonymous is True |     assert user.is_anonymous is True | ||||||
|     assert actor.public_key == public.decode("utf-8") |     assert actor.public_key == public.decode("utf-8") | ||||||
|     assert actor.url == actor_url |     assert actor.fid == actor_url | ||||||
|  |  | ||||||
|  | @ -1,64 +0,0 @@ | ||||||
| from funkwhale_api.federation import library, serializers |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_scan_from_account_name(mocker, factories): |  | ||||||
|     actor = factories["federation.Actor"]( |  | ||||||
|         preferred_username="library", domain="test.library" |  | ||||||
|     ) |  | ||||||
|     get_resource_result = {"actor_url": actor.url} |  | ||||||
|     get_resource = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.webfinger.get_resource", |  | ||||||
|         return_value=get_resource_result, |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     actor_data = serializers.ActorSerializer(actor).data |  | ||||||
|     actor_data["manuallyApprovesFollowers"] = False |  | ||||||
|     actor_data["url"] = [ |  | ||||||
|         { |  | ||||||
|             "type": "Link", |  | ||||||
|             "name": "library", |  | ||||||
|             "mediaType": "application/activity+json", |  | ||||||
|             "href": "https://test.library", |  | ||||||
|         } |  | ||||||
|     ] |  | ||||||
|     get_actor_data = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.actors.get_actor_data", return_value=actor_data |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     get_library_data_result = {"test": "test"} |  | ||||||
|     get_library_data = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.library.get_library_data", |  | ||||||
|         return_value=get_library_data_result, |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     result = library.scan_from_account_name("library@test.actor") |  | ||||||
| 
 |  | ||||||
|     get_resource.assert_called_once_with("acct:library@test.actor") |  | ||||||
|     get_actor_data.assert_called_once_with(actor.url) |  | ||||||
|     get_library_data.assert_called_once_with(actor_data["url"][0]["href"]) |  | ||||||
| 
 |  | ||||||
|     assert result == { |  | ||||||
|         "webfinger": get_resource_result, |  | ||||||
|         "actor": actor_data, |  | ||||||
|         "library": get_library_data_result, |  | ||||||
|         "local": {"following": False, "awaiting_approval": False}, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_get_library_data(r_mock, factories): |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     url = "https://test.library" |  | ||||||
|     conf = {"id": url, "items": [], "actor": actor, "page_size": 5} |  | ||||||
|     data = serializers.PaginatedCollectionSerializer(conf).data |  | ||||||
|     r_mock.get(url, json=data) |  | ||||||
| 
 |  | ||||||
|     result = library.get_library_data(url) |  | ||||||
|     for f in ["totalItems", "actor", "id", "type"]: |  | ||||||
|         assert result[f] == data[f] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_get_library_data_requires_authentication(r_mock, factories): |  | ||||||
|     url = "https://test.library" |  | ||||||
|     r_mock.get(url, status_code=403) |  | ||||||
|     result = library.get_library_data(url) |  | ||||||
|     assert result["errors"] == ["Permission denied while scanning library"] |  | ||||||
|  | @ -20,12 +20,37 @@ def test_cannot_duplicate_follow(factories): | ||||||
| 
 | 
 | ||||||
| def test_follow_federation_url(factories): | def test_follow_federation_url(factories): | ||||||
|     follow = factories["federation.Follow"](local=True) |     follow = factories["federation.Follow"](local=True) | ||||||
|     expected = "{}#follows/{}".format(follow.actor.url, follow.uuid) |     expected = "{}#follows/{}".format(follow.actor.fid, follow.uuid) | ||||||
| 
 | 
 | ||||||
|     assert follow.get_federation_url() == expected |     assert follow.get_federation_id() == expected | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_library_model_unique_per_actor(factories): | def test_actor_get_quota(factories): | ||||||
|     library = factories["federation.Library"]() |     library = factories["music.Library"]() | ||||||
|     with pytest.raises(db.IntegrityError): |     factories["music.TrackFile"]( | ||||||
|         factories["federation.Library"](actor=library.actor) |         library=library, | ||||||
|  |         import_status="pending", | ||||||
|  |         audio_file__from_path=None, | ||||||
|  |         audio_file__data=b"a", | ||||||
|  |     ) | ||||||
|  |     factories["music.TrackFile"]( | ||||||
|  |         library=library, | ||||||
|  |         import_status="skipped", | ||||||
|  |         audio_file__from_path=None, | ||||||
|  |         audio_file__data=b"aa", | ||||||
|  |     ) | ||||||
|  |     factories["music.TrackFile"]( | ||||||
|  |         library=library, | ||||||
|  |         import_status="errored", | ||||||
|  |         audio_file__from_path=None, | ||||||
|  |         audio_file__data=b"aaa", | ||||||
|  |     ) | ||||||
|  |     factories["music.TrackFile"]( | ||||||
|  |         library=library, | ||||||
|  |         import_status="finished", | ||||||
|  |         audio_file__from_path=None, | ||||||
|  |         audio_file__data=b"aaaa", | ||||||
|  |     ) | ||||||
|  |     expected = {"total": 10, "pending": 1, "skipped": 2, "errored": 3, "finished": 4} | ||||||
|  | 
 | ||||||
|  |     assert library.actor.get_current_usage() == expected | ||||||
|  |  | ||||||
|  | @ -1,61 +0,0 @@ | ||||||
| from rest_framework.views import APIView |  | ||||||
| 
 |  | ||||||
| from funkwhale_api.federation import actors, permissions |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_follower(factories, api_request, anonymous_user, preferences): |  | ||||||
|     preferences["federation__music_needs_approval"] = True |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.LibraryFollower() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "user", anonymous_user) |  | ||||||
|     check = permission.has_permission(request, view) |  | ||||||
| 
 |  | ||||||
|     assert check is False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_follower_actor_non_follower( |  | ||||||
|     factories, api_request, anonymous_user, preferences |  | ||||||
| ): |  | ||||||
|     preferences["federation__music_needs_approval"] = True |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.LibraryFollower() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "user", anonymous_user) |  | ||||||
|     setattr(request, "actor", actor) |  | ||||||
|     check = permission.has_permission(request, view) |  | ||||||
| 
 |  | ||||||
|     assert check is False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_follower_actor_follower_not_approved( |  | ||||||
|     factories, api_request, anonymous_user, preferences |  | ||||||
| ): |  | ||||||
|     preferences["federation__music_needs_approval"] = True |  | ||||||
|     library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     follow = factories["federation.Follow"](target=library, approved=False) |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.LibraryFollower() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "user", anonymous_user) |  | ||||||
|     setattr(request, "actor", follow.actor) |  | ||||||
|     check = permission.has_permission(request, view) |  | ||||||
| 
 |  | ||||||
|     assert check is False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_follower_actor_follower( |  | ||||||
|     factories, api_request, anonymous_user, preferences |  | ||||||
| ): |  | ||||||
|     preferences["federation__music_needs_approval"] = True |  | ||||||
|     library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     follow = factories["federation.Follow"](target=library, approved=True) |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.LibraryFollower() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "user", anonymous_user) |  | ||||||
|     setattr(request, "actor", follow.actor) |  | ||||||
|     check = permission.has_permission(request, view) |  | ||||||
| 
 |  | ||||||
|     assert check is True |  | ||||||
|  | @ -0,0 +1,147 @@ | ||||||
|  | import pytest | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.federation import routes, serializers | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "route,handler", | ||||||
|  |     [ | ||||||
|  |         ({"type": "Follow"}, routes.inbox_follow), | ||||||
|  |         ({"type": "Accept"}, routes.inbox_accept), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_inbox_routes(route, handler): | ||||||
|  |     for r, h in routes.inbox.routes: | ||||||
|  |         if r == route: | ||||||
|  |             assert h == handler | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |     assert False, "Inbox route {} not found".format(route) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "route,handler", | ||||||
|  |     [ | ||||||
|  |         ({"type": "Accept"}, routes.outbox_accept), | ||||||
|  |         ({"type": "Follow"}, routes.outbox_follow), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_outbox_routes(route, handler): | ||||||
|  |     for r, h in routes.outbox.routes: | ||||||
|  |         if r == route: | ||||||
|  |             assert h == handler | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |     assert False, "Outbox route {} not found".format(route) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_inbox_follow_library_autoapprove(factories, mocker): | ||||||
|  |     mocked_accept_follow = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.activity.accept_follow" | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     local_actor = factories["users.User"]().create_actor() | ||||||
|  |     remote_actor = factories["federation.Actor"]() | ||||||
|  |     library = factories["music.Library"](actor=local_actor, privacy_level="everyone") | ||||||
|  |     ii = factories["federation.InboxItem"](actor=local_actor) | ||||||
|  | 
 | ||||||
|  |     payload = { | ||||||
|  |         "type": "Follow", | ||||||
|  |         "id": "https://test.follow", | ||||||
|  |         "actor": remote_actor.fid, | ||||||
|  |         "object": library.fid, | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     routes.inbox_follow( | ||||||
|  |         payload, | ||||||
|  |         context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     follow = library.received_follows.latest("id") | ||||||
|  | 
 | ||||||
|  |     assert follow.fid == payload["id"] | ||||||
|  |     assert follow.actor == remote_actor | ||||||
|  |     assert follow.approved is True | ||||||
|  | 
 | ||||||
|  |     mocked_accept_follow.assert_called_once_with(follow) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_inbox_follow_library_manual_approve(factories, mocker): | ||||||
|  |     mocked_accept_follow = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.activity.accept_follow" | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     local_actor = factories["users.User"]().create_actor() | ||||||
|  |     remote_actor = factories["federation.Actor"]() | ||||||
|  |     library = factories["music.Library"](actor=local_actor, privacy_level="me") | ||||||
|  |     ii = factories["federation.InboxItem"](actor=local_actor) | ||||||
|  | 
 | ||||||
|  |     payload = { | ||||||
|  |         "type": "Follow", | ||||||
|  |         "id": "https://test.follow", | ||||||
|  |         "actor": remote_actor.fid, | ||||||
|  |         "object": library.fid, | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     routes.inbox_follow( | ||||||
|  |         payload, | ||||||
|  |         context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     follow = library.received_follows.latest("id") | ||||||
|  | 
 | ||||||
|  |     assert follow.fid == payload["id"] | ||||||
|  |     assert follow.actor == remote_actor | ||||||
|  |     assert follow.approved is False | ||||||
|  | 
 | ||||||
|  |     mocked_accept_follow.assert_not_called() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_outbox_accept(factories, mocker): | ||||||
|  |     remote_actor = factories["federation.Actor"]() | ||||||
|  |     follow = factories["federation.LibraryFollow"](actor=remote_actor) | ||||||
|  | 
 | ||||||
|  |     activity = list(routes.outbox_accept({"follow": follow}))[0] | ||||||
|  | 
 | ||||||
|  |     serializer = serializers.AcceptFollowSerializer( | ||||||
|  |         follow, context={"actor": follow.target.actor} | ||||||
|  |     ) | ||||||
|  |     expected = serializer.data | ||||||
|  |     expected["to"] = [follow.actor] | ||||||
|  | 
 | ||||||
|  |     assert activity["payload"] == expected | ||||||
|  |     assert activity["actor"] == follow.target.actor | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_inbox_accept(factories, mocker): | ||||||
|  |     mocked_scan = mocker.patch("funkwhale_api.music.models.Library.schedule_scan") | ||||||
|  |     local_actor = factories["users.User"]().create_actor() | ||||||
|  |     remote_actor = factories["federation.Actor"]() | ||||||
|  |     follow = factories["federation.LibraryFollow"]( | ||||||
|  |         actor=local_actor, target__actor=remote_actor | ||||||
|  |     ) | ||||||
|  |     assert follow.approved is None | ||||||
|  |     serializer = serializers.AcceptFollowSerializer( | ||||||
|  |         follow, context={"actor": remote_actor} | ||||||
|  |     ) | ||||||
|  |     ii = factories["federation.InboxItem"](actor=local_actor) | ||||||
|  |     routes.inbox_accept( | ||||||
|  |         serializer.data, | ||||||
|  |         context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     follow.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert follow.approved is True | ||||||
|  |     mocked_scan.assert_called_once_with() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_outbox_follow_library(factories, mocker): | ||||||
|  |     follow = factories["federation.LibraryFollow"]() | ||||||
|  |     activity = list(routes.outbox_follow({"follow": follow}))[0] | ||||||
|  |     serializer = serializers.FollowSerializer(follow, context={"actor": follow.actor}) | ||||||
|  |     expected = serializer.data | ||||||
|  |     expected["to"] = [follow.target.actor] | ||||||
|  | 
 | ||||||
|  |     assert activity["payload"] == expected | ||||||
|  |     assert activity["actor"] == follow.actor | ||||||
|  | @ -1,8 +1,7 @@ | ||||||
| import pendulum |  | ||||||
| import pytest | import pytest | ||||||
| from django.core.paginator import Paginator | from django.core.paginator import Paginator | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import actors, models, serializers, utils | from funkwhale_api.federation import activity, models, serializers, utils | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_actor_serializer_from_ap(db): | def test_actor_serializer_from_ap(db): | ||||||
|  | @ -31,7 +30,7 @@ def test_actor_serializer_from_ap(db): | ||||||
| 
 | 
 | ||||||
|     actor = serializer.build() |     actor = serializer.build() | ||||||
| 
 | 
 | ||||||
|     assert actor.url == payload["id"] |     assert actor.fid == payload["id"] | ||||||
|     assert actor.inbox_url == payload["inbox"] |     assert actor.inbox_url == payload["inbox"] | ||||||
|     assert actor.outbox_url == payload["outbox"] |     assert actor.outbox_url == payload["outbox"] | ||||||
|     assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"] |     assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"] | ||||||
|  | @ -62,7 +61,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db): | ||||||
| 
 | 
 | ||||||
|     actor = serializer.build() |     actor = serializer.build() | ||||||
| 
 | 
 | ||||||
|     assert actor.url == payload["id"] |     assert actor.fid == payload["id"] | ||||||
|     assert actor.inbox_url == payload["inbox"] |     assert actor.inbox_url == payload["inbox"] | ||||||
|     assert actor.outbox_url == payload["outbox"] |     assert actor.outbox_url == payload["outbox"] | ||||||
|     assert actor.followers_url == payload["followers"] |     assert actor.followers_url == payload["followers"] | ||||||
|  | @ -98,7 +97,7 @@ def test_actor_serializer_to_ap(): | ||||||
|         "endpoints": {"sharedInbox": "https://test.federation/inbox"}, |         "endpoints": {"sharedInbox": "https://test.federation/inbox"}, | ||||||
|     } |     } | ||||||
|     ac = models.Actor( |     ac = models.Actor( | ||||||
|         url=expected["id"], |         fid=expected["id"], | ||||||
|         inbox_url=expected["inbox"], |         inbox_url=expected["inbox"], | ||||||
|         outbox_url=expected["outbox"], |         outbox_url=expected["outbox"], | ||||||
|         shared_inbox_url=expected["endpoints"]["sharedInbox"], |         shared_inbox_url=expected["endpoints"]["sharedInbox"], | ||||||
|  | @ -130,7 +129,7 @@ def test_webfinger_serializer(): | ||||||
|         "aliases": ["https://test.federation/federation/instance/actor"], |         "aliases": ["https://test.federation/federation/instance/actor"], | ||||||
|     } |     } | ||||||
|     actor = models.Actor( |     actor = models.Actor( | ||||||
|         url=expected["links"][0]["href"], |         fid=expected["links"][0]["href"], | ||||||
|         preferred_username="service", |         preferred_username="service", | ||||||
|         domain="test.federation", |         domain="test.federation", | ||||||
|     ) |     ) | ||||||
|  | @ -149,10 +148,10 @@ def test_follow_serializer_to_ap(factories): | ||||||
|             "https://w3id.org/security/v1", |             "https://w3id.org/security/v1", | ||||||
|             {}, |             {}, | ||||||
|         ], |         ], | ||||||
|         "id": follow.get_federation_url(), |         "id": follow.get_federation_id(), | ||||||
|         "type": "Follow", |         "type": "Follow", | ||||||
|         "actor": follow.actor.url, |         "actor": follow.actor.fid, | ||||||
|         "object": follow.target.url, |         "object": follow.target.fid, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
|  | @ -165,8 +164,8 @@ def test_follow_serializer_save(factories): | ||||||
|     data = { |     data = { | ||||||
|         "id": "https://test.follow", |         "id": "https://test.follow", | ||||||
|         "type": "Follow", |         "type": "Follow", | ||||||
|         "actor": actor.url, |         "actor": actor.fid, | ||||||
|         "object": target.url, |         "object": target.fid, | ||||||
|     } |     } | ||||||
|     serializer = serializers.FollowSerializer(data=data) |     serializer = serializers.FollowSerializer(data=data) | ||||||
| 
 | 
 | ||||||
|  | @ -188,8 +187,8 @@ def test_follow_serializer_save_validates_on_context(factories): | ||||||
|     data = { |     data = { | ||||||
|         "id": "https://test.follow", |         "id": "https://test.follow", | ||||||
|         "type": "Follow", |         "type": "Follow", | ||||||
|         "actor": actor.url, |         "actor": actor.fid, | ||||||
|         "object": target.url, |         "object": target.fid, | ||||||
|     } |     } | ||||||
|     serializer = serializers.FollowSerializer( |     serializer = serializers.FollowSerializer( | ||||||
|         data=data, context={"follow_actor": impostor, "follow_target": impostor} |         data=data, context={"follow_actor": impostor, "follow_target": impostor} | ||||||
|  | @ -210,9 +209,9 @@ def test_accept_follow_serializer_representation(factories): | ||||||
|             "https://w3id.org/security/v1", |             "https://w3id.org/security/v1", | ||||||
|             {}, |             {}, | ||||||
|         ], |         ], | ||||||
|         "id": follow.get_federation_url() + "/accept", |         "id": follow.get_federation_id() + "/accept", | ||||||
|         "type": "Accept", |         "type": "Accept", | ||||||
|         "actor": follow.target.url, |         "actor": follow.target.fid, | ||||||
|         "object": serializers.FollowSerializer(follow).data, |         "object": serializers.FollowSerializer(follow).data, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -230,9 +229,9 @@ def test_accept_follow_serializer_save(factories): | ||||||
|             "https://w3id.org/security/v1", |             "https://w3id.org/security/v1", | ||||||
|             {}, |             {}, | ||||||
|         ], |         ], | ||||||
|         "id": follow.get_federation_url() + "/accept", |         "id": follow.get_federation_id() + "/accept", | ||||||
|         "type": "Accept", |         "type": "Accept", | ||||||
|         "actor": follow.target.url, |         "actor": follow.target.fid, | ||||||
|         "object": serializers.FollowSerializer(follow).data, |         "object": serializers.FollowSerializer(follow).data, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -254,7 +253,7 @@ def test_accept_follow_serializer_validates_on_context(factories): | ||||||
|             "https://w3id.org/security/v1", |             "https://w3id.org/security/v1", | ||||||
|             {}, |             {}, | ||||||
|         ], |         ], | ||||||
|         "id": follow.get_federation_url() + "/accept", |         "id": follow.get_federation_id() + "/accept", | ||||||
|         "type": "Accept", |         "type": "Accept", | ||||||
|         "actor": impostor.url, |         "actor": impostor.url, | ||||||
|         "object": serializers.FollowSerializer(follow).data, |         "object": serializers.FollowSerializer(follow).data, | ||||||
|  | @ -278,9 +277,9 @@ def test_undo_follow_serializer_representation(factories): | ||||||
|             "https://w3id.org/security/v1", |             "https://w3id.org/security/v1", | ||||||
|             {}, |             {}, | ||||||
|         ], |         ], | ||||||
|         "id": follow.get_federation_url() + "/undo", |         "id": follow.get_federation_id() + "/undo", | ||||||
|         "type": "Undo", |         "type": "Undo", | ||||||
|         "actor": follow.actor.url, |         "actor": follow.actor.fid, | ||||||
|         "object": serializers.FollowSerializer(follow).data, |         "object": serializers.FollowSerializer(follow).data, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -298,9 +297,9 @@ def test_undo_follow_serializer_save(factories): | ||||||
|             "https://w3id.org/security/v1", |             "https://w3id.org/security/v1", | ||||||
|             {}, |             {}, | ||||||
|         ], |         ], | ||||||
|         "id": follow.get_federation_url() + "/undo", |         "id": follow.get_federation_id() + "/undo", | ||||||
|         "type": "Undo", |         "type": "Undo", | ||||||
|         "actor": follow.actor.url, |         "actor": follow.actor.fid, | ||||||
|         "object": serializers.FollowSerializer(follow).data, |         "object": serializers.FollowSerializer(follow).data, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -321,7 +320,7 @@ def test_undo_follow_serializer_validates_on_context(factories): | ||||||
|             "https://w3id.org/security/v1", |             "https://w3id.org/security/v1", | ||||||
|             {}, |             {}, | ||||||
|         ], |         ], | ||||||
|         "id": follow.get_federation_url() + "/undo", |         "id": follow.get_federation_id() + "/undo", | ||||||
|         "type": "Undo", |         "type": "Undo", | ||||||
|         "actor": impostor.url, |         "actor": impostor.url, | ||||||
|         "object": serializers.FollowSerializer(follow).data, |         "object": serializers.FollowSerializer(follow).data, | ||||||
|  | @ -355,7 +354,7 @@ def test_paginated_collection_serializer(factories): | ||||||
|         ], |         ], | ||||||
|         "type": "Collection", |         "type": "Collection", | ||||||
|         "id": conf["id"], |         "id": conf["id"], | ||||||
|         "actor": actor.url, |         "actor": actor.fid, | ||||||
|         "totalItems": len(tfs), |         "totalItems": len(tfs), | ||||||
|         "current": conf["id"] + "?page=1", |         "current": conf["id"] + "?page=1", | ||||||
|         "last": conf["id"] + "?page=3", |         "last": conf["id"] + "?page=3", | ||||||
|  | @ -452,7 +451,7 @@ def test_collection_page_serializer(factories): | ||||||
|         ], |         ], | ||||||
|         "type": "CollectionPage", |         "type": "CollectionPage", | ||||||
|         "id": conf["id"] + "?page=2", |         "id": conf["id"] + "?page=2", | ||||||
|         "actor": actor.url, |         "actor": actor.fid, | ||||||
|         "totalItems": len(tfs), |         "totalItems": len(tfs), | ||||||
|         "partOf": conf["id"], |         "partOf": conf["id"], | ||||||
|         "prev": conf["id"] + "?page=1", |         "prev": conf["id"] + "?page=1", | ||||||
|  | @ -472,58 +471,148 @@ def test_collection_page_serializer(factories): | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_activity_pub_audio_serializer_to_library_track(factories): | def test_activity_pub_audio_serializer_to_library_track_no_duplicate(factories): | ||||||
|     remote_library = factories["federation.Library"]() |     remote_library = factories["music.Library"]() | ||||||
|     audio = factories["federation.Audio"]() |     tf = factories["music.TrackFile"].build(library=remote_library) | ||||||
|     serializer = serializers.AudioSerializer( |     data = serializers.AudioSerializer(tf).data | ||||||
|         data=audio, context={"library": remote_library} |     serializer1 = serializers.AudioSerializer(data=data) | ||||||
|  |     serializer2 = serializers.AudioSerializer(data=data) | ||||||
|  | 
 | ||||||
|  |     assert serializer1.is_valid(raise_exception=True) is True | ||||||
|  |     assert serializer2.is_valid(raise_exception=True) is True | ||||||
|  | 
 | ||||||
|  |     tf1 = serializer1.save() | ||||||
|  |     tf2 = serializer2.save() | ||||||
|  | 
 | ||||||
|  |     assert tf1 == tf2 | ||||||
|  | 
 | ||||||
|  |     assert tf1.library == remote_library | ||||||
|  |     assert tf1.source == utils.full_url(tf.listen_url) | ||||||
|  |     assert tf1.mimetype == tf.mimetype | ||||||
|  |     assert tf1.bitrate == tf.bitrate | ||||||
|  |     assert tf1.duration == tf.duration | ||||||
|  |     assert tf1.size == tf.size | ||||||
|  |     assert tf1.metadata == data | ||||||
|  |     assert tf1.fid == tf.get_federation_id() | ||||||
|  |     assert not tf1.audio_file | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_music_library_serializer_to_ap(factories): | ||||||
|  |     library = factories["music.Library"]() | ||||||
|  |     # pending, errored and skippednot included | ||||||
|  |     factories["music.TrackFile"](import_status="pending") | ||||||
|  |     factories["music.TrackFile"](import_status="errored") | ||||||
|  |     factories["music.TrackFile"](import_status="finished") | ||||||
|  |     serializer = serializers.LibrarySerializer(library) | ||||||
|  |     expected = { | ||||||
|  |         "@context": [ | ||||||
|  |             "https://www.w3.org/ns/activitystreams", | ||||||
|  |             "https://w3id.org/security/v1", | ||||||
|  |             {}, | ||||||
|  |         ], | ||||||
|  |         "type": "Library", | ||||||
|  |         "id": library.fid, | ||||||
|  |         "name": library.name, | ||||||
|  |         "summary": library.description, | ||||||
|  |         "audience": "", | ||||||
|  |         "actor": library.actor.fid, | ||||||
|  |         "totalItems": 0, | ||||||
|  |         "current": library.fid + "?page=1", | ||||||
|  |         "last": library.fid + "?page=1", | ||||||
|  |         "first": library.fid + "?page=1", | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     assert serializer.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_music_library_serializer_from_public(factories, mocker): | ||||||
|  |     actor = factories["federation.Actor"]() | ||||||
|  |     retrieve = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.utils.retrieve", return_value=actor | ||||||
|     ) |     ) | ||||||
|  |     data = { | ||||||
|  |         "@context": [ | ||||||
|  |             "https://www.w3.org/ns/activitystreams", | ||||||
|  |             "https://w3id.org/security/v1", | ||||||
|  |             {}, | ||||||
|  |         ], | ||||||
|  |         "audience": "https://www.w3.org/ns/activitystreams#Public", | ||||||
|  |         "name": "Hello", | ||||||
|  |         "summary": "World", | ||||||
|  |         "type": "Library", | ||||||
|  |         "id": "https://library.id", | ||||||
|  |         "actor": actor.fid, | ||||||
|  |         "totalItems": 12, | ||||||
|  |         "first": "https://library.id?page=1", | ||||||
|  |         "last": "https://library.id?page=2", | ||||||
|  |     } | ||||||
|  |     serializer = serializers.LibrarySerializer(data=data) | ||||||
| 
 | 
 | ||||||
|     assert serializer.is_valid(raise_exception=True) |     assert serializer.is_valid(raise_exception=True) | ||||||
| 
 | 
 | ||||||
|     lt = serializer.save() |     library = serializer.save() | ||||||
| 
 | 
 | ||||||
|     assert lt.pk is not None |     assert library.actor == actor | ||||||
|     assert lt.url == audio["id"] |     assert library.fid == data["id"] | ||||||
|     assert lt.library == remote_library |     assert library.files_count == data["totalItems"] | ||||||
|     assert lt.audio_url == audio["url"]["href"] |     assert library.privacy_level == "everyone" | ||||||
|     assert lt.audio_mimetype == audio["url"]["mediaType"] |     assert library.name == "Hello" | ||||||
|     assert lt.metadata == audio["metadata"] |     assert library.description == "World" | ||||||
|     assert lt.title == audio["metadata"]["recording"]["title"] |     retrieve.assert_called_once_with( | ||||||
|     assert lt.artist_name == audio["metadata"]["artist"]["name"] |         actor.fid, | ||||||
|     assert lt.album_title == audio["metadata"]["release"]["title"] |         queryset=actor.__class__, | ||||||
|     assert lt.published_date == pendulum.parse(audio["published"]) |         serializer_class=serializers.ActorSerializer, | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_activity_pub_audio_serializer_to_library_track_no_duplicate(factories): |  | ||||||
|     remote_library = factories["federation.Library"]() |  | ||||||
|     audio = factories["federation.Audio"]() |  | ||||||
|     serializer1 = serializers.AudioSerializer( |  | ||||||
|         data=audio, context={"library": remote_library} |  | ||||||
|     ) |  | ||||||
|     serializer2 = serializers.AudioSerializer( |  | ||||||
|         data=audio, context={"library": remote_library} |  | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     assert serializer1.is_valid() is True |  | ||||||
|     assert serializer2.is_valid() is True |  | ||||||
| 
 | 
 | ||||||
|     lt1 = serializer1.save() | def test_music_library_serializer_from_private(factories, mocker): | ||||||
|     lt2 = serializer2.save() |     actor = factories["federation.Actor"]() | ||||||
|  |     retrieve = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.utils.retrieve", return_value=actor | ||||||
|  |     ) | ||||||
|  |     data = { | ||||||
|  |         "@context": [ | ||||||
|  |             "https://www.w3.org/ns/activitystreams", | ||||||
|  |             "https://w3id.org/security/v1", | ||||||
|  |             {}, | ||||||
|  |         ], | ||||||
|  |         "audience": "", | ||||||
|  |         "name": "Hello", | ||||||
|  |         "summary": "World", | ||||||
|  |         "type": "Library", | ||||||
|  |         "id": "https://library.id", | ||||||
|  |         "actor": actor.fid, | ||||||
|  |         "totalItems": 12, | ||||||
|  |         "first": "https://library.id?page=1", | ||||||
|  |         "last": "https://library.id?page=2", | ||||||
|  |     } | ||||||
|  |     serializer = serializers.LibrarySerializer(data=data) | ||||||
| 
 | 
 | ||||||
|     assert lt1 == lt2 |     assert serializer.is_valid(raise_exception=True) | ||||||
|     assert models.LibraryTrack.objects.count() == 1 | 
 | ||||||
|  |     library = serializer.save() | ||||||
|  | 
 | ||||||
|  |     assert library.actor == actor | ||||||
|  |     assert library.fid == data["id"] | ||||||
|  |     assert library.files_count == data["totalItems"] | ||||||
|  |     assert library.privacy_level == "me" | ||||||
|  |     assert library.name == "Hello" | ||||||
|  |     assert library.description == "World" | ||||||
|  |     retrieve.assert_called_once_with( | ||||||
|  |         actor.fid, | ||||||
|  |         queryset=actor.__class__, | ||||||
|  |         serializer_class=serializers.ActorSerializer, | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_activity_pub_audio_serializer_to_ap(factories): | def test_activity_pub_audio_serializer_to_ap(factories): | ||||||
|     tf = factories["music.TrackFile"]( |     tf = factories["music.TrackFile"]( | ||||||
|         mimetype="audio/mp3", bitrate=42, duration=43, size=44 |         mimetype="audio/mp3", bitrate=42, duration=43, size=44 | ||||||
|     ) |     ) | ||||||
|     library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     expected = { |     expected = { | ||||||
|         "@context": serializers.AP_CONTEXT, |         "@context": serializers.AP_CONTEXT, | ||||||
|         "type": "Audio", |         "type": "Audio", | ||||||
|         "id": tf.get_federation_url(), |         "id": tf.get_federation_id(), | ||||||
|         "name": tf.track.full_name, |         "name": tf.track.full_name, | ||||||
|         "published": tf.creation_date.isoformat(), |         "published": tf.creation_date.isoformat(), | ||||||
|         "updated": tf.modification_date.isoformat(), |         "updated": tf.modification_date.isoformat(), | ||||||
|  | @ -542,14 +631,14 @@ def test_activity_pub_audio_serializer_to_ap(factories): | ||||||
|             "bitrate": tf.bitrate, |             "bitrate": tf.bitrate, | ||||||
|         }, |         }, | ||||||
|         "url": { |         "url": { | ||||||
|             "href": utils.full_url(tf.path), |             "href": utils.full_url(tf.listen_url), | ||||||
|             "type": "Link", |             "type": "Link", | ||||||
|             "mediaType": "audio/mp3", |             "mediaType": "audio/mp3", | ||||||
|         }, |         }, | ||||||
|         "attributedTo": [library.url], |         "library": tf.library.get_federation_id(), | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     serializer = serializers.AudioSerializer(tf, context={"actor": library}) |     serializer = serializers.AudioSerializer(tf) | ||||||
| 
 | 
 | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
| 
 | 
 | ||||||
|  | @ -561,11 +650,10 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories): | ||||||
|         track__album__mbid=None, |         track__album__mbid=None, | ||||||
|         track__album__artist__mbid=None, |         track__album__artist__mbid=None, | ||||||
|     ) |     ) | ||||||
|     library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     expected = { |     expected = { | ||||||
|         "@context": serializers.AP_CONTEXT, |         "@context": serializers.AP_CONTEXT, | ||||||
|         "type": "Audio", |         "type": "Audio", | ||||||
|         "id": tf.get_federation_url(), |         "id": tf.get_federation_id(), | ||||||
|         "name": tf.track.full_name, |         "name": tf.track.full_name, | ||||||
|         "published": tf.creation_date.isoformat(), |         "published": tf.creation_date.isoformat(), | ||||||
|         "updated": tf.modification_date.isoformat(), |         "updated": tf.modification_date.isoformat(), | ||||||
|  | @ -573,116 +661,23 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories): | ||||||
|             "artist": {"name": tf.track.artist.name, "musicbrainz_id": None}, |             "artist": {"name": tf.track.artist.name, "musicbrainz_id": None}, | ||||||
|             "release": {"title": tf.track.album.title, "musicbrainz_id": None}, |             "release": {"title": tf.track.album.title, "musicbrainz_id": None}, | ||||||
|             "recording": {"title": tf.track.title, "musicbrainz_id": None}, |             "recording": {"title": tf.track.title, "musicbrainz_id": None}, | ||||||
|             "size": None, |             "size": tf.size, | ||||||
|             "length": None, |             "length": None, | ||||||
|             "bitrate": None, |             "bitrate": None, | ||||||
|         }, |         }, | ||||||
|         "url": { |         "url": { | ||||||
|             "href": utils.full_url(tf.path), |             "href": utils.full_url(tf.listen_url), | ||||||
|             "type": "Link", |             "type": "Link", | ||||||
|             "mediaType": "audio/mp3", |             "mediaType": "audio/mp3", | ||||||
|         }, |         }, | ||||||
|         "attributedTo": [library.url], |         "library": tf.library.fid, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     serializer = serializers.AudioSerializer(tf, context={"actor": library}) |     serializer = serializers.AudioSerializer(tf) | ||||||
| 
 | 
 | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_collection_serializer_to_ap(factories): |  | ||||||
|     tf1 = factories["music.TrackFile"](mimetype="audio/mp3") |  | ||||||
|     tf2 = factories["music.TrackFile"](mimetype="audio/ogg") |  | ||||||
|     library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     expected = { |  | ||||||
|         "@context": serializers.AP_CONTEXT, |  | ||||||
|         "id": "https://test.id", |  | ||||||
|         "actor": library.url, |  | ||||||
|         "totalItems": 2, |  | ||||||
|         "type": "Collection", |  | ||||||
|         "items": [ |  | ||||||
|             serializers.AudioSerializer( |  | ||||||
|                 tf1, context={"actor": library, "include_ap_context": False} |  | ||||||
|             ).data, |  | ||||||
|             serializers.AudioSerializer( |  | ||||||
|                 tf2, context={"actor": library, "include_ap_context": False} |  | ||||||
|             ).data, |  | ||||||
|         ], |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     collection = { |  | ||||||
|         "id": expected["id"], |  | ||||||
|         "actor": library, |  | ||||||
|         "items": [tf1, tf2], |  | ||||||
|         "item_serializer": serializers.AudioSerializer, |  | ||||||
|     } |  | ||||||
|     serializer = serializers.CollectionSerializer( |  | ||||||
|         collection, context={"actor": library, "id": "https://test.id"} |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     assert serializer.data == expected |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_api_library_create_serializer_save(factories, r_mock): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     follow = factories["federation.Follow"](target=actor, actor=library_actor) |  | ||||||
|     actor_data = serializers.ActorSerializer(actor).data |  | ||||||
|     actor_data["url"] = [ |  | ||||||
|         {"href": "https://test.library", "name": "library", "type": "Link"} |  | ||||||
|     ] |  | ||||||
|     library_conf = { |  | ||||||
|         "id": "https://test.library", |  | ||||||
|         "items": range(10), |  | ||||||
|         "actor": actor, |  | ||||||
|         "page_size": 5, |  | ||||||
|     } |  | ||||||
|     library_data = serializers.PaginatedCollectionSerializer(library_conf).data |  | ||||||
|     r_mock.get(actor.url, json=actor_data) |  | ||||||
|     r_mock.get("https://test.library", json=library_data) |  | ||||||
|     data = { |  | ||||||
|         "actor": actor.url, |  | ||||||
|         "autoimport": False, |  | ||||||
|         "federation_enabled": True, |  | ||||||
|         "download_files": False, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     serializer = serializers.APILibraryCreateSerializer(data=data) |  | ||||||
|     assert serializer.is_valid(raise_exception=True) is True |  | ||||||
|     library = serializer.save() |  | ||||||
|     follow = models.Follow.objects.get(target=actor, actor=library_actor, approved=None) |  | ||||||
| 
 |  | ||||||
|     assert library.autoimport is data["autoimport"] |  | ||||||
|     assert library.federation_enabled is data["federation_enabled"] |  | ||||||
|     assert library.download_files is data["download_files"] |  | ||||||
|     assert library.tracks_count == 10 |  | ||||||
|     assert library.actor == actor |  | ||||||
|     assert library.follow == follow |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_tapi_library_track_serializer_not_imported(factories): |  | ||||||
|     lt = factories["federation.LibraryTrack"]() |  | ||||||
|     serializer = serializers.APILibraryTrackSerializer(lt) |  | ||||||
| 
 |  | ||||||
|     assert serializer.get_status(lt) == "not_imported" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_tapi_library_track_serializer_imported(factories): |  | ||||||
|     tf = factories["music.TrackFile"](federation=True) |  | ||||||
|     lt = tf.library_track |  | ||||||
|     serializer = serializers.APILibraryTrackSerializer(lt) |  | ||||||
| 
 |  | ||||||
|     assert serializer.get_status(lt) == "imported" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_tapi_library_track_serializer_import_pending(factories): |  | ||||||
|     job = factories["music.ImportJob"](federation=True, status="pending") |  | ||||||
|     lt = job.library_track |  | ||||||
|     serializer = serializers.APILibraryTrackSerializer(lt) |  | ||||||
| 
 |  | ||||||
|     assert serializer.get_status(lt) == "import_pending" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_local_actor_serializer_to_ap(factories): | def test_local_actor_serializer_to_ap(factories): | ||||||
|     expected = { |     expected = { | ||||||
|         "@context": [ |         "@context": [ | ||||||
|  | @ -708,7 +703,7 @@ def test_local_actor_serializer_to_ap(factories): | ||||||
|         "endpoints": {"sharedInbox": "https://test.federation/inbox"}, |         "endpoints": {"sharedInbox": "https://test.federation/inbox"}, | ||||||
|     } |     } | ||||||
|     ac = models.Actor.objects.create( |     ac = models.Actor.objects.create( | ||||||
|         url=expected["id"], |         fid=expected["id"], | ||||||
|         inbox_url=expected["inbox"], |         inbox_url=expected["inbox"], | ||||||
|         outbox_url=expected["outbox"], |         outbox_url=expected["outbox"], | ||||||
|         shared_inbox_url=expected["endpoints"]["sharedInbox"], |         shared_inbox_url=expected["endpoints"]["sharedInbox"], | ||||||
|  | @ -734,3 +729,45 @@ def test_local_actor_serializer_to_ap(factories): | ||||||
|     serializer = serializers.ActorSerializer(ac) |     serializer = serializers.ActorSerializer(ac) | ||||||
| 
 | 
 | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_activity_serializer_clean_recipients_empty(db): | ||||||
|  |     s = serializers.BaseActivitySerializer() | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         s.validate_recipients({}) | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         s.validate_recipients({"to": []}) | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         s.validate_recipients({"cc": []}) | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         s.validate_recipients({"to": ["nope"]}) | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         s.validate_recipients({"cc": ["nope"]}) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_activity_serializer_clean_recipients(factories): | ||||||
|  |     r1, r2, r3 = factories["federation.Actor"].create_batch(size=3) | ||||||
|  | 
 | ||||||
|  |     s = serializers.BaseActivitySerializer() | ||||||
|  | 
 | ||||||
|  |     expected = {"to": [r1, r2], "cc": [r3, activity.PUBLIC_ADDRESS]} | ||||||
|  | 
 | ||||||
|  |     assert ( | ||||||
|  |         s.validate_recipients( | ||||||
|  |             {"to": [r1.fid, r2.fid], "cc": [r3.fid, activity.PUBLIC_ADDRESS]} | ||||||
|  |         ) | ||||||
|  |         == expected | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_activity_serializer_clean_recipients_local(factories): | ||||||
|  |     r = factories["federation.Actor"]() | ||||||
|  | 
 | ||||||
|  |     s = serializers.BaseActivitySerializer(context={"local_recipients": True}) | ||||||
|  |     with pytest.raises(serializers.serializers.ValidationError): | ||||||
|  |         s.validate_recipients({"to": [r]}) | ||||||
|  |  | ||||||
|  | @ -1,132 +1,37 @@ | ||||||
| import datetime | import datetime | ||||||
| import os | import os | ||||||
| import pathlib | import pathlib | ||||||
|  | import pytest | ||||||
| 
 | 
 | ||||||
| from django.core.paginator import Paginator |  | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import serializers, tasks | from funkwhale_api.federation import tasks | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_scan_library_does_nothing_if_federation_disabled(mocker, factories): |  | ||||||
|     library = factories["federation.Library"](federation_enabled=False) |  | ||||||
|     tasks.scan_library(library_id=library.pk) |  | ||||||
| 
 |  | ||||||
|     assert library.tracks.count() == 0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_scan_library_page_does_nothing_if_federation_disabled(mocker, factories): |  | ||||||
|     library = factories["federation.Library"](federation_enabled=False) |  | ||||||
|     tasks.scan_library_page(library_id=library.pk, page_url=None) |  | ||||||
| 
 |  | ||||||
|     assert library.tracks.count() == 0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_scan_library_fetches_page_and_calls_scan_page(mocker, factories, r_mock): |  | ||||||
|     now = timezone.now() |  | ||||||
|     library = factories["federation.Library"](federation_enabled=True) |  | ||||||
|     collection_conf = { |  | ||||||
|         "actor": library.actor, |  | ||||||
|         "id": library.url, |  | ||||||
|         "page_size": 10, |  | ||||||
|         "items": range(10), |  | ||||||
|     } |  | ||||||
|     collection = serializers.PaginatedCollectionSerializer(collection_conf) |  | ||||||
|     scan_page = mocker.patch("funkwhale_api.federation.tasks.scan_library_page.delay") |  | ||||||
|     r_mock.get(collection_conf["id"], json=collection.data) |  | ||||||
|     tasks.scan_library(library_id=library.pk) |  | ||||||
| 
 |  | ||||||
|     scan_page.assert_called_once_with( |  | ||||||
|         library_id=library.id, page_url=collection.data["first"], until=None |  | ||||||
|     ) |  | ||||||
|     library.refresh_from_db() |  | ||||||
|     assert library.fetched_date > now |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_scan_page_fetches_page_and_creates_tracks(mocker, factories, r_mock): |  | ||||||
|     library = factories["federation.Library"](federation_enabled=True) |  | ||||||
|     tfs = factories["music.TrackFile"].create_batch(size=5) |  | ||||||
|     page_conf = { |  | ||||||
|         "actor": library.actor, |  | ||||||
|         "id": library.url, |  | ||||||
|         "page": Paginator(tfs, 5).page(1), |  | ||||||
|         "item_serializer": serializers.AudioSerializer, |  | ||||||
|     } |  | ||||||
|     page = serializers.CollectionPageSerializer(page_conf) |  | ||||||
|     r_mock.get(page.data["id"], json=page.data) |  | ||||||
| 
 |  | ||||||
|     tasks.scan_library_page(library_id=library.pk, page_url=page.data["id"]) |  | ||||||
| 
 |  | ||||||
|     lts = list(library.tracks.all().order_by("-published_date")) |  | ||||||
|     assert len(lts) == 5 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock): |  | ||||||
|     patched_scan = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.tasks.scan_library_page.delay" |  | ||||||
|     ) |  | ||||||
|     library = factories["federation.Library"](federation_enabled=True) |  | ||||||
|     tfs = factories["music.TrackFile"].create_batch(size=1) |  | ||||||
|     page_conf = { |  | ||||||
|         "actor": library.actor, |  | ||||||
|         "id": library.url, |  | ||||||
|         "page": Paginator(tfs, 3).page(1), |  | ||||||
|         "item_serializer": serializers.AudioSerializer, |  | ||||||
|     } |  | ||||||
|     page = serializers.CollectionPageSerializer(page_conf) |  | ||||||
|     data = page.data |  | ||||||
|     data["next"] = data["id"] |  | ||||||
|     r_mock.get(page.data["id"], json=data) |  | ||||||
| 
 |  | ||||||
|     tasks.scan_library_page(library_id=library.pk, page_url=data["id"]) |  | ||||||
|     patched_scan.assert_not_called() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_scan_page_stops_once_until_is_reached(mocker, factories, r_mock): |  | ||||||
|     library = factories["federation.Library"](federation_enabled=True) |  | ||||||
|     tfs = list(reversed(factories["music.TrackFile"].create_batch(size=5))) |  | ||||||
|     page_conf = { |  | ||||||
|         "actor": library.actor, |  | ||||||
|         "id": library.url, |  | ||||||
|         "page": Paginator(tfs, 3).page(1), |  | ||||||
|         "item_serializer": serializers.AudioSerializer, |  | ||||||
|     } |  | ||||||
|     page = serializers.CollectionPageSerializer(page_conf) |  | ||||||
|     r_mock.get(page.data["id"], json=page.data) |  | ||||||
| 
 |  | ||||||
|     tasks.scan_library_page( |  | ||||||
|         library_id=library.pk, page_url=page.data["id"], until=tfs[1].creation_date |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     lts = list(library.tracks.all().order_by("-published_date")) |  | ||||||
|     assert len(lts) == 2 |  | ||||||
|     for i, tf in enumerate(tfs[:1]): |  | ||||||
|         assert tf.creation_date == lts[i].published_date |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_clean_federation_music_cache_if_no_listen(preferences, factories): | def test_clean_federation_music_cache_if_no_listen(preferences, factories): | ||||||
|     preferences["federation__music_cache_duration"] = 60 |     preferences["federation__music_cache_duration"] = 60 | ||||||
|     lt1 = factories["federation.LibraryTrack"](with_audio_file=True) |     remote_library = factories["music.Library"]() | ||||||
|     lt2 = factories["federation.LibraryTrack"](with_audio_file=True) |     tf1 = factories["music.TrackFile"]( | ||||||
|     lt3 = factories["federation.LibraryTrack"](with_audio_file=True) |         library=remote_library, accessed_date=timezone.now() | ||||||
|     factories["music.TrackFile"](accessed_date=timezone.now(), library_track=lt1) |  | ||||||
|     factories["music.TrackFile"]( |  | ||||||
|         accessed_date=timezone.now() - datetime.timedelta(minutes=61), library_track=lt2 |  | ||||||
|     ) |     ) | ||||||
|     factories["music.TrackFile"](accessed_date=None, library_track=lt3) |     tf2 = factories["music.TrackFile"]( | ||||||
|     path1 = lt1.audio_file.path |         library=remote_library, | ||||||
|     path2 = lt2.audio_file.path |         accessed_date=timezone.now() - datetime.timedelta(minutes=61), | ||||||
|     path3 = lt3.audio_file.path |     ) | ||||||
|  |     tf3 = factories["music.TrackFile"](library=remote_library, accessed_date=None) | ||||||
|  |     path1 = tf1.audio_file.path | ||||||
|  |     path2 = tf2.audio_file.path | ||||||
|  |     path3 = tf3.audio_file.path | ||||||
| 
 | 
 | ||||||
|     tasks.clean_music_cache() |     tasks.clean_music_cache() | ||||||
| 
 | 
 | ||||||
|     lt1.refresh_from_db() |     tf1.refresh_from_db() | ||||||
|     lt2.refresh_from_db() |     tf2.refresh_from_db() | ||||||
|     lt3.refresh_from_db() |     tf3.refresh_from_db() | ||||||
| 
 | 
 | ||||||
|     assert bool(lt1.audio_file) is True |     assert bool(tf1.audio_file) is True | ||||||
|     assert bool(lt2.audio_file) is False |     assert bool(tf2.audio_file) is False | ||||||
|     assert bool(lt3.audio_file) is False |     assert bool(tf3.audio_file) is False | ||||||
|     assert os.path.exists(path1) is True |     assert os.path.exists(path1) is True | ||||||
|     assert os.path.exists(path2) is False |     assert os.path.exists(path2) is False | ||||||
|     assert os.path.exists(path3) is False |     assert os.path.exists(path3) is False | ||||||
|  | @ -134,22 +39,202 @@ def test_clean_federation_music_cache_if_no_listen(preferences, factories): | ||||||
| 
 | 
 | ||||||
| def test_clean_federation_music_cache_orphaned(settings, preferences, factories): | def test_clean_federation_music_cache_orphaned(settings, preferences, factories): | ||||||
|     preferences["federation__music_cache_duration"] = 60 |     preferences["federation__music_cache_duration"] = 60 | ||||||
|     path = os.path.join(settings.MEDIA_ROOT, "federation_cache") |     path = os.path.join(settings.MEDIA_ROOT, "federation_cache", "tracks") | ||||||
|     keep_path = os.path.join(os.path.join(path, "1a", "b2"), "keep.ogg") |     keep_path = os.path.join(os.path.join(path, "1a", "b2"), "keep.ogg") | ||||||
|     remove_path = os.path.join(os.path.join(path, "c3", "d4"), "remove.ogg") |     remove_path = os.path.join(os.path.join(path, "c3", "d4"), "remove.ogg") | ||||||
|     os.makedirs(os.path.dirname(keep_path), exist_ok=True) |     os.makedirs(os.path.dirname(keep_path), exist_ok=True) | ||||||
|     os.makedirs(os.path.dirname(remove_path), exist_ok=True) |     os.makedirs(os.path.dirname(remove_path), exist_ok=True) | ||||||
|     pathlib.Path(keep_path).touch() |     pathlib.Path(keep_path).touch() | ||||||
|     pathlib.Path(remove_path).touch() |     pathlib.Path(remove_path).touch() | ||||||
|     lt = factories["federation.LibraryTrack"]( |     tf = factories["music.TrackFile"]( | ||||||
|         with_audio_file=True, audio_file__path=keep_path |         accessed_date=timezone.now(), audio_file__path=keep_path | ||||||
|     ) |     ) | ||||||
|     factories["music.TrackFile"](library_track=lt, accessed_date=timezone.now()) |  | ||||||
| 
 | 
 | ||||||
|     tasks.clean_music_cache() |     tasks.clean_music_cache() | ||||||
| 
 | 
 | ||||||
|     lt.refresh_from_db() |     tf.refresh_from_db() | ||||||
| 
 | 
 | ||||||
|     assert bool(lt.audio_file) is True |     assert bool(tf.audio_file) is True | ||||||
|     assert os.path.exists(lt.audio_file.path) is True |     assert os.path.exists(tf.audio_file.path) is True | ||||||
|     assert os.path.exists(remove_path) is False |     assert os.path.exists(remove_path) is False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_handle_in(factories, mocker, now): | ||||||
|  |     mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch") | ||||||
|  | 
 | ||||||
|  |     r1 = factories["users.User"](with_actor=True).actor | ||||||
|  |     r2 = factories["users.User"](with_actor=True).actor | ||||||
|  |     a = factories["federation.Activity"](payload={"hello": "world"}) | ||||||
|  |     ii1 = factories["federation.InboxItem"](activity=a, actor=r1) | ||||||
|  |     ii2 = factories["federation.InboxItem"](activity=a, actor=r2) | ||||||
|  |     tasks.dispatch_inbox(activity_id=a.pk) | ||||||
|  | 
 | ||||||
|  |     mocked_dispatch.assert_called_once_with( | ||||||
|  |         a.payload, context={"actor": a.actor, "inbox_items": [ii1, ii2]} | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     ii1.refresh_from_db() | ||||||
|  |     ii2.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert ii1.is_delivered is True | ||||||
|  |     assert ii2.is_delivered is True | ||||||
|  |     assert ii1.last_delivery_date == now | ||||||
|  |     assert ii2.last_delivery_date == now | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_handle_in_error(factories, mocker, now): | ||||||
|  |     mocker.patch( | ||||||
|  |         "funkwhale_api.federation.routes.inbox.dispatch", side_effect=Exception() | ||||||
|  |     ) | ||||||
|  |     r1 = factories["users.User"](with_actor=True).actor | ||||||
|  |     r2 = factories["users.User"](with_actor=True).actor | ||||||
|  | 
 | ||||||
|  |     a = factories["federation.Activity"](payload={"hello": "world"}) | ||||||
|  |     factories["federation.InboxItem"](activity=a, actor=r1) | ||||||
|  |     factories["federation.InboxItem"](activity=a, actor=r2) | ||||||
|  | 
 | ||||||
|  |     with pytest.raises(Exception): | ||||||
|  |         tasks.dispatch_inbox(activity_id=a.pk) | ||||||
|  | 
 | ||||||
|  |     assert a.inbox_items.filter(is_delivered=False).count() == 2 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_dispatch_outbox_to_inbox(factories, mocker): | ||||||
|  |     mocked_inbox = mocker.patch("funkwhale_api.federation.tasks.dispatch_inbox.delay") | ||||||
|  |     mocked_deliver_to_remote_inbox = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.tasks.deliver_to_remote_inbox.delay" | ||||||
|  |     ) | ||||||
|  |     activity = factories["federation.Activity"](actor__local=True) | ||||||
|  |     factories["federation.InboxItem"](activity=activity, actor__local=True) | ||||||
|  |     remote_ii = factories["federation.InboxItem"]( | ||||||
|  |         activity=activity, | ||||||
|  |         actor__shared_inbox_url=None, | ||||||
|  |         actor__inbox_url="https://test.inbox", | ||||||
|  |     ) | ||||||
|  |     tasks.dispatch_outbox(activity_id=activity.pk) | ||||||
|  |     mocked_inbox.assert_called_once_with(activity_id=activity.pk) | ||||||
|  |     mocked_deliver_to_remote_inbox.assert_called_once_with( | ||||||
|  |         activity_id=activity.pk, inbox_url=remote_ii.actor.inbox_url | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_dispatch_outbox_to_shared_inbox_url(factories, mocker): | ||||||
|  |     mocked_deliver_to_remote_inbox = mocker.patch( | ||||||
|  |         "funkwhale_api.federation.tasks.deliver_to_remote_inbox.delay" | ||||||
|  |     ) | ||||||
|  |     activity = factories["federation.Activity"](actor__local=True) | ||||||
|  |     # shared inbox | ||||||
|  |     remote_ii_shared1 = factories["federation.InboxItem"]( | ||||||
|  |         activity=activity, actor__shared_inbox_url="https://shared.inbox" | ||||||
|  |     ) | ||||||
|  |     # another on the same shared inbox | ||||||
|  |     factories["federation.InboxItem"]( | ||||||
|  |         activity=activity, actor__shared_inbox_url="https://shared.inbox" | ||||||
|  |     ) | ||||||
|  |     # one on a dedicated inbox | ||||||
|  |     remote_ii_single = factories["federation.InboxItem"]( | ||||||
|  |         activity=activity, | ||||||
|  |         actor__shared_inbox_url=None, | ||||||
|  |         actor__inbox_url="https://single.inbox", | ||||||
|  |     ) | ||||||
|  |     tasks.dispatch_outbox(activity_id=activity.pk) | ||||||
|  | 
 | ||||||
|  |     assert mocked_deliver_to_remote_inbox.call_count == 2 | ||||||
|  |     mocked_deliver_to_remote_inbox.assert_any_call( | ||||||
|  |         activity_id=activity.pk, | ||||||
|  |         shared_inbox_url=remote_ii_shared1.actor.shared_inbox_url, | ||||||
|  |     ) | ||||||
|  |     mocked_deliver_to_remote_inbox.assert_any_call( | ||||||
|  |         activity_id=activity.pk, inbox_url=remote_ii_single.actor.inbox_url | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_deliver_to_remote_inbox_inbox_url(factories, r_mock): | ||||||
|  |     activity = factories["federation.Activity"]() | ||||||
|  |     url = "https://test.shared/" | ||||||
|  |     r_mock.post(url) | ||||||
|  | 
 | ||||||
|  |     tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url) | ||||||
|  | 
 | ||||||
|  |     request = r_mock.request_history[0] | ||||||
|  | 
 | ||||||
|  |     assert r_mock.called is True | ||||||
|  |     assert r_mock.call_count == 1 | ||||||
|  |     assert request.url == url | ||||||
|  |     assert request.headers["content-type"] == "application/activity+json" | ||||||
|  |     assert request.json() == activity.payload | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_deliver_to_remote_inbox_shared_inbox_url(factories, r_mock): | ||||||
|  |     activity = factories["federation.Activity"]() | ||||||
|  |     url = "https://test.shared/" | ||||||
|  |     r_mock.post(url) | ||||||
|  | 
 | ||||||
|  |     tasks.deliver_to_remote_inbox(activity_id=activity.pk, shared_inbox_url=url) | ||||||
|  | 
 | ||||||
|  |     request = r_mock.request_history[0] | ||||||
|  | 
 | ||||||
|  |     assert r_mock.called is True | ||||||
|  |     assert r_mock.call_count == 1 | ||||||
|  |     assert request.url == url | ||||||
|  |     assert request.headers["content-type"] == "application/activity+json" | ||||||
|  |     assert request.json() == activity.payload | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_deliver_to_remote_inbox_success_shared_inbox_marks_inbox_items_as_delivered( | ||||||
|  |     factories, r_mock, now | ||||||
|  | ): | ||||||
|  |     activity = factories["federation.Activity"]() | ||||||
|  |     url = "https://test.shared/" | ||||||
|  |     r_mock.post(url) | ||||||
|  |     ii = factories["federation.InboxItem"]( | ||||||
|  |         activity=activity, actor__shared_inbox_url=url | ||||||
|  |     ) | ||||||
|  |     other_ii = factories["federation.InboxItem"]( | ||||||
|  |         activity=activity, actor__shared_inbox_url="https://other.url" | ||||||
|  |     ) | ||||||
|  |     tasks.deliver_to_remote_inbox(activity_id=activity.pk, shared_inbox_url=url) | ||||||
|  | 
 | ||||||
|  |     ii.refresh_from_db() | ||||||
|  |     other_ii.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert ii.is_delivered is True | ||||||
|  |     assert ii.last_delivery_date == now | ||||||
|  |     assert other_ii.is_delivered is False | ||||||
|  |     assert other_ii.last_delivery_date is None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_deliver_to_remote_inbox_success_single_inbox_marks_inbox_items_as_delivered( | ||||||
|  |     factories, r_mock, now | ||||||
|  | ): | ||||||
|  |     activity = factories["federation.Activity"]() | ||||||
|  |     url = "https://test.single/" | ||||||
|  |     r_mock.post(url) | ||||||
|  |     ii = factories["federation.InboxItem"](activity=activity, actor__inbox_url=url) | ||||||
|  |     other_ii = factories["federation.InboxItem"]( | ||||||
|  |         activity=activity, actor__inbox_url="https://other.url" | ||||||
|  |     ) | ||||||
|  |     tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url) | ||||||
|  | 
 | ||||||
|  |     ii.refresh_from_db() | ||||||
|  |     other_ii.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert ii.is_delivered is True | ||||||
|  |     assert ii.last_delivery_date == now | ||||||
|  |     assert other_ii.is_delivered is False | ||||||
|  |     assert other_ii.last_delivery_date is None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_deliver_to_remote_inbox_error(factories, r_mock, now): | ||||||
|  |     activity = factories["federation.Activity"]() | ||||||
|  |     url = "https://test.single/" | ||||||
|  |     r_mock.post(url, status_code=404) | ||||||
|  |     ii = factories["federation.InboxItem"](activity=activity, actor__inbox_url=url) | ||||||
|  |     with pytest.raises(tasks.RequestException): | ||||||
|  |         tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url) | ||||||
|  | 
 | ||||||
|  |     ii.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert ii.is_delivered is False | ||||||
|  |     assert ii.last_delivery_date == now | ||||||
|  |     assert ii.delivery_attempts == 1 | ||||||
|  |  | ||||||
|  | @ -1,3 +1,4 @@ | ||||||
|  | from rest_framework import serializers | ||||||
| import pytest | import pytest | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import utils | from funkwhale_api.federation import utils | ||||||
|  | @ -50,3 +51,41 @@ def test_extract_headers_from_meta(): | ||||||
|         "User-Agent": "http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)", |         "User-Agent": "http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)", | ||||||
|     } |     } | ||||||
|     assert cleaned_headers == expected |     assert cleaned_headers == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_retrieve(r_mock): | ||||||
|  |     fid = "https://some.url" | ||||||
|  |     m = r_mock.get(fid, json={"hello": "world"}) | ||||||
|  |     result = utils.retrieve(fid) | ||||||
|  | 
 | ||||||
|  |     assert result == {"hello": "world"} | ||||||
|  |     assert m.request_history[-1].headers["Accept"] == "application/activity+json" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_retrieve_with_actor(r_mock, factories): | ||||||
|  |     actor = factories["federation.Actor"]() | ||||||
|  |     fid = "https://some.url" | ||||||
|  |     m = r_mock.get(fid, json={"hello": "world"}) | ||||||
|  |     result = utils.retrieve(fid, actor=actor) | ||||||
|  | 
 | ||||||
|  |     assert result == {"hello": "world"} | ||||||
|  |     assert m.request_history[-1].headers["Accept"] == "application/activity+json" | ||||||
|  |     assert m.request_history[-1].headers["Signature"] is not None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_retrieve_with_queryset(factories): | ||||||
|  |     actor = factories["federation.Actor"]() | ||||||
|  | 
 | ||||||
|  |     assert utils.retrieve(actor.fid, queryset=actor.__class__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_retrieve_with_serializer(r_mock): | ||||||
|  |     class S(serializers.Serializer): | ||||||
|  |         def create(self, validated_data): | ||||||
|  |             return {"persisted": "object"} | ||||||
|  | 
 | ||||||
|  |     fid = "https://some.url" | ||||||
|  |     r_mock.get(fid, json={"hello": "world"}) | ||||||
|  |     result = utils.retrieve(fid, serializer_class=S) | ||||||
|  | 
 | ||||||
|  |     assert result == {"persisted": "object"} | ||||||
|  |  | ||||||
|  | @ -1,29 +1,8 @@ | ||||||
| import pytest | import pytest | ||||||
| from django.core.paginator import Paginator | from django.core.paginator import Paginator | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils import timezone |  | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import ( | from funkwhale_api.federation import actors, serializers, webfinger | ||||||
|     activity, |  | ||||||
|     actors, |  | ||||||
|     models, |  | ||||||
|     serializers, |  | ||||||
|     utils, |  | ||||||
|     views, |  | ||||||
|     webfinger, |  | ||||||
| ) |  | ||||||
| from funkwhale_api.music import tasks as music_tasks |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.mark.parametrize( |  | ||||||
|     "view,permissions", |  | ||||||
|     [ |  | ||||||
|         (views.LibraryViewSet, ["federation"]), |  | ||||||
|         (views.LibraryTrackViewSet, ["federation"]), |  | ||||||
|     ], |  | ||||||
| ) |  | ||||||
| def test_permissions(assert_user_permission, view, permissions): |  | ||||||
|     assert_user_permission(view, permissions) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize("system_actor", actors.SYSTEM_ACTORS.keys()) | @pytest.mark.parametrize("system_actor", actors.SYSTEM_ACTORS.keys()) | ||||||
|  | @ -39,25 +18,6 @@ def test_instance_actors(system_actor, db, api_client): | ||||||
|     assert response.data == serializer.data |     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, preferences, api_client, route, kwargs |  | ||||||
| ): |  | ||||||
|     preferences["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): | def test_wellknown_webfinger_validates_resource(db, api_client, settings, mocker): | ||||||
|     clean = mocker.spy(webfinger, "clean_resource") |     clean = mocker.spy(webfinger, "clean_resource") | ||||||
|     url = reverse("federation:well-known-webfinger") |     url = reverse("federation:well-known-webfinger") | ||||||
|  | @ -110,318 +70,6 @@ def test_wellknown_nodeinfo_disabled(db, preferences, api_client): | ||||||
|     assert response.status_code == 404 |     assert response.status_code == 404 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_audio_file_list_requires_authenticated_actor(db, preferences, api_client): |  | ||||||
|     preferences["federation__music_needs_approval"] = True |  | ||||||
|     url = reverse("federation:music:files-list") |  | ||||||
|     response = api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 403 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_audio_file_list_actor_no_page(db, preferences, api_client, factories): |  | ||||||
|     preferences["federation__music_needs_approval"] = False |  | ||||||
|     preferences["federation__collection_page_size"] = 2 |  | ||||||
|     library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     tfs = factories["music.TrackFile"].create_batch(size=5) |  | ||||||
|     conf = { |  | ||||||
|         "id": utils.full_url(reverse("federation:music:files-list")), |  | ||||||
|         "page_size": 2, |  | ||||||
|         "items": list(reversed(tfs)),  # we order by -creation_date |  | ||||||
|         "item_serializer": serializers.AudioSerializer, |  | ||||||
|         "actor": library, |  | ||||||
|     } |  | ||||||
|     expected = serializers.PaginatedCollectionSerializer(conf).data |  | ||||||
|     url = reverse("federation:music:files-list") |  | ||||||
|     response = api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data == expected |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_audio_file_list_actor_page(db, preferences, api_client, factories): |  | ||||||
|     preferences["federation__music_needs_approval"] = False |  | ||||||
|     preferences["federation__collection_page_size"] = 2 |  | ||||||
|     library = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     tfs = factories["music.TrackFile"].create_batch(size=5) |  | ||||||
|     conf = { |  | ||||||
|         "id": utils.full_url(reverse("federation:music:files-list")), |  | ||||||
|         "page": Paginator(list(reversed(tfs)), 2).page(2), |  | ||||||
|         "item_serializer": serializers.AudioSerializer, |  | ||||||
|         "actor": library, |  | ||||||
|     } |  | ||||||
|     expected = serializers.CollectionPageSerializer(conf).data |  | ||||||
|     url = reverse("federation:music:files-list") |  | ||||||
|     response = api_client.get(url, data={"page": 2}) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data == expected |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_audio_file_list_actor_page_exclude_federated_files( |  | ||||||
|     db, preferences, api_client, factories |  | ||||||
| ): |  | ||||||
|     preferences["federation__music_needs_approval"] = False |  | ||||||
|     factories["music.TrackFile"].create_batch(size=5, federation=True) |  | ||||||
| 
 |  | ||||||
|     url = reverse("federation:music:files-list") |  | ||||||
|     response = api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data["totalItems"] == 0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_audio_file_list_actor_page_error(db, preferences, api_client, factories): |  | ||||||
|     preferences["federation__music_needs_approval"] = False |  | ||||||
|     url = reverse("federation:music:files-list") |  | ||||||
|     response = api_client.get(url, data={"page": "nope"}) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 400 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_audio_file_list_actor_page_error_too_far( |  | ||||||
|     db, preferences, api_client, factories |  | ||||||
| ): |  | ||||||
|     preferences["federation__music_needs_approval"] = False |  | ||||||
|     url = reverse("federation:music:files-list") |  | ||||||
|     response = api_client.get(url, data={"page": 5000}) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 404 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_actor_includes_library_link(db, preferences, api_client): |  | ||||||
|     url = reverse("federation:instance-actors-detail", kwargs={"actor": "library"}) |  | ||||||
|     response = api_client.get(url) |  | ||||||
|     expected_links = [ |  | ||||||
|         { |  | ||||||
|             "type": "Link", |  | ||||||
|             "name": "library", |  | ||||||
|             "mediaType": "application/activity+json", |  | ||||||
|             "href": utils.full_url(reverse("federation:music:files-list")), |  | ||||||
|         } |  | ||||||
|     ] |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data["url"] == expected_links |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_fetch_library(superuser_api_client, mocker): |  | ||||||
|     result = {"test": "test"} |  | ||||||
|     scan = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.library.scan_from_account_name", return_value=result |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     url = reverse("api:v1:federation:libraries-fetch") |  | ||||||
|     response = superuser_api_client.get(url, data={"account": "test@test.library"}) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data == result |  | ||||||
|     scan.assert_called_once_with("test@test.library") |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_follow_library(superuser_api_client, mocker, factories, r_mock): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     follow = {"test": "follow"} |  | ||||||
|     on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") |  | ||||||
|     actor_data = serializers.ActorSerializer(actor).data |  | ||||||
|     actor_data["url"] = [ |  | ||||||
|         {"href": "https://test.library", "name": "library", "type": "Link"} |  | ||||||
|     ] |  | ||||||
|     library_conf = { |  | ||||||
|         "id": "https://test.library", |  | ||||||
|         "items": range(10), |  | ||||||
|         "actor": actor, |  | ||||||
|         "page_size": 5, |  | ||||||
|     } |  | ||||||
|     library_data = serializers.PaginatedCollectionSerializer(library_conf).data |  | ||||||
|     r_mock.get(actor.url, json=actor_data) |  | ||||||
|     r_mock.get("https://test.library", json=library_data) |  | ||||||
|     data = { |  | ||||||
|         "actor": actor.url, |  | ||||||
|         "autoimport": False, |  | ||||||
|         "federation_enabled": True, |  | ||||||
|         "download_files": False, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     url = reverse("api:v1:federation:libraries-list") |  | ||||||
|     response = superuser_api_client.post(url, data) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 201 |  | ||||||
| 
 |  | ||||||
|     follow = models.Follow.objects.get(actor=library_actor, target=actor, approved=None) |  | ||||||
|     library = follow.library |  | ||||||
| 
 |  | ||||||
|     assert response.data == serializers.APILibraryCreateSerializer(library).data |  | ||||||
| 
 |  | ||||||
|     on_commit.assert_called_once_with( |  | ||||||
|         activity.deliver, |  | ||||||
|         serializers.FollowSerializer(follow).data, |  | ||||||
|         on_behalf_of=library_actor, |  | ||||||
|         to=[actor.url], |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_list_system_actor_following(factories, superuser_api_client): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     follow1 = factories["federation.Follow"](actor=library_actor) |  | ||||||
|     factories["federation.Follow"]() |  | ||||||
| 
 |  | ||||||
|     url = reverse("api:v1:federation:libraries-following") |  | ||||||
|     response = superuser_api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data["results"] == [serializers.APIFollowSerializer(follow1).data] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_list_system_actor_followers(factories, superuser_api_client): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     factories["federation.Follow"](actor=library_actor) |  | ||||||
|     follow2 = factories["federation.Follow"](target=library_actor) |  | ||||||
| 
 |  | ||||||
|     url = reverse("api:v1:federation:libraries-followers") |  | ||||||
|     response = superuser_api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data["results"] == [serializers.APIFollowSerializer(follow2).data] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_list_libraries(factories, superuser_api_client): |  | ||||||
|     library1 = factories["federation.Library"]() |  | ||||||
|     library2 = factories["federation.Library"]() |  | ||||||
| 
 |  | ||||||
|     url = reverse("api:v1:federation:libraries-list") |  | ||||||
|     response = superuser_api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data["results"] == [ |  | ||||||
|         serializers.APILibrarySerializer(library1).data, |  | ||||||
|         serializers.APILibrarySerializer(library2).data, |  | ||||||
|     ] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_detail_library(factories, superuser_api_client): |  | ||||||
|     library = factories["federation.Library"]() |  | ||||||
| 
 |  | ||||||
|     url = reverse( |  | ||||||
|         "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)} |  | ||||||
|     ) |  | ||||||
|     response = superuser_api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data == serializers.APILibrarySerializer(library).data |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_patch_library(factories, superuser_api_client): |  | ||||||
|     library = factories["federation.Library"]() |  | ||||||
|     data = { |  | ||||||
|         "federation_enabled": not library.federation_enabled, |  | ||||||
|         "download_files": not library.download_files, |  | ||||||
|         "autoimport": not library.autoimport, |  | ||||||
|     } |  | ||||||
|     url = reverse( |  | ||||||
|         "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)} |  | ||||||
|     ) |  | ||||||
|     response = superuser_api_client.patch(url, data) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     library.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     for k, v in data.items(): |  | ||||||
|         assert getattr(library, k) == v |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_scan_library(factories, mocker, superuser_api_client): |  | ||||||
|     scan = mocker.patch( |  | ||||||
|         "funkwhale_api.federation.tasks.scan_library.delay", |  | ||||||
|         return_value=mocker.Mock(id="id"), |  | ||||||
|     ) |  | ||||||
|     library = factories["federation.Library"]() |  | ||||||
|     now = timezone.now() |  | ||||||
|     data = {"until": now} |  | ||||||
|     url = reverse( |  | ||||||
|         "api:v1:federation:libraries-scan", kwargs={"uuid": str(library.uuid)} |  | ||||||
|     ) |  | ||||||
|     response = superuser_api_client.post(url, data) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data == {"task": "id"} |  | ||||||
|     scan.assert_called_once_with(library_id=library.pk, until=now) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_list_library_tracks(factories, superuser_api_client): |  | ||||||
|     library = factories["federation.Library"]() |  | ||||||
|     lts = list( |  | ||||||
|         reversed( |  | ||||||
|             factories["federation.LibraryTrack"].create_batch(size=5, library=library) |  | ||||||
|         ) |  | ||||||
|     ) |  | ||||||
|     factories["federation.LibraryTrack"].create_batch(size=5) |  | ||||||
|     url = reverse("api:v1:federation:library-tracks-list") |  | ||||||
|     response = superuser_api_client.get(url, {"library": library.uuid}) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data == { |  | ||||||
|         "results": serializers.APILibraryTrackSerializer(lts, many=True).data, |  | ||||||
|         "count": 5, |  | ||||||
|         "previous": None, |  | ||||||
|         "next": None, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_update_follow_status(factories, superuser_api_client, mocker): |  | ||||||
|     patched_accept = mocker.patch("funkwhale_api.federation.activity.accept_follow") |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     follow = factories["federation.Follow"](target=library_actor) |  | ||||||
| 
 |  | ||||||
|     payload = {"follow": follow.pk, "approved": True} |  | ||||||
|     url = reverse("api:v1:federation:libraries-followers") |  | ||||||
|     response = superuser_api_client.patch(url, payload) |  | ||||||
|     follow.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert follow.approved is True |  | ||||||
|     patched_accept.assert_called_once_with(follow) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_filter_pending_follows(factories, superuser_api_client): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     factories["federation.Follow"](target=library_actor, approved=True) |  | ||||||
| 
 |  | ||||||
|     params = {"pending": True} |  | ||||||
|     url = reverse("api:v1:federation:libraries-followers") |  | ||||||
|     response = superuser_api_client.get(url, params) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert len(response.data["results"]) == 0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_library_track_action_import(factories, superuser_api_client, mocker): |  | ||||||
|     lt1 = factories["federation.LibraryTrack"]() |  | ||||||
|     lt2 = factories["federation.LibraryTrack"](library=lt1.library) |  | ||||||
|     lt3 = factories["federation.LibraryTrack"]() |  | ||||||
|     factories["federation.LibraryTrack"](library=lt3.library) |  | ||||||
|     mocked_run = mocker.patch("funkwhale_api.common.utils.on_commit") |  | ||||||
| 
 |  | ||||||
|     payload = { |  | ||||||
|         "objects": "all", |  | ||||||
|         "action": "import", |  | ||||||
|         "filters": {"library": lt1.library.uuid}, |  | ||||||
|     } |  | ||||||
|     url = reverse("api:v1:federation:library-tracks-action") |  | ||||||
|     response = superuser_api_client.post(url, payload, format="json") |  | ||||||
|     batch = superuser_api_client.user.imports.latest("id") |  | ||||||
|     expected = {"updated": 2, "action": "import", "result": {"batch": {"id": batch.pk}}} |  | ||||||
| 
 |  | ||||||
|     imported_lts = [lt1, lt2] |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert response.data == expected |  | ||||||
|     assert batch.jobs.count() == 2 |  | ||||||
|     for i, job in enumerate(batch.jobs.all()): |  | ||||||
|         assert job.library_track == imported_lts[i] |  | ||||||
|     mocked_run.assert_called_once_with( |  | ||||||
|         music_tasks.import_batch_run.delay, import_batch_id=batch.pk |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_local_actor_detail(factories, api_client): | def test_local_actor_detail(factories, api_client): | ||||||
|     user = factories["users.User"](with_actor=True) |     user = factories["users.User"](with_actor=True) | ||||||
|     url = reverse( |     url = reverse( | ||||||
|  | @ -435,6 +83,34 @@ def test_local_actor_detail(factories, api_client): | ||||||
|     assert response.data == serializer.data |     assert response.data == serializer.data | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def test_local_actor_inbox_post_requires_auth(factories, api_client): | ||||||
|  |     user = factories["users.User"](with_actor=True) | ||||||
|  |     url = reverse( | ||||||
|  |         "federation:actors-inbox", | ||||||
|  |         kwargs={"preferred_username": user.actor.preferred_username}, | ||||||
|  |     ) | ||||||
|  |     response = api_client.post(url, {"hello": "world"}) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 403 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_actor): | ||||||
|  |     patched_receive = mocker.patch("funkwhale_api.federation.activity.receive") | ||||||
|  |     user = factories["users.User"](with_actor=True) | ||||||
|  |     url = reverse( | ||||||
|  |         "federation:actors-inbox", | ||||||
|  |         kwargs={"preferred_username": user.actor.preferred_username}, | ||||||
|  |     ) | ||||||
|  |     response = api_client.post(url, {"hello": "world"}, format="json") | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     patched_receive.assert_called_once_with( | ||||||
|  |         activity={"hello": "world"}, | ||||||
|  |         on_behalf_of=authenticated_actor, | ||||||
|  |         recipient=user.actor, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def test_wellknown_webfinger_local(factories, api_client, settings, mocker): | def test_wellknown_webfinger_local(factories, api_client, settings, mocker): | ||||||
|     user = factories["users.User"](with_actor=True) |     user = factories["users.User"](with_actor=True) | ||||||
|     url = reverse("federation:well-known-webfinger") |     url = reverse("federation:well-known-webfinger") | ||||||
|  | @ -448,3 +124,60 @@ def test_wellknown_webfinger_local(factories, api_client, settings, mocker): | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response["Content-Type"] == "application/jrd+json" |     assert response["Content-Type"] == "application/jrd+json" | ||||||
|     assert response.data == serializer.data |     assert response.data == serializer.data | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"]) | ||||||
|  | def test_music_library_retrieve(factories, api_client, privacy_level): | ||||||
|  |     library = factories["music.Library"](privacy_level=privacy_level) | ||||||
|  |     expected = serializers.LibrarySerializer(library).data | ||||||
|  | 
 | ||||||
|  |     url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) | ||||||
|  |     response = api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     assert response.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_music_library_retrieve_page_public(factories, api_client): | ||||||
|  |     library = factories["music.Library"](privacy_level="everyone") | ||||||
|  |     tf = factories["music.TrackFile"](library=library) | ||||||
|  |     id = library.get_federation_id() | ||||||
|  |     expected = serializers.CollectionPageSerializer( | ||||||
|  |         { | ||||||
|  |             "id": id, | ||||||
|  |             "item_serializer": serializers.AudioSerializer, | ||||||
|  |             "actor": library.actor, | ||||||
|  |             "page": Paginator([tf], 1).page(1), | ||||||
|  |             "name": library.name, | ||||||
|  |             "summary": library.description, | ||||||
|  |         } | ||||||
|  |     ).data | ||||||
|  | 
 | ||||||
|  |     url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) | ||||||
|  |     response = api_client.get(url, {"page": 1}) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     assert response.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize("privacy_level", ["me", "instance"]) | ||||||
|  | def test_music_library_retrieve_page_private(factories, api_client, privacy_level): | ||||||
|  |     library = factories["music.Library"](privacy_level=privacy_level) | ||||||
|  |     url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) | ||||||
|  |     response = api_client.get(url, {"page": 1}) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 403 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize("approved,expected", [(True, 200), (False, 403)]) | ||||||
|  | def test_music_library_retrieve_page_follow( | ||||||
|  |     factories, api_client, authenticated_actor, approved, expected | ||||||
|  | ): | ||||||
|  |     library = factories["music.Library"](privacy_level="me") | ||||||
|  |     factories["federation.LibraryFollow"]( | ||||||
|  |         actor=authenticated_actor, target=library, approved=approved | ||||||
|  |     ) | ||||||
|  |     url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) | ||||||
|  |     response = api_client.get(url, {"page": 1}) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == expected | ||||||
|  |  | ||||||
|  | @ -17,6 +17,7 @@ def test_permissions(assert_user_permission, view, permissions, operator): | ||||||
|     assert_user_permission(view, permissions, operator) |     assert_user_permission(view, permissions, operator) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.skip(reason="Refactoring in progress") | ||||||
| def test_track_file_view(factories, superuser_api_client): | def test_track_file_view(factories, superuser_api_client): | ||||||
|     tfs = factories["music.TrackFile"].create_batch(size=5) |     tfs = factories["music.TrackFile"].create_batch(size=5) | ||||||
|     qs = tfs[0].__class__.objects.order_by("-creation_date") |     qs = tfs[0].__class__.objects.order_by("-creation_date") | ||||||
|  |  | ||||||
|  | @ -1,3 +1,7 @@ | ||||||
|  | from funkwhale_api.music import serializers | ||||||
|  | from funkwhale_api.music import signals | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def test_get_track_activity_url_mbid(factories): | def test_get_track_activity_url_mbid(factories): | ||||||
|     track = factories["music.Track"]() |     track = factories["music.Track"]() | ||||||
|     expected = "https://musicbrainz.org/recording/{}".format(track.mbid) |     expected = "https://musicbrainz.org/recording/{}".format(track.mbid) | ||||||
|  | @ -8,3 +12,27 @@ def test_get_track_activity_url_no_mbid(settings, factories): | ||||||
|     track = factories["music.Track"](mbid=None) |     track = factories["music.Track"](mbid=None) | ||||||
|     expected = settings.FUNKWHALE_URL + "/tracks/{}".format(track.pk) |     expected = settings.FUNKWHALE_URL + "/tracks/{}".format(track.pk) | ||||||
|     assert track.get_activity_url() == expected |     assert track.get_activity_url() == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_track_file_import_status_updated_broadcast(factories, mocker): | ||||||
|  |     group_send = mocker.patch("funkwhale_api.common.channels.group_send") | ||||||
|  |     user = factories["users.User"]() | ||||||
|  |     tf = factories["music.TrackFile"]( | ||||||
|  |         import_status="finished", library__actor__user=user | ||||||
|  |     ) | ||||||
|  |     signals.track_file_import_status_updated.send( | ||||||
|  |         sender=None, track_file=tf, old_status="pending", new_status="finished" | ||||||
|  |     ) | ||||||
|  |     group_send.assert_called_once_with( | ||||||
|  |         "user.{}.imports".format(user.pk), | ||||||
|  |         { | ||||||
|  |             "type": "event.send", | ||||||
|  |             "text": "", | ||||||
|  |             "data": { | ||||||
|  |                 "type": "import.status_updated", | ||||||
|  |                 "old_status": "pending", | ||||||
|  |                 "new_status": "finished", | ||||||
|  |                 "track_file": serializers.TrackFileForOwnerSerializer(tf).data, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | @ -1,217 +1,12 @@ | ||||||
| import json |  | ||||||
| import os | import os | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.music import models, tasks |  | ||||||
| 
 | 
 | ||||||
| DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 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=artists["get"]["adhesive_wombat"], |  | ||||||
|     ) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.musicbrainz.api.releases.get", |  | ||||||
|         return_value=albums["get"]["marsupial"], |  | ||||||
|     ) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.musicbrainz.api.recordings.get", |  | ||||||
|         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": video_url, "mbid": mbid}) |  | ||||||
| 
 |  | ||||||
|     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): |  | ||||||
|     mocker.patch("funkwhale_api.music.tasks.import_job_run") |  | ||||||
|     mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" |  | ||||||
|     video_id = "tPEE9ZwTmy0" |  | ||||||
|     url = reverse("api:v1:submit-single") |  | ||||||
|     superuser_client.post( |  | ||||||
|         url, |  | ||||||
|         { |  | ||||||
|             "import_url": "https://www.youtube.com/watch?v={0}".format(video_id), |  | ||||||
|             "mbid": mbid, |  | ||||||
|         }, |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     batch = models.ImportBatch.objects.latest("id") |  | ||||||
|     assert batch.jobs.count() == 1 |  | ||||||
|     assert batch.submitted_by == superuser_client.user |  | ||||||
|     assert batch.status == "pending" |  | ||||||
|     job = batch.jobs.first() |  | ||||||
|     assert str(job.mbid) == mbid |  | ||||||
|     assert job.status == "pending" |  | ||||||
|     assert job.source == "https://www.youtube.com/watch?v={0}".format(video_id) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_import_whole_album(artists, albums, mocker, superuser_client): |  | ||||||
|     mocker.patch("funkwhale_api.music.tasks.import_job_run") |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.musicbrainz.api.artists.get", 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=albums["get_with_includes"]["hypnotize"], |  | ||||||
|     ) |  | ||||||
|     payload = { |  | ||||||
|         "releaseId": "47ae093f-1607-49a3-be11-a15d335ccc94", |  | ||||||
|         "tracks": [ |  | ||||||
|             { |  | ||||||
|                 "mbid": "1968a9d6-8d92-4051-8f76-674e157b6eed", |  | ||||||
|                 "source": "https://www.youtube.com/watch?v=1111111111", |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 "mbid": "2968a9d6-8d92-4051-8f76-674e157b6eed", |  | ||||||
|                 "source": "https://www.youtube.com/watch?v=2222222222", |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 "mbid": "3968a9d6-8d92-4051-8f76-674e157b6eed", |  | ||||||
|                 "source": "https://www.youtube.com/watch?v=3333333333", |  | ||||||
|             }, |  | ||||||
|         ], |  | ||||||
|     } |  | ||||||
|     url = reverse("api:v1:submit-album") |  | ||||||
|     superuser_client.post(url, json.dumps(payload), content_type="application/json") |  | ||||||
| 
 |  | ||||||
|     batch = models.ImportBatch.objects.latest("id") |  | ||||||
|     assert batch.jobs.count() == 3 |  | ||||||
|     assert batch.submitted_by == superuser_client.user |  | ||||||
|     assert batch.status == "pending" |  | ||||||
| 
 |  | ||||||
|     album = models.Album.objects.latest("id") |  | ||||||
|     assert str(album.mbid) == "47ae093f-1607-49a3-be11-a15d335ccc94" |  | ||||||
|     medium_data = albums["get_with_includes"]["hypnotize"]["release"]["medium-list"][0] |  | ||||||
|     assert int(medium_data["track-count"]) == album.tracks.all().count() |  | ||||||
| 
 |  | ||||||
|     for track in medium_data["track-list"]: |  | ||||||
|         instance = models.Track.objects.get(mbid=track["recording"]["id"]) |  | ||||||
|         assert instance.title == track["recording"]["title"] |  | ||||||
|         assert instance.position == int(track["position"]) |  | ||||||
|         assert instance.title == track["recording"]["title"] |  | ||||||
| 
 |  | ||||||
|     for row in payload["tracks"]: |  | ||||||
|         job = models.ImportJob.objects.get(mbid=row["mbid"]) |  | ||||||
|         assert str(job.mbid) == row["mbid"] |  | ||||||
|         assert job.status == "pending" |  | ||||||
|         assert job.source == row["source"] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_import_whole_artist(artists, albums, mocker, superuser_client): |  | ||||||
|     mocker.patch("funkwhale_api.music.tasks.import_job_run") |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.musicbrainz.api.artists.get", 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=albums["get_with_includes"]["hypnotize"], |  | ||||||
|     ) |  | ||||||
|     payload = { |  | ||||||
|         "artistId": "mbid", |  | ||||||
|         "albums": [ |  | ||||||
|             { |  | ||||||
|                 "releaseId": "47ae093f-1607-49a3-be11-a15d335ccc94", |  | ||||||
|                 "tracks": [ |  | ||||||
|                     { |  | ||||||
|                         "mbid": "1968a9d6-8d92-4051-8f76-674e157b6eed", |  | ||||||
|                         "source": "https://www.youtube.com/watch?v=1111111111", |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "mbid": "2968a9d6-8d92-4051-8f76-674e157b6eed", |  | ||||||
|                         "source": "https://www.youtube.com/watch?v=2222222222", |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "mbid": "3968a9d6-8d92-4051-8f76-674e157b6eed", |  | ||||||
|                         "source": "https://www.youtube.com/watch?v=3333333333", |  | ||||||
|                     }, |  | ||||||
|                 ], |  | ||||||
|             } |  | ||||||
|         ], |  | ||||||
|     } |  | ||||||
|     url = reverse("api:v1:submit-artist") |  | ||||||
|     superuser_client.post(url, json.dumps(payload), content_type="application/json") |  | ||||||
| 
 |  | ||||||
|     batch = models.ImportBatch.objects.latest("id") |  | ||||||
|     assert batch.jobs.count() == 3 |  | ||||||
|     assert batch.submitted_by == superuser_client.user |  | ||||||
|     assert batch.status == "pending" |  | ||||||
| 
 |  | ||||||
|     album = models.Album.objects.latest("id") |  | ||||||
|     assert str(album.mbid) == "47ae093f-1607-49a3-be11-a15d335ccc94" |  | ||||||
|     medium_data = albums["get_with_includes"]["hypnotize"]["release"]["medium-list"][0] |  | ||||||
|     assert int(medium_data["track-count"]) == album.tracks.all().count() |  | ||||||
| 
 |  | ||||||
|     for track in medium_data["track-list"]: |  | ||||||
|         instance = models.Track.objects.get(mbid=track["recording"]["id"]) |  | ||||||
|         assert instance.title == track["recording"]["title"] |  | ||||||
|         assert instance.position == int(track["position"]) |  | ||||||
|         assert instance.title == track["recording"]["title"] |  | ||||||
| 
 |  | ||||||
|     for row in payload["albums"][0]["tracks"]: |  | ||||||
|         job = models.ImportJob.objects.get(mbid=row["mbid"]) |  | ||||||
|         assert str(job.mbid) == row["mbid"] |  | ||||||
|         assert job.status == "pending" |  | ||||||
|         assert job.source == row["source"] |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_user_can_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 = superuser_api_client.user.imports.latest("id") |  | ||||||
| 
 |  | ||||||
|     assert batch.submitted_by == superuser_api_client.user |  | ||||||
|     assert batch.source == "api" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 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") |  | ||||||
|     batch = factories["music.ImportBatch"](submitted_by=superuser_api_client.user) |  | ||||||
|     url = reverse("api:v1:import-jobs-list") |  | ||||||
|     with open(path, "rb") as f: |  | ||||||
|         content = f.read() |  | ||||||
|         f.seek(0) |  | ||||||
|         response = superuser_api_client.post( |  | ||||||
|             url, {"batch": batch.pk, "audio_file": f, "source": "file://"} |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|     assert response.status_code == 201 |  | ||||||
| 
 |  | ||||||
|     job = batch.jobs.latest("id") |  | ||||||
| 
 |  | ||||||
|     assert job.status == "pending" |  | ||||||
|     assert job.source.startswith("file://") |  | ||||||
|     assert "test.ogg" in job.source |  | ||||||
|     assert job.audio_file.read() == content |  | ||||||
| 
 |  | ||||||
|     m.assert_called_once_with(tasks.import_job_run.delay, import_job_id=job.pk) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     "route,method", |     "route,method", | ||||||
|     [ |     [ | ||||||
|  | @ -234,9 +29,9 @@ def test_track_file_url_is_restricted_to_authenticated_users( | ||||||
|     api_client, factories, preferences |     api_client, factories, preferences | ||||||
| ): | ): | ||||||
|     preferences["common__api_authentication_required"] = True |     preferences["common__api_authentication_required"] = True | ||||||
|     f = factories["music.TrackFile"]() |     tf = factories["music.TrackFile"](library__privacy_level="instance") | ||||||
|     assert f.audio_file is not None |     assert tf.audio_file is not None | ||||||
|     url = f.path |     url = tf.track.listen_url | ||||||
|     response = api_client.get(url) |     response = api_client.get(url) | ||||||
|     assert response.status_code == 401 |     assert response.status_code == 401 | ||||||
| 
 | 
 | ||||||
|  | @ -244,11 +39,12 @@ def test_track_file_url_is_restricted_to_authenticated_users( | ||||||
| def test_track_file_url_is_accessible_to_authenticated_users( | def test_track_file_url_is_accessible_to_authenticated_users( | ||||||
|     logged_in_api_client, factories, preferences |     logged_in_api_client, factories, preferences | ||||||
| ): | ): | ||||||
|  |     actor = logged_in_api_client.user.create_actor() | ||||||
|     preferences["common__api_authentication_required"] = True |     preferences["common__api_authentication_required"] = True | ||||||
|     f = factories["music.TrackFile"]() |     tf = factories["music.TrackFile"](library__actor=actor) | ||||||
|     assert f.audio_file is not None |     assert tf.audio_file is not None | ||||||
|     url = f.path |     url = tf.track.listen_url | ||||||
|     response = logged_in_api_client.get(url) |     response = logged_in_api_client.get(url) | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response["X-Accel-Redirect"] == "/_protected{}".format(f.audio_file.url) |     assert response["X-Accel-Redirect"] == "/_protected{}".format(tf.audio_file.url) | ||||||
|  |  | ||||||
|  | @ -30,8 +30,10 @@ def test_fix_track_files_bitrate_length(factories, mocker): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_fix_track_files_size(factories, mocker): | def test_fix_track_files_size(factories, mocker): | ||||||
|     tf1 = factories["music.TrackFile"](size=1) |     tf1 = factories["music.TrackFile"]() | ||||||
|     tf2 = factories["music.TrackFile"](size=None) |     tf2 = factories["music.TrackFile"]() | ||||||
|  |     tf1.__class__.objects.filter(pk=tf1.pk).update(size=1) | ||||||
|  |     tf2.__class__.objects.filter(pk=tf2.pk).update(size=None) | ||||||
|     c = fix_track_files.Command() |     c = fix_track_files.Command() | ||||||
| 
 | 
 | ||||||
|     mocker.patch("funkwhale_api.music.models.TrackFile.get_file_size", return_value=2) |     mocker.patch("funkwhale_api.music.models.TrackFile.get_file_size", return_value=2) | ||||||
|  |  | ||||||
|  | @ -1,249 +1,15 @@ | ||||||
| import json |  | ||||||
| import os | import os | ||||||
| import pytest | import pytest | ||||||
| import uuid | import uuid | ||||||
| 
 | 
 | ||||||
| from django import forms | from django import forms | ||||||
| from django.urls import reverse |  | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import actors |  | ||||||
| from funkwhale_api.federation import serializers as federation_serializers |  | ||||||
| from funkwhale_api.music import importers | from funkwhale_api.music import importers | ||||||
| from funkwhale_api.music import models | from funkwhale_api.music import models | ||||||
| from funkwhale_api.music import tasks |  | ||||||
| 
 | 
 | ||||||
| DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_create_import_can_bind_to_request( |  | ||||||
|     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=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=albums["get_with_includes"]["hypnotize"], |  | ||||||
|     ) |  | ||||||
|     payload = { |  | ||||||
|         "releaseId": "47ae093f-1607-49a3-be11-a15d335ccc94", |  | ||||||
|         "importRequest": request.pk, |  | ||||||
|         "tracks": [ |  | ||||||
|             { |  | ||||||
|                 "mbid": "1968a9d6-8d92-4051-8f76-674e157b6eed", |  | ||||||
|                 "source": "https://www.youtube.com/watch?v=1111111111", |  | ||||||
|             } |  | ||||||
|         ], |  | ||||||
|     } |  | ||||||
|     url = reverse("api:v1:submit-album") |  | ||||||
|     superuser_api_client.post(url, json.dumps(payload), content_type="application/json") |  | ||||||
|     batch = request.import_batches.latest("id") |  | ||||||
| 
 |  | ||||||
|     assert batch.import_request == request |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_job_from_federation_no_musicbrainz(factories, mocker): |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.music.utils.get_audio_file_data", |  | ||||||
|         return_value={"bitrate": 24, "length": 666}, |  | ||||||
|     ) |  | ||||||
|     mocker.patch("funkwhale_api.music.models.TrackFile.get_file_size", return_value=42) |  | ||||||
|     lt = factories["federation.LibraryTrack"]( |  | ||||||
|         artist_name="Hello", |  | ||||||
|         album_title="World", |  | ||||||
|         title="Ping", |  | ||||||
|         metadata__length=42, |  | ||||||
|         metadata__bitrate=43, |  | ||||||
|         metadata__size=44, |  | ||||||
|     ) |  | ||||||
|     job = factories["music.ImportJob"](federation=True, library_track=lt) |  | ||||||
| 
 |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |  | ||||||
|     job.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     tf = job.track_file |  | ||||||
|     assert tf.mimetype == lt.audio_mimetype |  | ||||||
|     assert tf.duration == 42 |  | ||||||
|     assert tf.bitrate == 43 |  | ||||||
|     assert tf.size == 44 |  | ||||||
|     assert tf.library_track == job.library_track |  | ||||||
|     assert tf.track.title == "Ping" |  | ||||||
|     assert tf.track.artist.name == "Hello" |  | ||||||
|     assert tf.track.album.title == "World" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_job_from_federation_musicbrainz_recording(factories, mocker): |  | ||||||
|     t = factories["music.Track"]() |  | ||||||
|     track_from_api = mocker.patch( |  | ||||||
|         "funkwhale_api.music.models.Track.get_or_create_from_api", |  | ||||||
|         return_value=(t, True), |  | ||||||
|     ) |  | ||||||
|     lt = factories["federation.LibraryTrack"]( |  | ||||||
|         metadata__recording__musicbrainz=True, artist_name="Hello", album_title="World" |  | ||||||
|     ) |  | ||||||
|     job = factories["music.ImportJob"](federation=True, library_track=lt) |  | ||||||
| 
 |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |  | ||||||
|     job.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     tf = job.track_file |  | ||||||
|     assert tf.mimetype == lt.audio_mimetype |  | ||||||
|     assert tf.library_track == job.library_track |  | ||||||
|     assert tf.track == t |  | ||||||
|     track_from_api.assert_called_once_with( |  | ||||||
|         mbid=lt.metadata["recording"]["musicbrainz_id"] |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_job_from_federation_musicbrainz_release(factories, mocker): |  | ||||||
|     a = factories["music.Album"]() |  | ||||||
|     album_from_api = mocker.patch( |  | ||||||
|         "funkwhale_api.music.models.Album.get_or_create_from_api", |  | ||||||
|         return_value=(a, True), |  | ||||||
|     ) |  | ||||||
|     lt = factories["federation.LibraryTrack"]( |  | ||||||
|         metadata__release__musicbrainz=True, artist_name="Hello", title="Ping" |  | ||||||
|     ) |  | ||||||
|     job = factories["music.ImportJob"](federation=True, library_track=lt) |  | ||||||
| 
 |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |  | ||||||
|     job.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     tf = job.track_file |  | ||||||
|     assert tf.mimetype == lt.audio_mimetype |  | ||||||
|     assert tf.library_track == job.library_track |  | ||||||
|     assert tf.track.title == "Ping" |  | ||||||
|     assert tf.track.artist == a.artist |  | ||||||
|     assert tf.track.album == a |  | ||||||
| 
 |  | ||||||
|     album_from_api.assert_called_once_with( |  | ||||||
|         mbid=lt.metadata["release"]["musicbrainz_id"] |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_job_from_federation_musicbrainz_artist(factories, mocker): |  | ||||||
|     a = factories["music.Artist"]() |  | ||||||
|     artist_from_api = mocker.patch( |  | ||||||
|         "funkwhale_api.music.models.Artist.get_or_create_from_api", |  | ||||||
|         return_value=(a, True), |  | ||||||
|     ) |  | ||||||
|     lt = factories["federation.LibraryTrack"]( |  | ||||||
|         metadata__artist__musicbrainz=True, album_title="World", title="Ping" |  | ||||||
|     ) |  | ||||||
|     job = factories["music.ImportJob"](federation=True, library_track=lt) |  | ||||||
| 
 |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |  | ||||||
|     job.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     tf = job.track_file |  | ||||||
|     assert tf.mimetype == lt.audio_mimetype |  | ||||||
|     assert tf.library_track == job.library_track |  | ||||||
| 
 |  | ||||||
|     assert tf.track.title == "Ping" |  | ||||||
|     assert tf.track.artist == a |  | ||||||
|     assert tf.track.album.artist == a |  | ||||||
|     assert tf.track.album.title == "World" |  | ||||||
| 
 |  | ||||||
|     artist_from_api.assert_called_once_with( |  | ||||||
|         mbid=lt.metadata["artist"]["musicbrainz_id"] |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_job_run_triggers_notifies_followers(factories, mocker, tmpfile): |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.downloader.download", |  | ||||||
|         return_value={"audio_file_path": tmpfile.name}, |  | ||||||
|     ) |  | ||||||
|     mocked_notify = mocker.patch( |  | ||||||
|         "funkwhale_api.music.tasks.import_batch_notify_followers.delay" |  | ||||||
|     ) |  | ||||||
|     batch = factories["music.ImportBatch"]() |  | ||||||
|     job = factories["music.ImportJob"](finished=True, batch=batch) |  | ||||||
|     factories["music.Track"](mbid=job.mbid) |  | ||||||
| 
 |  | ||||||
|     batch.update_status() |  | ||||||
|     batch.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     assert batch.status == "finished" |  | ||||||
| 
 |  | ||||||
|     mocked_notify.assert_called_once_with(import_batch_id=batch.pk) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_batch_notifies_followers_skip_on_disabled_federation( |  | ||||||
|     preferences, factories, mocker |  | ||||||
| ): |  | ||||||
|     mocked_deliver = mocker.patch("funkwhale_api.federation.activity.deliver") |  | ||||||
|     batch = factories["music.ImportBatch"](finished=True) |  | ||||||
|     preferences["federation__enabled"] = False |  | ||||||
|     tasks.import_batch_notify_followers(import_batch_id=batch.pk) |  | ||||||
| 
 |  | ||||||
|     mocked_deliver.assert_not_called() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_batch_notifies_followers_skip_on_federation_import(factories, mocker): |  | ||||||
|     mocked_deliver = mocker.patch("funkwhale_api.federation.activity.deliver") |  | ||||||
|     batch = factories["music.ImportBatch"](finished=True, federation=True) |  | ||||||
|     tasks.import_batch_notify_followers(import_batch_id=batch.pk) |  | ||||||
| 
 |  | ||||||
|     mocked_deliver.assert_not_called() |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_batch_notifies_followers(factories, mocker): |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
| 
 |  | ||||||
|     f1 = factories["federation.Follow"](approved=True, target=library_actor) |  | ||||||
|     factories["federation.Follow"](approved=False, target=library_actor) |  | ||||||
|     factories["federation.Follow"]() |  | ||||||
| 
 |  | ||||||
|     mocked_deliver = mocker.patch("funkwhale_api.federation.activity.deliver") |  | ||||||
|     batch = factories["music.ImportBatch"]() |  | ||||||
|     job1 = factories["music.ImportJob"](finished=True, batch=batch) |  | ||||||
|     factories["music.ImportJob"](finished=True, federation=True, batch=batch) |  | ||||||
|     factories["music.ImportJob"](status="pending", batch=batch) |  | ||||||
| 
 |  | ||||||
|     batch.status = "finished" |  | ||||||
|     batch.save() |  | ||||||
|     tasks.import_batch_notify_followers(import_batch_id=batch.pk) |  | ||||||
| 
 |  | ||||||
|     # only f1 match the requirements to be notified |  | ||||||
|     # and only job1 is a non federated track with finished import |  | ||||||
|     expected = { |  | ||||||
|         "@context": federation_serializers.AP_CONTEXT, |  | ||||||
|         "actor": library_actor.url, |  | ||||||
|         "type": "Create", |  | ||||||
|         "id": batch.get_federation_url(), |  | ||||||
|         "to": [f1.actor.url], |  | ||||||
|         "object": federation_serializers.CollectionSerializer( |  | ||||||
|             { |  | ||||||
|                 "id": batch.get_federation_url(), |  | ||||||
|                 "items": [job1.track_file], |  | ||||||
|                 "actor": library_actor, |  | ||||||
|                 "item_serializer": federation_serializers.AudioSerializer, |  | ||||||
|             } |  | ||||||
|         ).data, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     mocked_deliver.assert_called_once_with( |  | ||||||
|         expected, on_behalf_of=library_actor, to=[f1.actor.url] |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test__do_import_in_place_mbid(factories, tmpfile): |  | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |  | ||||||
|     job = factories["music.ImportJob"](in_place=True, source="file://{}".format(path)) |  | ||||||
| 
 |  | ||||||
|     factories["music.Track"](mbid=job.mbid) |  | ||||||
|     tf = tasks._do_import(job, use_acoustid=False) |  | ||||||
| 
 |  | ||||||
|     assert bool(tf.audio_file) is False |  | ||||||
|     assert tf.source == "file://{}".format(path) |  | ||||||
|     assert tf.mimetype == "audio/ogg" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_importer_cleans(): | def test_importer_cleans(): | ||||||
|     importer = importers.Importer(models.Artist) |     importer = importers.Importer(models.Artist) | ||||||
|     with pytest.raises(forms.ValidationError): |     with pytest.raises(forms.ValidationError): | ||||||
|  |  | ||||||
|  | @ -2,6 +2,8 @@ import os | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| 
 | 
 | ||||||
|  | from django.utils import timezone | ||||||
|  | 
 | ||||||
| from funkwhale_api.music import importers, models, tasks | from funkwhale_api.music import importers, models, tasks | ||||||
| 
 | 
 | ||||||
| DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | ||||||
|  | @ -148,29 +150,6 @@ def test_import_track_with_different_artist_than_release(factories, mocker): | ||||||
|     assert track.artist == artist |     assert track.artist == artist | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_import_job_is_bound_to_track_file(factories, mocker): |  | ||||||
|     track = factories["music.Track"]() |  | ||||||
|     job = factories["music.ImportJob"](mbid=track.mbid) |  | ||||||
| 
 |  | ||||||
|     mocker.patch("funkwhale_api.music.models.TrackFile.download_file") |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |  | ||||||
|     job.refresh_from_db() |  | ||||||
|     assert job.track_file.track == track |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.mark.parametrize("status", ["pending", "errored", "finished"]) |  | ||||||
| def test_saving_job_updates_batch_status(status, factories, mocker): |  | ||||||
|     batch = factories["music.ImportBatch"]() |  | ||||||
| 
 |  | ||||||
|     assert batch.status == "pending" |  | ||||||
| 
 |  | ||||||
|     factories["music.ImportJob"](batch=batch, status=status) |  | ||||||
| 
 |  | ||||||
|     batch.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     assert batch.status == status |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     "extention,mimetype", [("ogg", "audio/ogg"), ("mp3", "audio/mpeg")] |     "extention,mimetype", [("ogg", "audio/ogg"), ("mp3", "audio/mpeg")] | ||||||
| ) | ) | ||||||
|  | @ -178,7 +157,7 @@ def test_audio_track_mime_type(extention, mimetype, factories): | ||||||
| 
 | 
 | ||||||
|     name = ".".join(["test", extention]) |     name = ".".join(["test", extention]) | ||||||
|     path = os.path.join(DATA_DIR, name) |     path = os.path.join(DATA_DIR, name) | ||||||
|     tf = factories["music.TrackFile"](audio_file__from_path=path) |     tf = factories["music.TrackFile"](audio_file__from_path=path, mimetype=None) | ||||||
| 
 | 
 | ||||||
|     assert tf.mimetype == mimetype |     assert tf.mimetype == mimetype | ||||||
| 
 | 
 | ||||||
|  | @ -199,14 +178,6 @@ def test_track_get_file_size(factories): | ||||||
|     assert tf.get_file_size() == 297745 |     assert tf.get_file_size() == 297745 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_track_get_file_size_federation(factories): |  | ||||||
|     tf = factories["music.TrackFile"]( |  | ||||||
|         federation=True, library_track__with_audio_file=True |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     assert tf.get_file_size() == tf.library_track.audio_file.size |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_track_get_file_size_in_place(factories): | def test_track_get_file_size_in_place(factories): | ||||||
|     name = "test.mp3" |     name = "test.mp3" | ||||||
|     path = os.path.join(DATA_DIR, name) |     path = os.path.join(DATA_DIR, name) | ||||||
|  | @ -221,3 +192,230 @@ def test_album_get_image_content(factories): | ||||||
|     album.refresh_from_db() |     album.refresh_from_db() | ||||||
| 
 | 
 | ||||||
|     assert album.cover.read() == b"test" |     assert album.cover.read() == b"test" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_library(factories): | ||||||
|  |     now = timezone.now() | ||||||
|  |     actor = factories["federation.Actor"]() | ||||||
|  |     library = factories["music.Library"]( | ||||||
|  |         name="Hello world", description="hello", actor=actor, privacy_level="instance" | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     assert library.creation_date >= now | ||||||
|  |     assert library.files.count() == 0 | ||||||
|  |     assert library.uuid is not None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_playable_by_correct_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     queryset = tf.library.files.playable_by(tf.library.actor) | ||||||
|  |     match = tf in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_playable_by_instance_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain) | ||||||
|  |     queryset = tf.library.files.playable_by(instance_actor) | ||||||
|  |     match = tf in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_playable_by_anonymous(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     queryset = tf.library.files.playable_by(None) | ||||||
|  |     match = tf in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_track_playable_by_correct_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"]() | ||||||
|  |     queryset = models.Track.objects.playable_by( | ||||||
|  |         tf.library.actor | ||||||
|  |     ).annotate_playable_by_actor(tf.library.actor) | ||||||
|  |     match = tf.track in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_track_playable_by_instance_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain) | ||||||
|  |     queryset = models.Track.objects.playable_by( | ||||||
|  |         instance_actor | ||||||
|  |     ).annotate_playable_by_actor(instance_actor) | ||||||
|  |     match = tf.track in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_track_playable_by_anonymous(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     queryset = models.Track.objects.playable_by(None).annotate_playable_by_actor(None) | ||||||
|  |     match = tf.track in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_album_playable_by_correct_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"]() | ||||||
|  | 
 | ||||||
|  |     queryset = models.Album.objects.playable_by( | ||||||
|  |         tf.library.actor | ||||||
|  |     ).annotate_playable_by_actor(tf.library.actor) | ||||||
|  |     match = tf.track.album in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_album_playable_by_instance_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain) | ||||||
|  |     queryset = models.Album.objects.playable_by( | ||||||
|  |         instance_actor | ||||||
|  |     ).annotate_playable_by_actor(instance_actor) | ||||||
|  |     match = tf.track.album in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_album_playable_by_anonymous(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     queryset = models.Album.objects.playable_by(None).annotate_playable_by_actor(None) | ||||||
|  |     match = tf.track.album in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_artist_playable_by_correct_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"]() | ||||||
|  | 
 | ||||||
|  |     queryset = models.Artist.objects.playable_by( | ||||||
|  |         tf.library.actor | ||||||
|  |     ).annotate_playable_by_actor(tf.library.actor) | ||||||
|  |     match = tf.track.artist in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_artist_playable_by_instance_actor(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain) | ||||||
|  |     queryset = models.Artist.objects.playable_by( | ||||||
|  |         instance_actor | ||||||
|  |     ).annotate_playable_by_actor(instance_actor) | ||||||
|  |     match = tf.track.artist in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)] | ||||||
|  | ) | ||||||
|  | def test_artist_playable_by_anonymous(privacy_level, expected, factories): | ||||||
|  |     tf = factories["music.TrackFile"](library__privacy_level=privacy_level) | ||||||
|  |     queryset = models.Artist.objects.playable_by(None).annotate_playable_by_actor(None) | ||||||
|  |     match = tf.track.artist in list(queryset) | ||||||
|  |     assert match is expected | ||||||
|  |     if expected: | ||||||
|  |         assert bool(queryset.first().is_playable_by_actor) is expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_track_file_listen_url(factories): | ||||||
|  |     tf = factories["music.TrackFile"]() | ||||||
|  |     expected = tf.track.listen_url + "?file={}".format(tf.uuid) | ||||||
|  | 
 | ||||||
|  |     assert tf.listen_url == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_library_schedule_scan(factories, now, mocker): | ||||||
|  |     on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") | ||||||
|  |     library = factories["music.Library"](files_count=5) | ||||||
|  | 
 | ||||||
|  |     scan = library.schedule_scan() | ||||||
|  | 
 | ||||||
|  |     assert scan.creation_date >= now | ||||||
|  |     assert scan.status == "pending" | ||||||
|  |     assert scan.library == library | ||||||
|  |     assert scan.total_files == 5 | ||||||
|  |     assert scan.processed_files == 0 | ||||||
|  |     assert scan.errored_files == 0 | ||||||
|  |     assert scan.modification_date is None | ||||||
|  | 
 | ||||||
|  |     on_commit.assert_called_once_with( | ||||||
|  |         tasks.start_library_scan.delay, library_scan_id=scan.pk | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_library_schedule_scan_too_recent(factories, now): | ||||||
|  |     scan = factories["music.LibraryScan"]() | ||||||
|  |     result = scan.library.schedule_scan() | ||||||
|  | 
 | ||||||
|  |     assert result is None | ||||||
|  |     assert scan.library.scans.count() == 1 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_get_audio_data(factories): | ||||||
|  |     tf = factories["music.TrackFile"]() | ||||||
|  | 
 | ||||||
|  |     result = tf.get_audio_data() | ||||||
|  | 
 | ||||||
|  |     assert result == {"duration": 229, "bitrate": 128000, "size": 3459481} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.skip(reason="Refactoring in progress") | ||||||
|  | def test_library_viewable_by(): | ||||||
|  |     assert False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_library_queryset_with_follows(factories): | ||||||
|  |     library1 = factories["music.Library"]() | ||||||
|  |     library2 = factories["music.Library"]() | ||||||
|  |     follow = factories["federation.LibraryFollow"](target=library2) | ||||||
|  |     qs = library1.__class__.objects.with_follows(follow.actor).order_by("pk") | ||||||
|  | 
 | ||||||
|  |     l1 = list(qs)[0] | ||||||
|  |     l2 = list(qs)[1] | ||||||
|  |     assert l1._follows == [] | ||||||
|  |     assert l2._follows == [follow] | ||||||
|  |  | ||||||
|  | @ -1,60 +0,0 @@ | ||||||
| from rest_framework.views import APIView |  | ||||||
| 
 |  | ||||||
| from funkwhale_api.federation import actors |  | ||||||
| from funkwhale_api.music import permissions |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_list_permission_no_protect(preferences, anonymous_user, api_request): |  | ||||||
|     preferences["common__api_authentication_required"] = False |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.Listen() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     assert permission.has_permission(request, view) is True |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_list_permission_protect_authenticated(factories, api_request, preferences): |  | ||||||
|     preferences["common__api_authentication_required"] = True |  | ||||||
|     user = factories["users.User"]() |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.Listen() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "user", user) |  | ||||||
|     assert permission.has_permission(request, view) is True |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_list_permission_protect_not_following_actor( |  | ||||||
|     factories, api_request, preferences |  | ||||||
| ): |  | ||||||
|     preferences["common__api_authentication_required"] = True |  | ||||||
|     actor = factories["federation.Actor"]() |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.Listen() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "actor", actor) |  | ||||||
|     assert permission.has_permission(request, view) is False |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_list_permission_protect_following_actor(factories, api_request, preferences): |  | ||||||
|     preferences["common__api_authentication_required"] = True |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     follow = factories["federation.Follow"](approved=True, target=library_actor) |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.Listen() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "actor", follow.actor) |  | ||||||
| 
 |  | ||||||
|     assert permission.has_permission(request, view) is True |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_list_permission_protect_following_actor_not_approved( |  | ||||||
|     factories, api_request, preferences |  | ||||||
| ): |  | ||||||
|     preferences["common__api_authentication_required"] = True |  | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |  | ||||||
|     follow = factories["federation.Follow"](approved=False, target=library_actor) |  | ||||||
|     view = APIView.as_view() |  | ||||||
|     permission = permissions.Listen() |  | ||||||
|     request = api_request.get("/") |  | ||||||
|     setattr(request, "actor", follow.actor) |  | ||||||
| 
 |  | ||||||
|     assert permission.has_permission(request, view) is False |  | ||||||
|  | @ -1,4 +1,6 @@ | ||||||
|  | from funkwhale_api.music import models | ||||||
| from funkwhale_api.music import serializers | from funkwhale_api.music import serializers | ||||||
|  | from funkwhale_api.music import tasks | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_artist_album_serializer(factories, to_api_date): | def test_artist_album_serializer(factories, to_api_date): | ||||||
|  | @ -12,6 +14,7 @@ def test_artist_album_serializer(factories, to_api_date): | ||||||
|         "artist": album.artist.id, |         "artist": album.artist.id, | ||||||
|         "creation_date": to_api_date(album.creation_date), |         "creation_date": to_api_date(album.creation_date), | ||||||
|         "tracks_count": 1, |         "tracks_count": 1, | ||||||
|  |         "is_playable": None, | ||||||
|         "cover": { |         "cover": { | ||||||
|             "original": album.cover.url, |             "original": album.cover.url, | ||||||
|             "square_crop": album.cover.crop["400x400"].url, |             "square_crop": album.cover.crop["400x400"].url, | ||||||
|  | @ -53,8 +56,9 @@ def test_album_track_serializer(factories, to_api_date): | ||||||
|         "mbid": str(track.mbid), |         "mbid": str(track.mbid), | ||||||
|         "title": track.title, |         "title": track.title, | ||||||
|         "position": track.position, |         "position": track.position, | ||||||
|  |         "is_playable": None, | ||||||
|         "creation_date": to_api_date(track.creation_date), |         "creation_date": to_api_date(track.creation_date), | ||||||
|         "files": [serializers.TrackFileSerializer(tf).data], |         "listen_url": track.listen_url, | ||||||
|     } |     } | ||||||
|     serializer = serializers.AlbumTrackSerializer(track) |     serializer = serializers.AlbumTrackSerializer(track) | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
|  | @ -64,20 +68,54 @@ def test_track_file_serializer(factories, to_api_date): | ||||||
|     tf = factories["music.TrackFile"]() |     tf = factories["music.TrackFile"]() | ||||||
| 
 | 
 | ||||||
|     expected = { |     expected = { | ||||||
|         "id": tf.id, |         "uuid": str(tf.uuid), | ||||||
|         "path": tf.path, |  | ||||||
|         "source": tf.source, |  | ||||||
|         "filename": tf.filename, |         "filename": tf.filename, | ||||||
|         "track": tf.track.pk, |         "track": serializers.TrackSerializer(tf.track).data, | ||||||
|         "duration": tf.duration, |         "duration": tf.duration, | ||||||
|         "mimetype": tf.mimetype, |         "mimetype": tf.mimetype, | ||||||
|         "bitrate": tf.bitrate, |         "bitrate": tf.bitrate, | ||||||
|         "size": tf.size, |         "size": tf.size, | ||||||
|  |         "library": serializers.LibraryForOwnerSerializer(tf.library).data, | ||||||
|  |         "creation_date": tf.creation_date.isoformat().split("+")[0] + "Z", | ||||||
|  |         "import_date": None, | ||||||
|  |         "import_status": "pending", | ||||||
|     } |     } | ||||||
|     serializer = serializers.TrackFileSerializer(tf) |     serializer = serializers.TrackFileSerializer(tf) | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def test_track_file_owner_serializer(factories, to_api_date): | ||||||
|  |     tf = factories["music.TrackFile"]( | ||||||
|  |         import_status="success", | ||||||
|  |         import_details={"hello": "world"}, | ||||||
|  |         import_metadata={"import": "metadata"}, | ||||||
|  |         import_reference="ref", | ||||||
|  |         metadata={"test": "metadata"}, | ||||||
|  |         source="upload://test", | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     expected = { | ||||||
|  |         "uuid": str(tf.uuid), | ||||||
|  |         "filename": tf.filename, | ||||||
|  |         "track": serializers.TrackSerializer(tf.track).data, | ||||||
|  |         "duration": tf.duration, | ||||||
|  |         "mimetype": tf.mimetype, | ||||||
|  |         "bitrate": tf.bitrate, | ||||||
|  |         "size": tf.size, | ||||||
|  |         "library": serializers.LibraryForOwnerSerializer(tf.library).data, | ||||||
|  |         "creation_date": tf.creation_date.isoformat().split("+")[0] + "Z", | ||||||
|  |         "metadata": {"test": "metadata"}, | ||||||
|  |         "import_metadata": {"import": "metadata"}, | ||||||
|  |         "import_date": None, | ||||||
|  |         "import_status": "success", | ||||||
|  |         "import_details": {"hello": "world"}, | ||||||
|  |         "source": "upload://test", | ||||||
|  |         "import_reference": "ref", | ||||||
|  |     } | ||||||
|  |     serializer = serializers.TrackFileForOwnerSerializer(tf) | ||||||
|  |     assert serializer.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def test_album_serializer(factories, to_api_date): | def test_album_serializer(factories, to_api_date): | ||||||
|     track1 = factories["music.Track"](position=2) |     track1 = factories["music.Track"](position=2) | ||||||
|     track2 = factories["music.Track"](position=1, album=track1.album) |     track2 = factories["music.Track"](position=1, album=track1.album) | ||||||
|  | @ -88,6 +126,7 @@ def test_album_serializer(factories, to_api_date): | ||||||
|         "title": album.title, |         "title": album.title, | ||||||
|         "artist": serializers.ArtistSimpleSerializer(album.artist).data, |         "artist": serializers.ArtistSimpleSerializer(album.artist).data, | ||||||
|         "creation_date": to_api_date(album.creation_date), |         "creation_date": to_api_date(album.creation_date), | ||||||
|  |         "is_playable": None, | ||||||
|         "cover": { |         "cover": { | ||||||
|             "original": album.cover.url, |             "original": album.cover.url, | ||||||
|             "square_crop": album.cover.crop["400x400"].url, |             "square_crop": album.cover.crop["400x400"].url, | ||||||
|  | @ -113,9 +152,94 @@ def test_track_serializer(factories, to_api_date): | ||||||
|         "mbid": str(track.mbid), |         "mbid": str(track.mbid), | ||||||
|         "title": track.title, |         "title": track.title, | ||||||
|         "position": track.position, |         "position": track.position, | ||||||
|  |         "is_playable": None, | ||||||
|         "creation_date": to_api_date(track.creation_date), |         "creation_date": to_api_date(track.creation_date), | ||||||
|         "lyrics": track.get_lyrics_url(), |         "lyrics": track.get_lyrics_url(), | ||||||
|         "files": [serializers.TrackFileSerializer(tf).data], |         "listen_url": track.listen_url, | ||||||
|     } |     } | ||||||
|     serializer = serializers.TrackSerializer(track) |     serializer = serializers.TrackSerializer(track) | ||||||
|     assert serializer.data == expected |     assert serializer.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_cannot_bind_file_to_a_not_owned_library(factories): | ||||||
|  |     user = factories["users.User"]() | ||||||
|  |     library = factories["music.Library"]() | ||||||
|  | 
 | ||||||
|  |     s = serializers.TrackFileForOwnerSerializer( | ||||||
|  |         data={"library": library.uuid, "source": "upload://test"}, | ||||||
|  |         context={"user": user}, | ||||||
|  |     ) | ||||||
|  |     assert s.is_valid() is False | ||||||
|  |     assert "library" in s.errors | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_can_create_file_in_own_library(factories, uploaded_audio_file): | ||||||
|  |     user = factories["users.User"]() | ||||||
|  |     library = factories["music.Library"](actor__user=user) | ||||||
|  |     s = serializers.TrackFileForOwnerSerializer( | ||||||
|  |         data={ | ||||||
|  |             "library": library.uuid, | ||||||
|  |             "source": "upload://test", | ||||||
|  |             "audio_file": uploaded_audio_file, | ||||||
|  |         }, | ||||||
|  |         context={"user": user}, | ||||||
|  |     ) | ||||||
|  |     assert s.is_valid(raise_exception=True) is True | ||||||
|  |     tf = s.save() | ||||||
|  | 
 | ||||||
|  |     assert tf.library == library | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_create_file_checks_for_user_quota( | ||||||
|  |     factories, preferences, uploaded_audio_file, mocker | ||||||
|  | ): | ||||||
|  |     mocker.patch( | ||||||
|  |         "funkwhale_api.users.models.User.get_quota_status", | ||||||
|  |         return_value={"remaining": 0}, | ||||||
|  |     ) | ||||||
|  |     user = factories["users.User"]() | ||||||
|  |     library = factories["music.Library"](actor__user=user) | ||||||
|  |     s = serializers.TrackFileForOwnerSerializer( | ||||||
|  |         data={ | ||||||
|  |             "library": library.uuid, | ||||||
|  |             "source": "upload://test", | ||||||
|  |             "audio_file": uploaded_audio_file, | ||||||
|  |         }, | ||||||
|  |         context={"user": user}, | ||||||
|  |     ) | ||||||
|  |     assert s.is_valid() is False | ||||||
|  |     assert s.errors["non_field_errors"] == ["upload_quota_reached"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_manage_track_file_action_delete(factories): | ||||||
|  |     tfs = factories["music.TrackFile"](size=5) | ||||||
|  |     s = serializers.TrackFileActionSerializer(queryset=None) | ||||||
|  | 
 | ||||||
|  |     s.handle_delete(tfs.__class__.objects.all()) | ||||||
|  | 
 | ||||||
|  |     assert tfs.__class__.objects.count() == 0 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_manage_track_file_action_relaunch_import(factories, mocker): | ||||||
|  |     m = mocker.patch("funkwhale_api.common.utils.on_commit") | ||||||
|  | 
 | ||||||
|  |     # this one is finished and should stay as is | ||||||
|  |     finished = factories["music.TrackFile"](import_status="finished") | ||||||
|  | 
 | ||||||
|  |     to_relaunch = [ | ||||||
|  |         factories["music.TrackFile"](import_status="pending"), | ||||||
|  |         factories["music.TrackFile"](import_status="skipped"), | ||||||
|  |         factories["music.TrackFile"](import_status="errored"), | ||||||
|  |     ] | ||||||
|  |     s = serializers.TrackFileActionSerializer(queryset=None) | ||||||
|  | 
 | ||||||
|  |     s.handle_relaunch_import(models.TrackFile.objects.all()) | ||||||
|  | 
 | ||||||
|  |     for obj in to_relaunch: | ||||||
|  |         obj.refresh_from_db() | ||||||
|  |         assert obj.import_status == "pending" | ||||||
|  |         m.assert_any_call(tasks.import_track_file.delay, track_file_id=obj.pk) | ||||||
|  | 
 | ||||||
|  |     finished.refresh_from_db() | ||||||
|  |     assert finished.import_status == "finished" | ||||||
|  |     assert m.call_count == 3 | ||||||
|  |  | ||||||
|  | @ -1,227 +1,212 @@ | ||||||
|  | import datetime | ||||||
| import os | import os | ||||||
| 
 |  | ||||||
| import pytest | import pytest | ||||||
|  | import uuid | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.music import tasks | from django.core.paginator import Paginator | ||||||
|  | 
 | ||||||
|  | from funkwhale_api.federation import serializers as federation_serializers | ||||||
|  | from funkwhale_api.music import signals, tasks | ||||||
| 
 | 
 | ||||||
| DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_set_acoustid_on_track_file(factories, mocker, preferences): | # DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files") | ||||||
|     preferences["providers_acoustid__api_key"] = "test" | 
 | ||||||
|     track_file = factories["music.TrackFile"](acoustid_track_id=None) | 
 | ||||||
|     id = "e475bf79-c1ce-4441-bed7-1e33f226c0a2" | def test_can_create_track_from_file_metadata_no_mbid(db, mocker): | ||||||
|     payload = { |     metadata = { | ||||||
|         "results": [ |         "artist": ["Test artist"], | ||||||
|  |         "album": ["Test album"], | ||||||
|  |         "title": ["Test track"], | ||||||
|  |         "TRACKNUMBER": ["4"], | ||||||
|  |         "date": ["2012-08-15"], | ||||||
|  |     } | ||||||
|  |     mocker.patch("mutagen.File", return_value=metadata) | ||||||
|  |     mocker.patch( | ||||||
|  |         "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" | ||||||
|  |     ) | ||||||
|  |     track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg")) | ||||||
|  | 
 | ||||||
|  |     assert track.title == metadata["title"][0] | ||||||
|  |     assert track.mbid is None | ||||||
|  |     assert track.position == 4 | ||||||
|  |     assert track.album.title == metadata["album"][0] | ||||||
|  |     assert track.album.mbid is None | ||||||
|  |     assert track.album.release_date == datetime.date(2012, 8, 15) | ||||||
|  |     assert track.artist.name == metadata["artist"][0] | ||||||
|  |     assert track.artist.mbid is None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_can_create_track_from_file_metadata_mbid(factories, mocker): | ||||||
|  |     album = factories["music.Album"]() | ||||||
|  |     artist = factories["music.Artist"]() | ||||||
|  |     mocker.patch( | ||||||
|  |         "funkwhale_api.music.models.Album.get_or_create_from_api", | ||||||
|  |         return_value=(album, True), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     album_data = { | ||||||
|  |         "release": { | ||||||
|  |             "id": album.mbid, | ||||||
|  |             "medium-list": [ | ||||||
|                 { |                 { | ||||||
|                 "id": id, |                     "track-list": [ | ||||||
|                 "recordings": [ |  | ||||||
|                         { |                         { | ||||||
|                         "artists": [ |                             "id": "03baca8b-855a-3c05-8f3d-d3235287d84d", | ||||||
|                             { |                             "position": "4", | ||||||
|                                 "id": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13", |                             "number": "4", | ||||||
|                                 "name": "Binärpilot", |                             "recording": { | ||||||
|  |                                 "id": "2109e376-132b-40ad-b993-2bb6812e19d4", | ||||||
|  |                                 "title": "Teen Age Riot", | ||||||
|  |                                 "artist-credit": [ | ||||||
|  |                                     {"artist": {"id": artist.mbid, "name": artist.name}} | ||||||
|  |                                 ], | ||||||
|  |                             }, | ||||||
|                         } |                         } | ||||||
|                     ], |                     ], | ||||||
|                         "duration": 268, |                     "track-count": 1, | ||||||
|                         "id": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", |  | ||||||
|                         "title": "Bend", |  | ||||||
|                 } |                 } | ||||||
|             ], |             ], | ||||||
|                 "score": 0.860825, |  | ||||||
|         } |         } | ||||||
|         ], |  | ||||||
|         "status": "ok", |  | ||||||
|     } |     } | ||||||
|     m = mocker.patch("acoustid.match", return_value=payload) |     mocker.patch("funkwhale_api.musicbrainz.api.releases.get", return_value=album_data) | ||||||
|     r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk) |     track_data = album_data["release"]["medium-list"][0]["track-list"][0] | ||||||
|     track_file.refresh_from_db() |     metadata = { | ||||||
| 
 |         "musicbrainz_albumid": [album.mbid], | ||||||
|     assert str(track_file.acoustid_track_id) == id |         "musicbrainz_trackid": [track_data["recording"]["id"]], | ||||||
|     assert r == id |  | ||||||
|     m.assert_called_once_with("test", track_file.audio_file.path, parse=False) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_set_acoustid_on_track_file_required_high_score(factories, mocker): |  | ||||||
|     track_file = factories["music.TrackFile"](acoustid_track_id=None) |  | ||||||
|     payload = {"results": [{"score": 0.79}], "status": "ok"} |  | ||||||
|     mocker.patch("acoustid.match", return_value=payload) |  | ||||||
|     tasks.set_acoustid_on_track_file(track_file_id=track_file.pk) |  | ||||||
|     track_file.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     assert track_file.acoustid_track_id is None |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_batch_run(factories, mocker): |  | ||||||
|     job = factories["music.ImportJob"]() |  | ||||||
|     mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") |  | ||||||
|     tasks.import_batch_run(import_batch_id=job.batch.pk) |  | ||||||
| 
 |  | ||||||
|     mocked_job_run.assert_called_once_with(import_job_id=job.pk) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| @pytest.mark.skip("Acoustid is disabled") |  | ||||||
| def test_import_job_can_run_with_file_and_acoustid( |  | ||||||
|     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" |  | ||||||
|     acoustid_payload = { |  | ||||||
|         "results": [ |  | ||||||
|             { |  | ||||||
|                 "id": "e475bf79-c1ce-4441-bed7-1e33f226c0a2", |  | ||||||
|                 "recordings": [{"duration": 268, "id": mbid}], |  | ||||||
|                 "score": 0.860825, |  | ||||||
|     } |     } | ||||||
|         ], |     mocker.patch("mutagen.File", return_value=metadata) | ||||||
|         "status": "ok", |     mocker.patch( | ||||||
|  |         "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" | ||||||
|  |     ) | ||||||
|  |     track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg")) | ||||||
|  | 
 | ||||||
|  |     assert track.title == track_data["recording"]["title"] | ||||||
|  |     assert track.mbid == track_data["recording"]["id"] | ||||||
|  |     assert track.position == 4 | ||||||
|  |     assert track.album == album | ||||||
|  |     assert track.artist == artist | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_track_file_import_mbid(now, factories, temp_signal): | ||||||
|  |     track = factories["music.Track"]() | ||||||
|  |     tf = factories["music.TrackFile"]( | ||||||
|  |         track=None, import_metadata={"track": {"mbid": track.mbid}} | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     with temp_signal(signals.track_file_import_status_updated) as handler: | ||||||
|  |         tasks.import_track_file(track_file_id=tf.pk) | ||||||
|  | 
 | ||||||
|  |     tf.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert tf.track == track | ||||||
|  |     assert tf.import_status == "finished" | ||||||
|  |     assert tf.import_date == now | ||||||
|  |     handler.assert_called_once_with( | ||||||
|  |         track_file=tf, | ||||||
|  |         old_status="pending", | ||||||
|  |         new_status="finished", | ||||||
|  |         sender=None, | ||||||
|  |         signal=signals.track_file_import_status_updated, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_track_file_import_get_audio_data(factories, mocker): | ||||||
|  |     mocker.patch( | ||||||
|  |         "funkwhale_api.music.models.TrackFile.get_audio_data", | ||||||
|  |         return_value={"size": 23, "duration": 42, "bitrate": 66}, | ||||||
|  |     ) | ||||||
|  |     track = factories["music.Track"]() | ||||||
|  |     tf = factories["music.TrackFile"]( | ||||||
|  |         track=None, import_metadata={"track": {"mbid": track.mbid}} | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     tasks.import_track_file(track_file_id=tf.pk) | ||||||
|  | 
 | ||||||
|  |     tf.refresh_from_db() | ||||||
|  |     assert tf.size == 23 | ||||||
|  |     assert tf.duration == 42 | ||||||
|  |     assert tf.bitrate == 66 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_track_file_import_skip_existing_track_in_own_library(factories, temp_signal): | ||||||
|  |     track = factories["music.Track"]() | ||||||
|  |     library = factories["music.Library"]() | ||||||
|  |     existing = factories["music.TrackFile"]( | ||||||
|  |         track=track, | ||||||
|  |         import_status="finished", | ||||||
|  |         library=library, | ||||||
|  |         import_metadata={"track": {"mbid": track.mbid}}, | ||||||
|  |     ) | ||||||
|  |     duplicate = factories["music.TrackFile"]( | ||||||
|  |         track=track, | ||||||
|  |         import_status="pending", | ||||||
|  |         library=library, | ||||||
|  |         import_metadata={"track": {"mbid": track.mbid}}, | ||||||
|  |     ) | ||||||
|  |     with temp_signal(signals.track_file_import_status_updated) as handler: | ||||||
|  |         tasks.import_track_file(track_file_id=duplicate.pk) | ||||||
|  | 
 | ||||||
|  |     duplicate.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert duplicate.import_status == "skipped" | ||||||
|  |     assert duplicate.import_details == { | ||||||
|  |         "code": "already_imported_in_owned_libraries", | ||||||
|  |         "duplicates": [str(existing.uuid)], | ||||||
|     } |     } | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.music.utils.get_audio_file_data", |  | ||||||
|         return_value={"bitrate": 42, "length": 43}, |  | ||||||
|     ) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.musicbrainz.api.artists.get", |  | ||||||
|         return_value=artists["get"]["adhesive_wombat"], |  | ||||||
|     ) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.musicbrainz.api.releases.get", |  | ||||||
|         return_value=albums["get"]["marsupial"], |  | ||||||
|     ) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.musicbrainz.api.recordings.search", |  | ||||||
|         return_value=tracks["search"]["8bitadventures"], |  | ||||||
|     ) |  | ||||||
|     mocker.patch("acoustid.match", return_value=acoustid_payload) |  | ||||||
| 
 | 
 | ||||||
|     job = factories["music.FileImportJob"](audio_file__path=path) |     handler.assert_called_once_with( | ||||||
|     f = job.audio_file |         track_file=duplicate, | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |         old_status="pending", | ||||||
|     job.refresh_from_db() |         new_status="skipped", | ||||||
| 
 |         sender=None, | ||||||
|     track_file = job.track_file |         signal=signals.track_file_import_status_updated, | ||||||
| 
 |  | ||||||
|     with open(path, "rb") as f: |  | ||||||
|         assert track_file.audio_file.read() == f.read() |  | ||||||
|     assert track_file.bitrate == 42 |  | ||||||
|     assert track_file.duration == 43 |  | ||||||
|     assert track_file.size == os.path.getsize(path) |  | ||||||
|     # audio file is deleted from import job once persisted to audio file |  | ||||||
|     assert not job.audio_file |  | ||||||
|     assert job.status == "finished" |  | ||||||
|     assert job.source == "file://" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_run_import_skipping_accoustid(factories, mocker): |  | ||||||
|     m = mocker.patch("funkwhale_api.music.tasks._do_import") |  | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |  | ||||||
|     job = factories["music.FileImportJob"](audio_file__path=path) |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk, use_acoustid=False) |  | ||||||
|     m.assert_called_once_with(job, use_acoustid=False) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test__do_import_skipping_accoustid(factories, mocker): |  | ||||||
|     t = factories["music.Track"]() |  | ||||||
|     m = mocker.patch( |  | ||||||
|         "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", |  | ||||||
|         return_value=t, |  | ||||||
|     ) |  | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |  | ||||||
|     job = factories["music.FileImportJob"](mbid=None, audio_file__path=path) |  | ||||||
|     p = job.audio_file.path |  | ||||||
|     tasks._do_import(job, use_acoustid=False) |  | ||||||
|     m.assert_called_once_with(p) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test__do_import_skipping_accoustid_if_no_key(factories, mocker, preferences): |  | ||||||
|     preferences["providers_acoustid__api_key"] = "" |  | ||||||
|     t = factories["music.Track"]() |  | ||||||
|     m = mocker.patch( |  | ||||||
|         "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", |  | ||||||
|         return_value=t, |  | ||||||
|     ) |  | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |  | ||||||
|     job = factories["music.FileImportJob"](mbid=None, audio_file__path=path) |  | ||||||
|     p = job.audio_file.path |  | ||||||
|     tasks._do_import(job, use_acoustid=False) |  | ||||||
|     m.assert_called_once_with(p) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test__do_import_replace_if_duplicate(factories, mocker): |  | ||||||
|     existing_file = factories["music.TrackFile"]() |  | ||||||
|     existing_track = existing_file.track |  | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", |  | ||||||
|         return_value=existing_track, |  | ||||||
|     ) |  | ||||||
|     job = factories["music.FileImportJob"]( |  | ||||||
|         replace_if_duplicate=True, audio_file__path=path |  | ||||||
|     ) |  | ||||||
|     tasks._do_import(job) |  | ||||||
|     with pytest.raises(existing_file.__class__.DoesNotExist): |  | ||||||
|         existing_file.refresh_from_db() |  | ||||||
|     assert existing_file.creation_date != job.track_file.creation_date |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_job_skip_if_already_exists(artists, albums, tracks, factories, mocker): |  | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |  | ||||||
|     mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" |  | ||||||
|     track_file = factories["music.TrackFile"](track__mbid=mbid) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", |  | ||||||
|         return_value=track_file.track, |  | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     job = factories["music.FileImportJob"](audio_file__path=path) |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |  | ||||||
|     job.refresh_from_db() |  | ||||||
| 
 | 
 | ||||||
|     assert job.track_file is None | def test_track_file_import_track_uuid(now, factories): | ||||||
|     # audio file is deleted from import job once persisted to audio file |     track = factories["music.Track"]() | ||||||
|     assert not job.audio_file |     tf = factories["music.TrackFile"]( | ||||||
|     assert job.status == "skipped" |         track=None, import_metadata={"track": {"uuid": track.uuid}} | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     tasks.import_track_file(track_file_id=tf.pk) | ||||||
|  | 
 | ||||||
|  |     tf.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert tf.track == track | ||||||
|  |     assert tf.import_status == "finished" | ||||||
|  |     assert tf.import_date == now | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_import_job_can_be_errored(factories, mocker, preferences): | def test_track_file_import_error(factories, now, temp_signal): | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |     tf = factories["music.TrackFile"](import_metadata={"track": {"uuid": uuid.uuid4()}}) | ||||||
|     mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" |     with temp_signal(signals.track_file_import_status_updated) as handler: | ||||||
|     factories["music.TrackFile"](track__mbid=mbid) |         tasks.import_track_file(track_file_id=tf.pk) | ||||||
|  |     tf.refresh_from_db() | ||||||
| 
 | 
 | ||||||
|     class MyException(Exception): |     assert tf.import_status == "errored" | ||||||
|         pass |     assert tf.import_date == now | ||||||
| 
 |     assert tf.import_details == {"error_code": "track_uuid_not_found"} | ||||||
|     mocker.patch("funkwhale_api.music.tasks._do_import", side_effect=MyException()) |     handler.assert_called_once_with( | ||||||
| 
 |         track_file=tf, | ||||||
|     job = factories["music.FileImportJob"](audio_file__path=path, track_file=None) |         old_status="pending", | ||||||
| 
 |         new_status="errored", | ||||||
|     with pytest.raises(MyException): |         sender=None, | ||||||
|         tasks.import_job_run(import_job_id=job.pk) |         signal=signals.track_file_import_status_updated, | ||||||
| 
 |     ) | ||||||
|     job.refresh_from_db() |  | ||||||
| 
 |  | ||||||
|     assert job.track_file is None |  | ||||||
|     assert job.status == "errored" |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test__do_import_calls_update_album_cover_if_no_cover(factories, mocker): | def test_track_file_import_updates_cover_if_no_cover(factories, mocker, now): | ||||||
|     path = os.path.join(DATA_DIR, "test.ogg") |     mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover") | ||||||
|     album = factories["music.Album"](cover="") |     album = factories["music.Album"](cover="") | ||||||
|     track = factories["music.Track"](album=album) |     track = factories["music.Track"](album=album) | ||||||
| 
 |     tf = factories["music.TrackFile"]( | ||||||
|     mocker.patch( |         track=None, import_metadata={"track": {"uuid": track.uuid}} | ||||||
|         "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", |  | ||||||
|         return_value=track, |  | ||||||
|     ) |     ) | ||||||
| 
 |     tasks.import_track_file(track_file_id=tf.pk) | ||||||
|     mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover") |     mocked_update.assert_called_once_with(album, tf) | ||||||
| 
 |  | ||||||
|     job = factories["music.FileImportJob"](audio_file__path=path, track_file=None) |  | ||||||
| 
 |  | ||||||
|     tasks.import_job_run(import_job_id=job.pk) |  | ||||||
| 
 |  | ||||||
|     mocked_update.assert_called_once_with(album, track.files.first()) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_update_album_cover_mbid(factories, mocker): | def test_update_album_cover_mbid(factories, mocker): | ||||||
|  | @ -263,3 +248,84 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m | ||||||
|     mocked_get.assert_called_once_with( |     mocked_get.assert_called_once_with( | ||||||
|         data={"mimetype": mimetype, "content": image_content} |         data={"mimetype": mimetype, "content": image_content} | ||||||
|     ) |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_scan_library_fetches_page_and_calls_scan_page(now, mocker, factories, r_mock): | ||||||
|  |     scan = factories["music.LibraryScan"]() | ||||||
|  |     collection_conf = { | ||||||
|  |         "actor": scan.library.actor, | ||||||
|  |         "id": scan.library.fid, | ||||||
|  |         "page_size": 10, | ||||||
|  |         "items": range(10), | ||||||
|  |     } | ||||||
|  |     collection = federation_serializers.PaginatedCollectionSerializer(collection_conf) | ||||||
|  |     scan_page = mocker.patch("funkwhale_api.music.tasks.scan_library_page.delay") | ||||||
|  |     r_mock.get(collection_conf["id"], json=collection.data) | ||||||
|  |     tasks.start_library_scan(library_scan_id=scan.pk) | ||||||
|  | 
 | ||||||
|  |     scan_page.assert_called_once_with( | ||||||
|  |         library_scan_id=scan.pk, page_url=collection.data["first"] | ||||||
|  |     ) | ||||||
|  |     scan.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert scan.status == "scanning" | ||||||
|  |     assert scan.total_files == len(collection_conf["items"]) | ||||||
|  |     assert scan.modification_date == now | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_scan_page_fetches_page_and_creates_tracks(now, mocker, factories, r_mock): | ||||||
|  |     scan_page = mocker.patch("funkwhale_api.music.tasks.scan_library_page.delay") | ||||||
|  |     import_tf = mocker.patch("funkwhale_api.music.tasks.import_track_file.delay") | ||||||
|  |     scan = factories["music.LibraryScan"](status="scanning", total_files=5) | ||||||
|  |     tfs = factories["music.TrackFile"].build_batch(size=5, library=scan.library) | ||||||
|  |     for i, tf in enumerate(tfs): | ||||||
|  |         tf.fid = "https://track.test/{}".format(i) | ||||||
|  | 
 | ||||||
|  |     page_conf = { | ||||||
|  |         "actor": scan.library.actor, | ||||||
|  |         "id": scan.library.fid, | ||||||
|  |         "page": Paginator(tfs, 3).page(1), | ||||||
|  |         "item_serializer": federation_serializers.AudioSerializer, | ||||||
|  |     } | ||||||
|  |     page = federation_serializers.CollectionPageSerializer(page_conf) | ||||||
|  |     r_mock.get(page.data["id"], json=page.data) | ||||||
|  | 
 | ||||||
|  |     tasks.scan_library_page(library_scan_id=scan.pk, page_url=page.data["id"]) | ||||||
|  | 
 | ||||||
|  |     scan.refresh_from_db() | ||||||
|  |     lts = list(scan.library.files.all().order_by("-creation_date")) | ||||||
|  | 
 | ||||||
|  |     assert len(lts) == 3 | ||||||
|  |     for tf in tfs[:3]: | ||||||
|  |         new_tf = scan.library.files.get(fid=tf.get_federation_id()) | ||||||
|  |         import_tf.assert_any_call(track_file_id=new_tf.pk) | ||||||
|  | 
 | ||||||
|  |     assert scan.status == "scanning" | ||||||
|  |     assert scan.processed_files == 3 | ||||||
|  |     assert scan.modification_date == now | ||||||
|  | 
 | ||||||
|  |     scan_page.assert_called_once_with( | ||||||
|  |         library_scan_id=scan.pk, page_url=page.data["next"] | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock): | ||||||
|  |     patched_scan = mocker.patch("funkwhale_api.music.tasks.scan_library_page.delay") | ||||||
|  |     scan = factories["music.LibraryScan"](status="scanning", total_files=5) | ||||||
|  |     tfs = factories["music.TrackFile"].build_batch(size=5, library=scan.library) | ||||||
|  |     page_conf = { | ||||||
|  |         "actor": scan.library.actor, | ||||||
|  |         "id": scan.library.fid, | ||||||
|  |         "page": Paginator(tfs, 3).page(1), | ||||||
|  |         "item_serializer": federation_serializers.AudioSerializer, | ||||||
|  |     } | ||||||
|  |     page = federation_serializers.CollectionPageSerializer(page_conf) | ||||||
|  |     data = page.data | ||||||
|  |     data["next"] = data["id"] | ||||||
|  |     r_mock.get(page.data["id"], json=data) | ||||||
|  | 
 | ||||||
|  |     tasks.scan_library_page(library_scan_id=scan.pk, page_url=data["id"]) | ||||||
|  |     patched_scan.assert_not_called() | ||||||
|  |     scan.refresh_from_db() | ||||||
|  | 
 | ||||||
|  |     assert scan.status == "finished" | ||||||
|  |  | ||||||
|  | @ -1,26 +1,17 @@ | ||||||
| import io | import io | ||||||
|  | import os | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import actors | from funkwhale_api.music import serializers, tasks, views | ||||||
| from funkwhale_api.music import serializers, views |  | ||||||
| 
 | 
 | ||||||
| 
 | DATA_DIR = os.path.dirname(os.path.abspath(__file__)) | ||||||
| @pytest.mark.parametrize( |  | ||||||
|     "view,permissions,operator", |  | ||||||
|     [ |  | ||||||
|         (views.ImportBatchViewSet, ["library", "upload"], "or"), |  | ||||||
|         (views.ImportJobViewSet, ["library", "upload"], "or"), |  | ||||||
|     ], |  | ||||||
| ) |  | ||||||
| def test_permissions(assert_user_permission, view, permissions, operator): |  | ||||||
|     assert_user_permission(view, permissions, operator) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_artist_list_serializer(api_request, factories, logged_in_api_client): | def test_artist_list_serializer(api_request, factories, logged_in_api_client): | ||||||
|     track = factories["music.Track"]() |     track = factories["music.TrackFile"](library__privacy_level="everyone").track | ||||||
|     artist = track.artist |     artist = track.artist | ||||||
|     request = api_request.get("/") |     request = api_request.get("/") | ||||||
|     qs = artist.__class__.objects.with_albums() |     qs = artist.__class__.objects.with_albums() | ||||||
|  | @ -36,7 +27,7 @@ def test_artist_list_serializer(api_request, factories, logged_in_api_client): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_album_list_serializer(api_request, factories, logged_in_api_client): | def test_album_list_serializer(api_request, factories, logged_in_api_client): | ||||||
|     track = factories["music.Track"]() |     track = factories["music.TrackFile"](library__privacy_level="everyone").track | ||||||
|     album = track.album |     album = track.album | ||||||
|     request = api_request.get("/") |     request = api_request.get("/") | ||||||
|     qs = album.__class__.objects.all() |     qs = album.__class__.objects.all() | ||||||
|  | @ -44,21 +35,24 @@ def test_album_list_serializer(api_request, factories, logged_in_api_client): | ||||||
|         qs, many=True, context={"request": request} |         qs, many=True, context={"request": request} | ||||||
|     ) |     ) | ||||||
|     expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} |     expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} | ||||||
|  |     expected["results"][0]["is_playable"] = True | ||||||
|  |     expected["results"][0]["tracks"][0]["is_playable"] = True | ||||||
|     url = reverse("api:v1:albums-list") |     url = reverse("api:v1:albums-list") | ||||||
|     response = logged_in_api_client.get(url) |     response = logged_in_api_client.get(url) | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response.data == expected |     assert response.data["results"][0] == expected["results"][0] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_track_list_serializer(api_request, factories, logged_in_api_client): | def test_track_list_serializer(api_request, factories, logged_in_api_client): | ||||||
|     track = factories["music.Track"]() |     track = factories["music.TrackFile"](library__privacy_level="everyone").track | ||||||
|     request = api_request.get("/") |     request = api_request.get("/") | ||||||
|     qs = track.__class__.objects.all() |     qs = track.__class__.objects.all() | ||||||
|     serializer = serializers.TrackSerializer( |     serializer = serializers.TrackSerializer( | ||||||
|         qs, many=True, context={"request": request} |         qs, many=True, context={"request": request} | ||||||
|     ) |     ) | ||||||
|     expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} |     expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} | ||||||
|  |     expected["results"][0]["is_playable"] = True | ||||||
|     url = reverse("api:v1:tracks-list") |     url = reverse("api:v1:tracks-list") | ||||||
|     response = logged_in_api_client.get(url) |     response = logged_in_api_client.get(url) | ||||||
| 
 | 
 | ||||||
|  | @ -67,13 +61,15 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) | @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) | ||||||
| def test_artist_view_filter_listenable(param, expected, factories, api_request): | def test_artist_view_filter_playable(param, expected, factories, api_request): | ||||||
|     artists = { |     artists = { | ||||||
|         "empty": factories["music.Artist"](), |         "empty": factories["music.Artist"](), | ||||||
|         "full": factories["music.TrackFile"]().track.artist, |         "full": factories["music.TrackFile"]( | ||||||
|  |             library__privacy_level="everyone" | ||||||
|  |         ).track.artist, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     request = api_request.get("/", {"listenable": param}) |     request = api_request.get("/", {"playable": param}) | ||||||
|     view = views.ArtistViewSet() |     view = views.ArtistViewSet() | ||||||
|     view.action_map = {"get": "list"} |     view.action_map = {"get": "list"} | ||||||
|     expected = [artists[expected]] |     expected = [artists[expected]] | ||||||
|  | @ -84,13 +80,15 @@ def test_artist_view_filter_listenable(param, expected, factories, api_request): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) | @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) | ||||||
| def test_album_view_filter_listenable(param, expected, factories, api_request): | def test_album_view_filter_playable(param, expected, factories, api_request): | ||||||
|     artists = { |     artists = { | ||||||
|         "empty": factories["music.Album"](), |         "empty": factories["music.Album"](), | ||||||
|         "full": factories["music.TrackFile"]().track.album, |         "full": factories["music.TrackFile"]( | ||||||
|  |             library__privacy_level="everyone" | ||||||
|  |         ).track.album, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     request = api_request.get("/", {"listenable": param}) |     request = api_request.get("/", {"playable": param}) | ||||||
|     view = views.AlbumViewSet() |     view = views.AlbumViewSet() | ||||||
|     view.action_map = {"get": "list"} |     view.action_map = {"get": "list"} | ||||||
|     expected = [artists[expected]] |     expected = [artists[expected]] | ||||||
|  | @ -101,16 +99,16 @@ def test_album_view_filter_listenable(param, expected, factories, api_request): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_can_serve_track_file_as_remote_library( | def test_can_serve_track_file_as_remote_library( | ||||||
|     factories, authenticated_actor, api_client, settings, preferences |     factories, authenticated_actor, logged_in_api_client, settings, preferences | ||||||
| ): | ): | ||||||
|     preferences["common__api_authentication_required"] = True |     preferences["common__api_authentication_required"] = True | ||||||
|     library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() |     track_file = factories["music.TrackFile"](library__privacy_level="everyone") | ||||||
|  |     library_actor = track_file.library.actor | ||||||
|     factories["federation.Follow"]( |     factories["federation.Follow"]( | ||||||
|         approved=True, actor=authenticated_actor, target=library_actor |         approved=True, actor=authenticated_actor, target=library_actor | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     track_file = factories["music.TrackFile"]() |     response = logged_in_api_client.get(track_file.track.listen_url) | ||||||
|     response = api_client.get(track_file.path) |  | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response["X-Accel-Redirect"] == "{}{}".format( |     assert response["X-Accel-Redirect"] == "{}{}".format( | ||||||
|  | @ -122,8 +120,8 @@ def test_can_serve_track_file_as_remote_library_deny_not_following( | ||||||
|     factories, authenticated_actor, settings, api_client, preferences |     factories, authenticated_actor, settings, api_client, preferences | ||||||
| ): | ): | ||||||
|     preferences["common__api_authentication_required"] = True |     preferences["common__api_authentication_required"] = True | ||||||
|     track_file = factories["music.TrackFile"]() |     track_file = factories["music.TrackFile"](library__privacy_level="everyone") | ||||||
|     response = api_client.get(track_file.path) |     response = api_client.get(track_file.track.listen_url) | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 403 |     assert response.status_code == 403 | ||||||
| 
 | 
 | ||||||
|  | @ -147,9 +145,11 @@ def test_serve_file_in_place( | ||||||
|     settings.MUSIC_DIRECTORY_PATH = "/app/music" |     settings.MUSIC_DIRECTORY_PATH = "/app/music" | ||||||
|     settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path |     settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path | ||||||
|     tf = factories["music.TrackFile"]( |     tf = factories["music.TrackFile"]( | ||||||
|         in_place=True, source="file:///app/music/hello/world.mp3" |         in_place=True, | ||||||
|  |         source="file:///app/music/hello/world.mp3", | ||||||
|  |         library__privacy_level="everyone", | ||||||
|     ) |     ) | ||||||
|     response = api_client.get(tf.path) |     response = api_client.get(tf.track.listen_url) | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response[headers[proxy]] == expected |     assert response[headers[proxy]] == expected | ||||||
|  | @ -198,9 +198,9 @@ def test_serve_file_media( | ||||||
|     settings.MUSIC_DIRECTORY_PATH = "/app/music" |     settings.MUSIC_DIRECTORY_PATH = "/app/music" | ||||||
|     settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path |     settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path | ||||||
| 
 | 
 | ||||||
|     tf = factories["music.TrackFile"]() |     tf = factories["music.TrackFile"](library__privacy_level="everyone") | ||||||
|     tf.__class__.objects.filter(pk=tf.pk).update(audio_file="tracks/hello/world.mp3") |     tf.__class__.objects.filter(pk=tf.pk).update(audio_file="tracks/hello/world.mp3") | ||||||
|     response = api_client.get(tf.path) |     response = api_client.get(tf.track.listen_url) | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response[headers[proxy]] == expected |     assert response[headers[proxy]] == expected | ||||||
|  | @ -208,146 +208,179 @@ def test_serve_file_media( | ||||||
| 
 | 
 | ||||||
| def test_can_proxy_remote_track(factories, settings, api_client, r_mock, preferences): | def test_can_proxy_remote_track(factories, settings, api_client, r_mock, preferences): | ||||||
|     preferences["common__api_authentication_required"] = False |     preferences["common__api_authentication_required"] = False | ||||||
|     track_file = factories["music.TrackFile"](federation=True) |     url = "https://file.test" | ||||||
|  |     track_file = factories["music.TrackFile"]( | ||||||
|  |         library__privacy_level="everyone", audio_file="", source=url | ||||||
|  |     ) | ||||||
| 
 | 
 | ||||||
|     r_mock.get(track_file.library_track.audio_url, body=io.BytesIO(b"test")) |     r_mock.get(url, body=io.BytesIO(b"test")) | ||||||
|     response = api_client.get(track_file.path) |     response = api_client.get(track_file.track.listen_url) | ||||||
|  |     track_file.refresh_from_db() | ||||||
| 
 | 
 | ||||||
|     library_track = track_file.library_track |  | ||||||
|     library_track.refresh_from_db() |  | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response["X-Accel-Redirect"] == "{}{}".format( |     assert response["X-Accel-Redirect"] == "{}{}".format( | ||||||
|         settings.PROTECT_FILES_PATH, library_track.audio_file.url |         settings.PROTECT_FILES_PATH, track_file.audio_file.url | ||||||
|     ) |     ) | ||||||
|     assert library_track.audio_file.read() == b"test" |     assert track_file.audio_file.read() == b"test" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_serve_updates_access_date(factories, settings, api_client, preferences): | def test_serve_updates_access_date(factories, settings, api_client, preferences): | ||||||
|     preferences["common__api_authentication_required"] = False |     preferences["common__api_authentication_required"] = False | ||||||
|     track_file = factories["music.TrackFile"]() |     track_file = factories["music.TrackFile"](library__privacy_level="everyone") | ||||||
|     now = timezone.now() |     now = timezone.now() | ||||||
|     assert track_file.accessed_date is None |     assert track_file.accessed_date is None | ||||||
| 
 | 
 | ||||||
|     response = api_client.get(track_file.path) |     response = api_client.get(track_file.track.listen_url) | ||||||
|     track_file.refresh_from_db() |     track_file.refresh_from_db() | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert track_file.accessed_date > now |     assert track_file.accessed_date > now | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_can_list_import_jobs(factories, superuser_api_client): | def test_listen_no_track(factories, logged_in_api_client): | ||||||
|     job = factories["music.ImportJob"]() |     url = reverse("api:v1:listen-detail", kwargs={"uuid": "noop"}) | ||||||
|     url = reverse("api:v1:import-jobs-list") |     response = logged_in_api_client.get(url) | ||||||
|     response = superuser_api_client.get(url) | 
 | ||||||
|  |     assert response.status_code == 404 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_listen_no_file(factories, logged_in_api_client): | ||||||
|  |     track = factories["music.Track"]() | ||||||
|  |     url = reverse("api:v1:listen-detail", kwargs={"uuid": track.uuid}) | ||||||
|  |     response = logged_in_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 404 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_listen_no_available_file(factories, logged_in_api_client): | ||||||
|  |     tf = factories["music.TrackFile"]() | ||||||
|  |     url = reverse("api:v1:listen-detail", kwargs={"uuid": tf.track.uuid}) | ||||||
|  |     response = logged_in_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 404 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_listen_correct_access(factories, logged_in_api_client): | ||||||
|  |     logged_in_api_client.user.create_actor() | ||||||
|  |     tf = factories["music.TrackFile"]( | ||||||
|  |         library__actor=logged_in_api_client.user.actor, library__privacy_level="me" | ||||||
|  |     ) | ||||||
|  |     url = reverse("api:v1:listen-detail", kwargs={"uuid": tf.track.uuid}) | ||||||
|  |     response = logged_in_api_client.get(url) | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response.data["results"][0]["id"] == job.pk |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_import_job_stats(factories, superuser_api_client): | def test_listen_explicit_file(factories, logged_in_api_client, mocker): | ||||||
|     factories["music.ImportJob"](status="pending") |     mocked_serve = mocker.spy(views, "handle_serve") | ||||||
|     factories["music.ImportJob"](status="errored") |     tf1 = factories["music.TrackFile"](library__privacy_level="everyone") | ||||||
|  |     tf2 = factories["music.TrackFile"]( | ||||||
|  |         library__privacy_level="everyone", track=tf1.track | ||||||
|  |     ) | ||||||
|  |     url = reverse("api:v1:listen-detail", kwargs={"uuid": tf2.track.uuid}) | ||||||
|  |     response = logged_in_api_client.get(url, {"file": tf2.uuid}) | ||||||
| 
 | 
 | ||||||
|     url = reverse("api:v1:import-jobs-stats") |  | ||||||
|     response = superuser_api_client.get(url) |  | ||||||
|     expected = {"errored": 1, "pending": 1, "finished": 0, "skipped": 0, "count": 2} |  | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response.data == expected |     mocked_serve.assert_called_once_with(tf2, user=logged_in_api_client.user) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_import_job_stats_filter(factories, superuser_api_client): | def test_user_can_create_library(factories, logged_in_api_client): | ||||||
|     job1 = factories["music.ImportJob"](status="pending") |     actor = logged_in_api_client.user.create_actor() | ||||||
|     factories["music.ImportJob"](status="errored") |     url = reverse("api:v1:libraries-list") | ||||||
|  | 
 | ||||||
|  |     response = logged_in_api_client.post( | ||||||
|  |         url, {"name": "hello", "description": "world", "privacy_level": "me"} | ||||||
|  |     ) | ||||||
|  |     library = actor.libraries.first() | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 201 | ||||||
|  | 
 | ||||||
|  |     assert library.actor == actor | ||||||
|  |     assert library.name == "hello" | ||||||
|  |     assert library.description == "world" | ||||||
|  |     assert library.privacy_level == "me" | ||||||
|  |     assert library.fid == library.get_federation_id() | ||||||
|  |     assert library.followers_url == library.fid + "/followers" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_can_list_their_library(factories, logged_in_api_client): | ||||||
|  |     actor = logged_in_api_client.user.create_actor() | ||||||
|  |     library = factories["music.Library"](actor=actor) | ||||||
|  |     factories["music.Library"]() | ||||||
|  | 
 | ||||||
|  |     url = reverse("api:v1:libraries-list") | ||||||
|  |     response = logged_in_api_client.get(url) | ||||||
| 
 | 
 | ||||||
|     url = reverse("api:v1:import-jobs-stats") |  | ||||||
|     response = superuser_api_client.get(url, {"batch": job1.batch.pk}) |  | ||||||
|     expected = {"errored": 0, "pending": 1, "finished": 0, "skipped": 0, "count": 1} |  | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response.data == expected |     assert response.data["count"] == 1 | ||||||
|  |     assert response.data["results"][0]["uuid"] == str(library.uuid) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_import_job_run_via_api(factories, superuser_api_client, mocker): | def test_user_cannot_delete_other_actors_library(factories, logged_in_api_client): | ||||||
|     run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") |     logged_in_api_client.user.create_actor() | ||||||
|     job1 = factories["music.ImportJob"](status="errored") |     library = factories["music.Library"](privacy_level="everyone") | ||||||
|     job2 = factories["music.ImportJob"](status="pending") |  | ||||||
| 
 | 
 | ||||||
|     url = reverse("api:v1:import-jobs-run") |     url = reverse("api:v1:libraries-detail", kwargs={"uuid": library.uuid}) | ||||||
|     response = superuser_api_client.post(url, {"jobs": [job2.pk, job1.pk]}) |     response = logged_in_api_client.delete(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 404 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_cannot_get_other_actors_files(factories, logged_in_api_client): | ||||||
|  |     logged_in_api_client.user.create_actor() | ||||||
|  |     track_file = factories["music.TrackFile"]() | ||||||
|  | 
 | ||||||
|  |     url = reverse("api:v1:trackfiles-detail", kwargs={"uuid": track_file.uuid}) | ||||||
|  |     response = logged_in_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 404 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_cannot_delete_other_actors_files(factories, logged_in_api_client): | ||||||
|  |     logged_in_api_client.user.create_actor() | ||||||
|  |     track_file = factories["music.TrackFile"]() | ||||||
|  | 
 | ||||||
|  |     url = reverse("api:v1:trackfiles-detail", kwargs={"uuid": track_file.uuid}) | ||||||
|  |     response = logged_in_api_client.delete(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 404 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_cannot_list_other_actors_files(factories, logged_in_api_client): | ||||||
|  |     logged_in_api_client.user.create_actor() | ||||||
|  |     factories["music.TrackFile"]() | ||||||
|  | 
 | ||||||
|  |     url = reverse("api:v1:trackfiles-list") | ||||||
|  |     response = logged_in_api_client.get(url) | ||||||
| 
 | 
 | ||||||
|     job1.refresh_from_db() |  | ||||||
|     job2.refresh_from_db() |  | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
|     assert response.data == {"jobs": [job1.pk, job2.pk]} |     assert response.data["count"] == 0 | ||||||
|     assert job1.status == "pending" |  | ||||||
|     assert job2.status == "pending" |  | ||||||
| 
 |  | ||||||
|     run.assert_any_call(import_job_id=job1.pk) |  | ||||||
|     run.assert_any_call(import_job_id=job2.pk) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_import_batch_run_via_api(factories, superuser_api_client, mocker): | def test_user_can_create_track_file( | ||||||
|     run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") |     logged_in_api_client, factories, mocker, audio_file | ||||||
|  | ): | ||||||
|  |     library = factories["music.Library"](actor__user=logged_in_api_client.user) | ||||||
|  |     url = reverse("api:v1:trackfiles-list") | ||||||
|  |     m = mocker.patch("funkwhale_api.common.utils.on_commit") | ||||||
| 
 | 
 | ||||||
|     batch = factories["music.ImportBatch"]() |     response = logged_in_api_client.post( | ||||||
|     job1 = factories["music.ImportJob"](batch=batch, status="errored") |         url, | ||||||
|     job2 = factories["music.ImportJob"](batch=batch, status="pending") |         { | ||||||
| 
 |             "audio_file": audio_file, | ||||||
|     url = reverse("api:v1:import-jobs-run") |             "source": "upload://test", | ||||||
|     response = superuser_api_client.post(url, {"batches": [batch.pk]}) |             "import_reference": "test", | ||||||
| 
 |             "library": library.uuid, | ||||||
|     job1.refresh_from_db() |         }, | ||||||
|     job2.refresh_from_db() |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert job1.status == "pending" |  | ||||||
|     assert job2.status == "pending" |  | ||||||
| 
 |  | ||||||
|     run.assert_any_call(import_job_id=job1.pk) |  | ||||||
|     run.assert_any_call(import_job_id=job2.pk) |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_batch_and_job_run_via_api(factories, superuser_api_client, mocker): |  | ||||||
|     run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") |  | ||||||
| 
 |  | ||||||
|     batch = factories["music.ImportBatch"]() |  | ||||||
|     job1 = factories["music.ImportJob"](batch=batch, status="errored") |  | ||||||
|     job2 = factories["music.ImportJob"](status="pending") |  | ||||||
| 
 |  | ||||||
|     url = reverse("api:v1:import-jobs-run") |  | ||||||
|     response = superuser_api_client.post( |  | ||||||
|         url, {"batches": [batch.pk], "jobs": [job2.pk]} |  | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     job1.refresh_from_db() |     assert response.status_code == 201 | ||||||
|     job2.refresh_from_db() |  | ||||||
|     assert response.status_code == 200 |  | ||||||
|     assert job1.status == "pending" |  | ||||||
|     assert job2.status == "pending" |  | ||||||
| 
 | 
 | ||||||
|     run.assert_any_call(import_job_id=job1.pk) |     tf = library.files.latest("id") | ||||||
|     run.assert_any_call(import_job_id=job2.pk) |  | ||||||
| 
 | 
 | ||||||
| 
 |     audio_file.seek(0) | ||||||
| def test_import_job_viewset_get_queryset_upload_filters_user( |     assert tf.audio_file.read() == audio_file.read() | ||||||
|     factories, logged_in_api_client |     assert tf.source == "upload://test" | ||||||
| ): |     assert tf.import_reference == "test" | ||||||
|     logged_in_api_client.user.permission_upload = True |     assert tf.track is None | ||||||
|     logged_in_api_client.user.save() |     m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk) | ||||||
| 
 |  | ||||||
|     factories["music.ImportJob"]() |  | ||||||
|     url = reverse("api:v1:import-jobs-list") |  | ||||||
|     response = logged_in_api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.data["count"] == 0 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_import_batch_viewset_get_queryset_upload_filters_user( |  | ||||||
|     factories, logged_in_api_client |  | ||||||
| ): |  | ||||||
|     logged_in_api_client.user.permission_upload = True |  | ||||||
|     logged_in_api_client.user.save() |  | ||||||
| 
 |  | ||||||
|     factories["music.ImportBatch"]() |  | ||||||
|     url = reverse("api:v1:import-batches-list") |  | ||||||
|     response = logged_in_api_client.get(url) |  | ||||||
| 
 |  | ||||||
|     assert response.data["count"] == 0 |  | ||||||
|  |  | ||||||
|  | @ -1,3 +1,7 @@ | ||||||
|  | import pytest | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.skip(reason="Refactoring in progress") | ||||||
| def test_can_bind_import_batch_to_request(factories): | def test_can_bind_import_batch_to_request(factories): | ||||||
|     request = factories["requests.ImportRequest"]() |     request = factories["requests.ImportRequest"]() | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -165,7 +165,7 @@ def test_stream(f, db, logged_in_api_client, factories, mocker): | ||||||
|     tf = factories["music.TrackFile"](track=track) |     tf = factories["music.TrackFile"](track=track) | ||||||
|     response = logged_in_api_client.get(url, {"f": f, "id": track.pk}) |     response = logged_in_api_client.get(url, {"f": f, "id": track.pk}) | ||||||
| 
 | 
 | ||||||
|     mocked_serve.assert_called_once_with(track_file=tf) |     mocked_serve.assert_called_once_with(track_file=tf, user=logged_in_api_client.user) | ||||||
|     assert response.status_code == 200 |     assert response.status_code == 200 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,91 +1,14 @@ | ||||||
| import datetime |  | ||||||
| import os | import os | ||||||
| 
 | 
 | ||||||
| import pytest | import pytest | ||||||
| from django.core.management import call_command | from django.core.management import call_command | ||||||
| from django.core.management.base import CommandError | from django.core.management.base import CommandError | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.providers.audiofile import tasks |  | ||||||
| from funkwhale_api.music.models import ImportJob | from funkwhale_api.music.models import ImportJob | ||||||
| 
 | 
 | ||||||
| DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files") | DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def test_can_create_track_from_file_metadata_no_mbid(db, mocker): |  | ||||||
|     metadata = { |  | ||||||
|         "artist": ["Test artist"], |  | ||||||
|         "album": ["Test album"], |  | ||||||
|         "title": ["Test track"], |  | ||||||
|         "TRACKNUMBER": ["4"], |  | ||||||
|         "date": ["2012-08-15"], |  | ||||||
|     } |  | ||||||
|     mocker.patch("mutagen.File", return_value=metadata) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" |  | ||||||
|     ) |  | ||||||
|     track = tasks.import_track_data_from_path(os.path.join(DATA_DIR, "dummy_file.ogg")) |  | ||||||
| 
 |  | ||||||
|     assert track.title == metadata["title"][0] |  | ||||||
|     assert track.mbid is None |  | ||||||
|     assert track.position == 4 |  | ||||||
|     assert track.album.title == metadata["album"][0] |  | ||||||
|     assert track.album.mbid is None |  | ||||||
|     assert track.album.release_date == datetime.date(2012, 8, 15) |  | ||||||
|     assert track.artist.name == metadata["artist"][0] |  | ||||||
|     assert track.artist.mbid is None |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_create_track_from_file_metadata_mbid(factories, mocker): |  | ||||||
|     album = factories["music.Album"]() |  | ||||||
|     artist = factories["music.Artist"]() |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.music.models.Album.get_or_create_from_api", |  | ||||||
|         return_value=(album, True), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     album_data = { |  | ||||||
|         "release": { |  | ||||||
|             "id": album.mbid, |  | ||||||
|             "medium-list": [ |  | ||||||
|                 { |  | ||||||
|                     "track-list": [ |  | ||||||
|                         { |  | ||||||
|                             "id": "03baca8b-855a-3c05-8f3d-d3235287d84d", |  | ||||||
|                             "position": "4", |  | ||||||
|                             "number": "4", |  | ||||||
|                             "recording": { |  | ||||||
|                                 "id": "2109e376-132b-40ad-b993-2bb6812e19d4", |  | ||||||
|                                 "title": "Teen Age Riot", |  | ||||||
|                                 "artist-credit": [ |  | ||||||
|                                     {"artist": {"id": artist.mbid, "name": artist.name}} |  | ||||||
|                                 ], |  | ||||||
|                             }, |  | ||||||
|                         } |  | ||||||
|                     ], |  | ||||||
|                     "track-count": 1, |  | ||||||
|                 } |  | ||||||
|             ], |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     mocker.patch("funkwhale_api.musicbrainz.api.releases.get", return_value=album_data) |  | ||||||
|     track_data = album_data["release"]["medium-list"][0]["track-list"][0] |  | ||||||
|     metadata = { |  | ||||||
|         "musicbrainz_albumid": [album.mbid], |  | ||||||
|         "musicbrainz_trackid": [track_data["recording"]["id"]], |  | ||||||
|     } |  | ||||||
|     mocker.patch("mutagen.File", return_value=metadata) |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" |  | ||||||
|     ) |  | ||||||
|     track = tasks.import_track_data_from_path(os.path.join(DATA_DIR, "dummy_file.ogg")) |  | ||||||
| 
 |  | ||||||
|     assert track.title == track_data["recording"]["title"] |  | ||||||
|     assert track.mbid == track_data["recording"]["id"] |  | ||||||
|     assert track.position == 4 |  | ||||||
|     assert track.album == album |  | ||||||
|     assert track.artist == artist |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_management_command_requires_a_valid_username(factories, mocker): | def test_management_command_requires_a_valid_username(factories, mocker): | ||||||
|     path = os.path.join(DATA_DIR, "dummy_file.ogg") |     path = os.path.join(DATA_DIR, "dummy_file.ogg") | ||||||
|     factories["users.User"](username="me") |     factories["users.User"](username="me") | ||||||
|  | @ -120,6 +43,7 @@ def test_import_with_multiple_argument(factories, mocker): | ||||||
|     mocked_filter.assert_called_once_with([path1, path2]) |     mocked_filter.assert_called_once_with([path1, path2]) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.skip("Refactoring in progress") | ||||||
| def test_import_with_replace_flag(factories, mocker): | def test_import_with_replace_flag(factories, mocker): | ||||||
|     factories["users.User"](username="me") |     factories["users.User"](username="me") | ||||||
|     path = os.path.join(DATA_DIR, "dummy_file.ogg") |     path = os.path.join(DATA_DIR, "dummy_file.ogg") | ||||||
|  | @ -133,6 +57,7 @@ def test_import_with_replace_flag(factories, mocker): | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.skip("Refactoring in progress") | ||||||
| def test_import_files_creates_a_batch_and_job(factories, mocker): | def test_import_files_creates_a_batch_and_job(factories, mocker): | ||||||
|     m = mocker.patch("funkwhale_api.music.tasks.import_job_run") |     m = mocker.patch("funkwhale_api.music.tasks.import_job_run") | ||||||
|     user = factories["users.User"](username="me") |     user = factories["users.User"](username="me") | ||||||
|  | @ -162,6 +87,7 @@ def test_import_files_skip_if_path_already_imported(factories, mocker): | ||||||
|     assert user.imports.count() == 0 |     assert user.imports.count() == 0 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.skip("Refactoring in progress") | ||||||
| def test_import_files_works_with_utf8_file_name(factories, mocker): | def test_import_files_works_with_utf8_file_name(factories, mocker): | ||||||
|     m = mocker.patch("funkwhale_api.music.tasks.import_job_run") |     m = mocker.patch("funkwhale_api.music.tasks.import_job_run") | ||||||
|     user = factories["users.User"](username="me") |     user = factories["users.User"](username="me") | ||||||
|  | @ -172,6 +98,7 @@ def test_import_files_works_with_utf8_file_name(factories, mocker): | ||||||
|     m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) |     m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @pytest.mark.skip("Refactoring in progress") | ||||||
| def test_import_files_in_place(factories, mocker, settings): | def test_import_files_in_place(factories, mocker, settings): | ||||||
|     settings.MUSIC_DIRECTORY_PATH = DATA_DIR |     settings.MUSIC_DIRECTORY_PATH = DATA_DIR | ||||||
|     m = mocker.patch("funkwhale_api.music.tasks.import_job_run") |     m = mocker.patch("funkwhale_api.music.tasks.import_job_run") | ||||||
|  |  | ||||||
|  | @ -1,96 +0,0 @@ | ||||||
| from collections import OrderedDict |  | ||||||
| 
 |  | ||||||
| from django.urls import reverse |  | ||||||
| 
 |  | ||||||
| from funkwhale_api.providers.youtube.client import client |  | ||||||
| 
 |  | ||||||
| from .data import youtube as api_data |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_get_search_results_from_youtube(mocker): |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.providers.youtube.client._do_search", |  | ||||||
|         return_value=api_data.search["8 bit adventure"], |  | ||||||
|     ) |  | ||||||
|     query = "8 bit adventure" |  | ||||||
|     results = client.search(query) |  | ||||||
|     assert results[0]["id"]["videoId"] == "0HxZn6CzOIo" |  | ||||||
|     assert results[0]["snippet"]["title"] == "AdhesiveWombat - 8 Bit Adventure" |  | ||||||
|     assert results[0]["full_url"] == "https://www.youtube.com/watch?v=0HxZn6CzOIo" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_get_search_results_from_funkwhale(preferences, mocker, api_client, db): |  | ||||||
|     preferences["common__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 = api_client.get(url, {"query": query}) |  | ||||||
|     # we should cast the youtube result to something more generic |  | ||||||
|     expected = { |  | ||||||
|         "id": "0HxZn6CzOIo", |  | ||||||
|         "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", |  | ||||||
|         "type": "youtube#video", |  | ||||||
|         "description": "Description", |  | ||||||
|         "channelId": "UCps63j3krzAG4OyXeEyuhFw", |  | ||||||
|         "title": "AdhesiveWombat - 8 Bit Adventure", |  | ||||||
|         "channelTitle": "AdhesiveWombat", |  | ||||||
|         "publishedAt": "2012-08-22T18:41:03.000Z", |  | ||||||
|         "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg", |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     assert response.data[0] == expected |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_send_multiple_queries_at_once(mocker): |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.providers.youtube.client._do_search", |  | ||||||
|         side_effect=[ |  | ||||||
|             api_data.search["8 bit adventure"], |  | ||||||
|             api_data.search["system of a down toxicity"], |  | ||||||
|         ], |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     queries = OrderedDict() |  | ||||||
|     queries["1"] = {"q": "8 bit adventure"} |  | ||||||
|     queries["2"] = {"q": "system of a down toxicity"} |  | ||||||
| 
 |  | ||||||
|     results = client.search_multiple(queries) |  | ||||||
| 
 |  | ||||||
|     assert results["1"][0]["id"]["videoId"] == "0HxZn6CzOIo" |  | ||||||
|     assert results["1"][0]["snippet"]["title"] == "AdhesiveWombat - 8 Bit Adventure" |  | ||||||
|     assert results["1"][0]["full_url"] == "https://www.youtube.com/watch?v=0HxZn6CzOIo" |  | ||||||
|     assert results["2"][0]["id"]["videoId"] == "BorYwGi2SJc" |  | ||||||
|     assert results["2"][0]["snippet"]["title"] == "System of a Down: Toxicity" |  | ||||||
|     assert results["2"][0]["full_url"] == "https://www.youtube.com/watch?v=BorYwGi2SJc" |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def test_can_send_multiple_queries_at_once_from_funwkhale( |  | ||||||
|     preferences, mocker, db, api_client |  | ||||||
| ): |  | ||||||
|     preferences["common__api_authentication_required"] = False |  | ||||||
|     mocker.patch( |  | ||||||
|         "funkwhale_api.providers.youtube.client._do_search", |  | ||||||
|         return_value=api_data.search["8 bit adventure"], |  | ||||||
|     ) |  | ||||||
|     queries = OrderedDict() |  | ||||||
|     queries["1"] = {"q": "8 bit adventure"} |  | ||||||
| 
 |  | ||||||
|     expected = { |  | ||||||
|         "id": "0HxZn6CzOIo", |  | ||||||
|         "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", |  | ||||||
|         "type": "youtube#video", |  | ||||||
|         "description": "Description", |  | ||||||
|         "channelId": "UCps63j3krzAG4OyXeEyuhFw", |  | ||||||
|         "title": "AdhesiveWombat - 8 Bit Adventure", |  | ||||||
|         "channelTitle": "AdhesiveWombat", |  | ||||||
|         "publishedAt": "2012-08-22T18:41:03.000Z", |  | ||||||
|         "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg", |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     url = reverse("api:v1:providers:youtube:searchs") |  | ||||||
|     response = api_client.post(url, queries, format="json") |  | ||||||
| 
 |  | ||||||
|     assert expected == response.data["1"][0] |  | ||||||
|  | @ -141,7 +141,7 @@ def test_creating_actor_from_user(factories, settings): | ||||||
|     assert actor.type == "Person" |     assert actor.type == "Person" | ||||||
|     assert actor.name == user.username |     assert actor.name == user.username | ||||||
|     assert actor.manually_approves_followers is False |     assert actor.manually_approves_followers is False | ||||||
|     assert actor.url == federation_utils.full_url( |     assert actor.fid == federation_utils.full_url( | ||||||
|         reverse( |         reverse( | ||||||
|             "federation:actors-detail", |             "federation:actors-detail", | ||||||
|             kwargs={"preferred_username": actor.preferred_username}, |             kwargs={"preferred_username": actor.preferred_username}, | ||||||
|  | @ -165,3 +165,46 @@ def test_creating_actor_from_user(factories, settings): | ||||||
|             kwargs={"preferred_username": actor.preferred_username}, |             kwargs={"preferred_username": actor.preferred_username}, | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_get_channels_groups(factories): | ||||||
|  |     user = factories["users.User"]() | ||||||
|  | 
 | ||||||
|  |     assert user.get_channels_groups() == ["user.{}.imports".format(user.pk)] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_quota_default_to_preference(factories, preferences): | ||||||
|  |     preferences["users__upload_quota"] = 42 | ||||||
|  | 
 | ||||||
|  |     user = factories["users.User"]() | ||||||
|  |     assert user.get_upload_quota() == 42 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_quota_set_on_user(factories, preferences): | ||||||
|  |     preferences["users__upload_quota"] = 42 | ||||||
|  | 
 | ||||||
|  |     user = factories["users.User"](upload_quota=66) | ||||||
|  |     assert user.get_upload_quota() == 66 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_user_get_quota_status(factories, preferences, mocker): | ||||||
|  |     user = factories["users.User"](upload_quota=66, with_actor=True) | ||||||
|  |     mocker.patch( | ||||||
|  |         "funkwhale_api.federation.models.Actor.get_current_usage", | ||||||
|  |         return_value={ | ||||||
|  |             "total": 10 * 1000 * 1000, | ||||||
|  |             "pending": 1 * 1000 * 1000, | ||||||
|  |             "skipped": 2 * 1000 * 1000, | ||||||
|  |             "errored": 3 * 1000 * 1000, | ||||||
|  |             "finished": 4 * 1000 * 1000, | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     assert user.get_quota_status() == { | ||||||
|  |         "max": 66, | ||||||
|  |         "remaining": 56, | ||||||
|  |         "current": 10, | ||||||
|  |         "pending": 1, | ||||||
|  |         "skipped": 2, | ||||||
|  |         "errored": 3, | ||||||
|  |         "finished": 4, | ||||||
|  |     } | ||||||
|  |  | ||||||
							
								
								
									
										3
									
								
								dev.yml
								
								
								
								
							
							
						
						
									
										3
									
								
								dev.yml
								
								
								
								
							|  | @ -8,9 +8,6 @@ services: | ||||||
|       - .env |       - .env | ||||||
|     environment: |     environment: | ||||||
|       - "HOST=0.0.0.0" |       - "HOST=0.0.0.0" | ||||||
|       - "VUE_PORT=${VUE_PORT-8080}" |  | ||||||
|     ports: |  | ||||||
|       - "${VUE_PORT_BINDING-8080:}${VUE_PORT-8080}" |  | ||||||
|     volumes: |     volumes: | ||||||
|       - "./front:/app" |       - "./front:/app" | ||||||
|       - "/app/node_modules" |       - "/app/node_modules" | ||||||
|  |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue
	
	 Eliot Berriot
						Eliot Berriot