Resolve "Per-user libraries" (use !368 instead)
This commit is contained in:
parent
b0ca181016
commit
2ea21994ee
|
@ -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)
|
||||||
try:
|
if self.context.get("local_recipients", False):
|
||||||
if o.local_track_file is not None:
|
matching = matching.local()
|
||||||
return "imported"
|
|
||||||
except music_models.TrackFile.DoesNotExist:
|
if not len(matching):
|
||||||
pass
|
raise serializers.ValidationError("No matching recipients found")
|
||||||
for job in o.import_jobs.all():
|
|
||||||
if job.status == "pending":
|
actors_by_fid = {a.fid: a for a in matching}
|
||||||
return "import_pending"
|
|
||||||
return "not_imported"
|
def match(recipients, actors):
|
||||||
|
for r in recipients:
|
||||||
|
if r == activity.PUBLIC_ADDRESS:
|
||||||
|
yield r
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
yield actors[r]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"to": list(match(to, actors_by_fid)),
|
||||||
|
"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:
|
||||||
raise serializers.ValidationError("Target not found")
|
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")
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
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()
|
||||||
|
|
||||||
|
conf = {
|
||||||
|
"id": lb.get_federation_id(),
|
||||||
|
"actor": lb.actor,
|
||||||
|
"name": lb.name,
|
||||||
|
"summary": lb.description,
|
||||||
|
"items": lb.files.order_by("-creation_date"),
|
||||||
|
"item_serializer": serializers.AudioSerializer,
|
||||||
|
}
|
||||||
page = request.GET.get("page")
|
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:
|
if page is None:
|
||||||
conf = {
|
serializer = serializers.LibrarySerializer(lb)
|
||||||
"id": utils.full_url(reverse("federation:music:files-list")),
|
|
||||||
"page_size": preferences.get("federation__collection_page_size"),
|
|
||||||
"items": qs,
|
|
||||||
"item_serializer": serializers.AudioSerializer,
|
|
||||||
"actor": library,
|
|
||||||
}
|
|
||||||
serializer = serializers.PaginatedCollectionSerializer(conf)
|
|
||||||
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)
|
import_date = models.DateTimeField(null=True, blank=True)
|
||||||
self.duration = data.get("duration", None)
|
# optionnal metadata provided during import
|
||||||
self.audio_file.save(
|
import_metadata = JSONField(
|
||||||
os.path.basename(data["audio_file_path"]),
|
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder
|
||||||
File(open(data["audio_file_path"], "rb")),
|
)
|
||||||
|
# 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,
|
||||||
)
|
)
|
||||||
shutil.rmtree(tmp_dir)
|
with remote_response as r:
|
||||||
return self.audio_file
|
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
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
library_track = None
|
|
||||||
if library_track and not audio_file:
|
|
||||||
if not library_track.audio_file:
|
|
||||||
# we need to populate from cache
|
|
||||||
with transaction.atomic():
|
|
||||||
# why the transaction/select_for_update?
|
|
||||||
# this is because browsers may send multiple requests
|
|
||||||
# in a short time range, for partial content,
|
|
||||||
# thus resulting in multiple downloads from the remote
|
|
||||||
qs = LibraryTrack.objects.select_for_update()
|
|
||||||
library_track = qs.get(pk=library_track.pk)
|
|
||||||
library_track.download_audio()
|
|
||||||
track_file.library_track = library_track
|
|
||||||
track_file.set_audio_data()
|
|
||||||
track_file.save(update_fields=["bitrate", "duration", "size"])
|
|
||||||
|
|
||||||
audio_file = library_track.audio_file
|
elif f.source and (
|
||||||
file_path = get_file_path(audio_file)
|
f.source.startswith("http://") or f.source.startswith("https://")
|
||||||
mt = library_track.audio_mimetype
|
):
|
||||||
elif audio_file:
|
# we need to populate from cache
|
||||||
file_path = get_file_path(audio_file)
|
with transaction.atomic():
|
||||||
|
# why the transaction/select_for_update?
|
||||||
|
# this is because browsers may send multiple requests
|
||||||
|
# in a short time range, for partial content,
|
||||||
|
# thus resulting in multiple downloads from the remote
|
||||||
|
qs = f.__class__.objects.select_for_update()
|
||||||
|
f = qs.get(pk=f.pk)
|
||||||
|
f.download_audio_from_remote(user=user)
|
||||||
|
data = f.get_audio_data()
|
||||||
|
if data:
|
||||||
|
f.duration = data["duration"]
|
||||||
|
f.size = data["size"]
|
||||||
|
f.bitrate = data["bitrate"]
|
||||||
|
f.save(update_fields=["bitrate", "duration", "size"])
|
||||||
|
file_path = get_file_path(f.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"],
|
||||||
"id": id,
|
"title": ["Test track"],
|
||||||
"recordings": [
|
"TRACKNUMBER": ["4"],
|
||||||
{
|
"date": ["2012-08-15"],
|
||||||
"artists": [
|
|
||||||
{
|
|
||||||
"id": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13",
|
|
||||||
"name": "Binärpilot",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"duration": 268,
|
|
||||||
"id": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb",
|
|
||||||
"title": "Bend",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"score": 0.860825,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "ok",
|
|
||||||
}
|
}
|
||||||
m = mocker.patch("acoustid.match", return_value=payload)
|
mocker.patch("mutagen.File", return_value=metadata)
|
||||||
r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk)
|
mocker.patch(
|
||||||
track_file.refresh_from_db()
|
"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 str(track_file.acoustid_track_id) == id
|
assert track.title == metadata["title"][0]
|
||||||
assert r == id
|
assert track.mbid is None
|
||||||
m.assert_called_once_with("test", track_file.audio_file.path, parse=False)
|
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_set_acoustid_on_track_file_required_high_score(factories, mocker):
|
def test_can_create_track_from_file_metadata_mbid(factories, mocker):
|
||||||
track_file = factories["music.TrackFile"](acoustid_track_id=None)
|
album = factories["music.Album"]()
|
||||||
payload = {"results": [{"score": 0.79}], "status": "ok"}
|
artist = factories["music.Artist"]()
|
||||||
mocker.patch("acoustid.match", return_value=payload)
|
mocker.patch(
|
||||||
tasks.set_acoustid_on_track_file(track_file_id=track_file.pk)
|
"funkwhale_api.music.models.Album.get_or_create_from_api",
|
||||||
track_file.refresh_from_db()
|
return_value=(album, True),
|
||||||
|
)
|
||||||
|
|
||||||
assert track_file.acoustid_track_id is None
|
album_data = {
|
||||||
|
"release": {
|
||||||
|
"id": album.mbid,
|
||||||
def test_import_batch_run(factories, mocker):
|
"medium-list": [
|
||||||
job = factories["music.ImportJob"]()
|
{
|
||||||
mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay")
|
"track-list": [
|
||||||
tasks.import_batch_run(import_batch_id=job.batch.pk)
|
{
|
||||||
|
"id": "03baca8b-855a-3c05-8f3d-d3235287d84d",
|
||||||
mocked_job_run.assert_called_once_with(import_job_id=job.pk)
|
"position": "4",
|
||||||
|
"number": "4",
|
||||||
|
"recording": {
|
||||||
@pytest.mark.skip("Acoustid is disabled")
|
"id": "2109e376-132b-40ad-b993-2bb6812e19d4",
|
||||||
def test_import_job_can_run_with_file_and_acoustid(
|
"title": "Teen Age Riot",
|
||||||
artists, albums, tracks, preferences, factories, mocker
|
"artist-credit": [
|
||||||
):
|
{"artist": {"id": artist.mbid, "name": artist.name}}
|
||||||
preferences["providers_acoustid__api_key"] = "test"
|
],
|
||||||
path = os.path.join(DATA_DIR, "test.ogg")
|
},
|
||||||
mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
|
}
|
||||||
acoustid_payload = {
|
],
|
||||||
"results": [
|
"track-count": 1,
|
||||||
{
|
}
|
||||||
"id": "e475bf79-c1ce-4441-bed7-1e33f226c0a2",
|
],
|
||||||
"recordings": [{"duration": 268, "id": mbid}],
|
}
|
||||||
"score": 0.860825,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "ok",
|
|
||||||
}
|
}
|
||||||
|
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(
|
mocker.patch(
|
||||||
"funkwhale_api.music.utils.get_audio_file_data",
|
"funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis"
|
||||||
return_value={"bitrate": 42, "length": 43},
|
|
||||||
)
|
)
|
||||||
|
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(
|
mocker.patch(
|
||||||
"funkwhale_api.musicbrainz.api.artists.get",
|
"funkwhale_api.music.models.TrackFile.get_audio_data",
|
||||||
return_value=artists["get"]["adhesive_wombat"],
|
return_value={"size": 23, "duration": 42, "bitrate": 66},
|
||||||
)
|
)
|
||||||
mocker.patch(
|
track = factories["music.Track"]()
|
||||||
"funkwhale_api.musicbrainz.api.releases.get",
|
tf = factories["music.TrackFile"](
|
||||||
return_value=albums["get"]["marsupial"],
|
track=None, import_metadata={"track": {"mbid": track.mbid}}
|
||||||
)
|
|
||||||
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)
|
|
||||||
f = job.audio_file
|
|
||||||
tasks.import_job_run(import_job_id=job.pk)
|
|
||||||
job.refresh_from_db()
|
|
||||||
|
|
||||||
track_file = job.track_file
|
|
||||||
|
|
||||||
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_track_file(track_file_id=tf.pk)
|
||||||
tasks.import_job_run(import_job_id=job.pk)
|
|
||||||
job.refresh_from_db()
|
|
||||||
|
|
||||||
assert job.track_file is None
|
tf.refresh_from_db()
|
||||||
# audio file is deleted from import job once persisted to audio file
|
assert tf.size == 23
|
||||||
assert not job.audio_file
|
assert tf.duration == 42
|
||||||
assert job.status == "skipped"
|
assert tf.bitrate == 66
|
||||||
|
|
||||||
|
|
||||||
def test_import_job_can_be_errored(factories, mocker, preferences):
|
def test_track_file_import_skip_existing_track_in_own_library(factories, temp_signal):
|
||||||
path = os.path.join(DATA_DIR, "test.ogg")
|
track = factories["music.Track"]()
|
||||||
mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
|
library = factories["music.Library"]()
|
||||||
factories["music.TrackFile"](track__mbid=mbid)
|
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)
|
||||||
|
|
||||||
class MyException(Exception):
|
duplicate.refresh_from_db()
|
||||||
pass
|
|
||||||
|
|
||||||
mocker.patch("funkwhale_api.music.tasks._do_import", side_effect=MyException())
|
assert duplicate.import_status == "skipped"
|
||||||
|
assert duplicate.import_details == {
|
||||||
|
"code": "already_imported_in_owned_libraries",
|
||||||
|
"duplicates": [str(existing.uuid)],
|
||||||
|
}
|
||||||
|
|
||||||
job = factories["music.FileImportJob"](audio_file__path=path, track_file=None)
|
handler.assert_called_once_with(
|
||||||
|
track_file=duplicate,
|
||||||
with pytest.raises(MyException):
|
old_status="pending",
|
||||||
tasks.import_job_run(import_job_id=job.pk)
|
new_status="skipped",
|
||||||
|
sender=None,
|
||||||
job.refresh_from_db()
|
signal=signals.track_file_import_status_updated,
|
||||||
|
)
|
||||||
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_track_uuid(now, factories):
|
||||||
path = os.path.join(DATA_DIR, "test.ogg")
|
track = factories["music.Track"]()
|
||||||
|
tf = factories["music.TrackFile"](
|
||||||
|
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_track_file_import_error(factories, now, temp_signal):
|
||||||
|
tf = factories["music.TrackFile"](import_metadata={"track": {"uuid": uuid.uuid4()}})
|
||||||
|
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.import_status == "errored"
|
||||||
|
assert tf.import_date == now
|
||||||
|
assert tf.import_details == {"error_code": "track_uuid_not_found"}
|
||||||
|
handler.assert_called_once_with(
|
||||||
|
track_file=tf,
|
||||||
|
old_status="pending",
|
||||||
|
new_status="errored",
|
||||||
|
sender=None,
|
||||||
|
signal=signals.track_file_import_status_updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_file_import_updates_cover_if_no_cover(factories, mocker, now):
|
||||||
|
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