diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 9f87a7af3eee635621c8596bf14561387aa53184..bfa360f1009f7adc917f5c9f36d95aea14d1cec0 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -14,12 +14,11 @@ router.register(r"settings", GlobalPreferencesViewSet, base_name="settings") router.register(r"activity", activity_views.ActivityViewSet, "activity") router.register(r"tags", views.TagViewSet, "tags") router.register(r"tracks", views.TrackViewSet, "tracks") -router.register(r"trackfiles", views.TrackFileViewSet, "trackfiles") +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"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"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" diff --git a/api/config/routing.py b/api/config/routing.py index fa25aad0764fc76662c2de3c0edb3b13b4a54820..13a67cd1e4f2e8691713bc64b0db27248249da46 100644 --- a/api/config/routing.py +++ b/api/config/routing.py @@ -8,9 +8,7 @@ application = ProtocolTypeRouter( { # Empty for now (http->django views is added by default) "websocket": TokenAuthMiddleware( - URLRouter( - [url("^api/v1/instance/activity$", consumers.InstanceActivityConsumer)] - ) + URLRouter([url("^api/v1/activity$", consumers.InstanceActivityConsumer)]) ) } ) diff --git a/api/config/settings/common.py b/api/config/settings/common.py index d952e0d2d46a3e3aa4e5def2a80a73c3b375ed0a..4b1c693c11ba5e13594af85ba5c323be4f4b4303 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -126,7 +126,6 @@ LOCAL_APPS = ( "funkwhale_api.history", "funkwhale_api.playlists", "funkwhale_api.providers.audiofile", - "funkwhale_api.providers.youtube", "funkwhale_api.providers.acoustid", "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 MEDIA_URL = env("MEDIA_URL", default="/media/") - +FILE_UPLOAD_PERMISSIONS = 0o644 # URL Configuration # ------------------------------------------------------------------------------ ROOT_URLCONF = "config.urls" @@ -446,7 +445,7 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS", "funkwhale_api.common.authentication.BearerTokenHeaderAuth", - "rest_framework_jwt.authentication.JSONWebTokenAuthentication", + "funkwhale_api.common.authentication.JSONWebTokenAuthentication", "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.BasicAuthentication", ), diff --git a/api/config/urls.py b/api/config/urls.py index 5ffcf211b8588c5e9ec78d21baa11f288ebf9a20..ba3b630d991828aa1be65c5eb524b560ee6a47bf 100644 --- a/api/config/urls.py +++ b/api/config/urls.py @@ -2,11 +2,13 @@ from __future__ import unicode_literals 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.contrib import admin from django.views import defaults as default_views + urlpatterns = [ # Django Admin, use {% url 'admin:index' %} url(settings.ADMIN_URL, admin.site.urls), @@ -36,4 +38,6 @@ if settings.DEBUG: if "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar - urlpatterns += [url(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns = [ + path("api/__debug__/", include(debug_toolbar.urls)) + ] + urlpatterns diff --git a/api/funkwhale_api/common/authentication.py b/api/funkwhale_api/common/authentication.py index 10bf36613f7e7cb99a20e26184dca552cc7eed44..415b84cb247f4e13f0b750c23c0c940129fa9ea2 100644 --- a/api/funkwhale_api/common/authentication.py +++ b/api/funkwhale_api/common/authentication.py @@ -56,3 +56,20 @@ class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication): def authenticate_header(self, request): 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 diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py index a009ab5abf4bce698897cbacef0b263a49d5208e..b8106bef49f4c377c7af28f4993038e8a9a03309 100644 --- a/api/funkwhale_api/common/channels.py +++ b/api/funkwhale_api/common/channels.py @@ -1,6 +1,25 @@ +import json +import logging + from asgiref.sync import async_to_sync from channels.layers import get_channel_layer +from django.core.serializers.json import DjangoJSONEncoder +logger = logging.getLogger(__file__) channel_layer = get_channel_layer() -group_send = async_to_sync(channel_layer.group_send) 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) diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py index 47a666f0540fe15a7a472478b9d75516d8706ef4..48c3186384f278be8e1e3480cd4011f73de2a10c 100644 --- a/api/funkwhale_api/common/consumers.py +++ b/api/funkwhale_api/common/consumers.py @@ -16,3 +16,5 @@ class JsonAuthConsumer(JsonWebsocketConsumer): super().accept() for group in self.groups: channels.group_add(group, self.channel_name) + for group in self.scope["user"].get_channels_groups(): + channels.group_add(group, self.channel_name) diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py index 769fd00e445693247600ae5809d01dd8f83f8c12..c1fd39e619a016708dd629962e8a6411a8e31714 100644 --- a/api/funkwhale_api/common/scripts/__init__.py +++ b/api/funkwhale_api/common/scripts/__init__.py @@ -1,6 +1,7 @@ from . import create_actors from . import create_image_variations from . import django_permissions_to_user_permissions +from . import migrate_to_user_libraries from . import test @@ -8,5 +9,6 @@ __all__ = [ "create_actors", "create_image_variations", "django_permissions_to_user_permissions", + "migrate_to_user_libraries", "test", ] diff --git a/api/funkwhale_api/common/scripts/migrate_to_user_libraries.py b/api/funkwhale_api/common/scripts/migrate_to_user_libraries.py new file mode 100644 index 0000000000000000000000000000000000000000..aa3b4d4dab3599c7960d688d19aa32ab1dd84a8c --- /dev/null +++ b/api/funkwhale_api/common/scripts/migrate_to_user_libraries.py @@ -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!") diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 161c581025da4c68d33de0277d956ed710f1b4ed..c94b08d0022ef2ac43f2320c94e1f0765fe10dd3 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -1,5 +1,70 @@ +import collections + 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): def __init__(self, name, allow_all=False, qs_filter=None): @@ -21,6 +86,7 @@ class ActionSerializer(serializers.Serializer): objects = serializers.JSONField(required=True) filters = serializers.DictField(required=False) actions = None + pk_field = "pk" def __init__(self, *args, **kwargs): self.actions_by_name = {a.name: a for a in self.actions} @@ -51,7 +117,9 @@ class ActionSerializer(serializers.Serializer): if value == "all": return self.queryset.all().order_by("id") 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( "{} is not a valid value for objects. You must provide either a " diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index dae90ebbdcfeb3719a915f125bc26f76208d431b..7e30c28a6a7283e0c57d049961e617fb60aec3c4 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -3,9 +3,12 @@ from rest_framework.decorators import list_route from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response +from django.db.models import Prefetch + from funkwhale_api.activity import record 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 filters, models, serializers @@ -19,11 +22,7 @@ class TrackFavoriteViewSet( filter_class = filters.TrackFavoriteFilter serializer_class = serializers.UserTrackFavoriteSerializer - queryset = ( - models.TrackFavorite.objects.all() - .select_related("track__artist", "track__album__artist", "user") - .prefetch_related("track__files") - ) + queryset = models.TrackFavorite.objects.all().select_related("user") permission_classes = [ permissions.ConditionalAuthentication, permissions.OwnerPermission, @@ -49,9 +48,14 @@ class TrackFavoriteViewSet( def get_queryset(self): queryset = super().get_queryset() - return queryset.filter( + queryset = queryset.filter( 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): track = Track.objects.get(pk=serializer.data["track"]) diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 73e83e334534bd211daa90a684a1441b5a10907b..2e740780083d454a32345a9816e14568ff336c58 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -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 = [ "Accept", "Add", @@ -58,4 +64,145 @@ def accept_follow(follow): from . import serializers 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 diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py index 7fbf815dc50e73bb2c15c577e418650650b5dc68..a91e9a5e91659d89aea66e76a730967f8f845c49 100644 --- a/api/funkwhale_api/federation/actors.py +++ b/api/funkwhale_api/federation/actors.py @@ -3,15 +3,11 @@ import logging import xml from django.conf import settings -from django.db import transaction from django.urls import reverse from django.utils import timezone from rest_framework.exceptions import PermissionDenied 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 @@ -39,9 +35,9 @@ def get_actor_data(actor_url): raise ValueError("Invalid actor payload: {}".format(response.text)) -def get_actor(actor_url): +def get_actor(fid): try: - actor = models.Actor.objects.get(url=actor_url) + actor = models.Actor.objects.get(fid=fid) except models.Actor.DoesNotExist: actor = None fetch_delta = datetime.timedelta( @@ -50,7 +46,7 @@ def get_actor(actor_url): if actor and actor.last_fetch_date > timezone.now() - fetch_delta: # cache is hot, we can return as is return actor - data = get_actor_data(actor_url) + data = get_actor_data(fid) serializer = serializers.ActorSerializer(data=data) serializer.is_valid(raise_exception=True) @@ -72,7 +68,7 @@ class SystemActor(object): def get_actor_instance(self): 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: pass private, public = keys.get_key_pair() @@ -83,7 +79,7 @@ class SystemActor(object): args["public_key"] = public.decode("utf-8") return models.Actor.objects.create(**args) - def get_actor_url(self): + def get_actor_id(self): return utils.full_url( reverse("federation:instance-actors-detail", kwargs={"actor": self.id}) ) @@ -95,7 +91,7 @@ class SystemActor(object): "type": "Person", "name": name.format(host=settings.FEDERATION_HOSTNAME), "manually_approves_followers": True, - "url": self.get_actor_url(), + "fid": self.get_actor_id(), "shared_inbox_url": utils.full_url( reverse("federation:instance-actors-inbox", kwargs={"actor": id}) ), @@ -178,91 +174,13 @@ class SystemActor(object): if ac["object"]["type"] != "Follow": return - if ac["object"]["actor"] != sender.url: + if ac["object"]["actor"] != sender.fid: # not the same actor, permission issue return 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): id = "test" name = "{host}'s test account" @@ -321,7 +239,7 @@ class TestActor(SystemActor): {}, ], "type": "Create", - "actor": test_actor.url, + "actor": test_actor.fid, "id": "{}/activity".format(reply_url), "published": now.isoformat(), "to": ac["actor"], @@ -336,14 +254,14 @@ class TestActor(SystemActor): "sensitive": False, "url": reply_url, "to": [ac["actor"]], - "attributedTo": test_actor.url, + "attributedTo": test_actor.fid, "cc": [], "attachment": [], "tag": [ { "type": "Mention", "href": ac["actor"], - "name": sender.mention_username, + "name": sender.full_username, } ], }, @@ -359,7 +277,7 @@ class TestActor(SystemActor): )[0] activity.deliver( serializers.FollowSerializer(follow_back).data, - to=[follow_back.target.url], + to=[follow_back.target.fid], on_behalf_of=follow_back.actor, ) @@ -373,7 +291,7 @@ class TestActor(SystemActor): return undo = serializers.UndoFollowSerializer(follow).data 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()} diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py index a82e9aaf24086e05e3dbb0f3d1fb4ee7e9dd4b6a..4d8af0bcfce2e523e2e4f934439a47d5edce4860 100644 --- a/api/funkwhale_api/federation/admin.py +++ b/api/funkwhale_api/federation/admin.py @@ -6,14 +6,14 @@ from . import models @admin.register(models.Actor) class ActorAdmin(admin.ModelAdmin): list_display = [ - "url", + "fid", "domain", "preferred_username", "type", "creation_date", "last_fetch_date", ] - search_fields = ["url", "domain", "preferred_username"] + search_fields = ["fid", "domain", "preferred_username"] list_filter = ["type"] @@ -21,14 +21,14 @@ class ActorAdmin(admin.ModelAdmin): class FollowAdmin(admin.ModelAdmin): list_display = ["actor", "target", "approved", "creation_date"] list_filter = ["approved"] - search_fields = ["actor__url", "target__url"] + search_fields = ["actor__fid", "target__fid"] list_select_related = True @admin.register(models.Library) class LibraryAdmin(admin.ModelAdmin): 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_select_related = True diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..e8c30db0c6bb96e85f63822a437d1d1b5922e4ec --- /dev/null +++ b/api/funkwhale_api/federation/api_serializers.py @@ -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 diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py index 625043bf6cb89598d867661512fd580814b1fd82..831bc6630416645283d14d0fe9a667cb5398c518 100644 --- a/api/funkwhale_api/federation/api_urls.py +++ b/api/funkwhale_api/federation/api_urls.py @@ -1,9 +1,9 @@ from rest_framework import routers -from . import views +from . import api_views router = routers.SimpleRouter() -router.register(r"libraries", views.LibraryViewSet, "libraries") -router.register(r"library-tracks", views.LibraryTrackViewSet, "library-tracks") +router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows") +router.register(r"libraries", api_views.LibraryViewSet, "libraries") urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py new file mode 100644 index 0000000000000000000000000000000000000000..75ca8960933ad521d9b6a30e1a378aa41231e3a1 --- /dev/null +++ b/api/funkwhale_api/federation/api_views.py @@ -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]}) diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 4a13842da3b668b7aeed409b042192e238c6f42b..f9cc79e4201cb39a765fb59d4fec7a385e18d62c 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -8,6 +8,7 @@ from django.utils import timezone from django.utils.http import http_date from funkwhale_api.factories import registry +from funkwhale_api.users import factories as user_factories from . import keys, models @@ -61,6 +62,10 @@ class LinkFactory(factory.Factory): audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"])) +def create_user(actor): + return user_factories.UserFactory(actor=actor) + + @registry.register class ActorFactory(factory.DjangoModelFactory): public_key = None @@ -68,7 +73,7 @@ class ActorFactory(factory.DjangoModelFactory): preferred_username = factory.Faker("user_name") summary = factory.Faker("paragraph") domain = factory.Faker("domain_name") - url = factory.LazyAttribute( + fid = factory.LazyAttribute( lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username) ) inbox_url = factory.LazyAttribute( @@ -81,20 +86,34 @@ class ActorFactory(factory.DjangoModelFactory): class Meta: model = models.Actor - class Params: - local = factory.Trait( - domain=factory.LazyAttribute(lambda o: settings.FEDERATION_HOSTNAME) - ) - - @classmethod - def _generate(cls, create, attrs): - has_public = attrs.get("public_key") is not None - has_private = attrs.get("private_key") is not None - if not has_public and not has_private: + @factory.post_generation + def local(self, create, extracted, **kwargs): + if not extracted and not kwargs: + return + from funkwhale_api.users.factories import UserFactory + + self.domain = settings.FEDERATION_HOSTNAME + self.save(update_fields=["domain"]) + if not create: + if extracted and hasattr(extracted, "pk"): + 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() - attrs["private_key"] = private.decode("utf-8") - attrs["public_key"] = public.decode("utf-8") - return super()._generate(create, attrs) + self.private_key = private.decode("utf-8") + self.public_key = public.decode("utf-8") @registry.register @@ -110,15 +129,70 @@ class FollowFactory(factory.DjangoModelFactory): @registry.register -class LibraryFactory(factory.DjangoModelFactory): +class MusicLibraryFactory(factory.django.DjangoModelFactory): + actor = factory.SubFactory(ActorFactory) + privacy_level = "me" + name = factory.Faker("sentence") + description = factory.Faker("sentence") + files_count = 0 + + class Meta: + 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") - federation_enabled = True - download_files = False - autoimport = False + payload = factory.LazyFunction(lambda: {"type": "Create"}) class Meta: - model = models.Library + 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): @@ -161,25 +235,6 @@ class LibraryTrackMetadataFactory(factory.Factory): 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") class NoteFactory(factory.Factory): type = "Note" @@ -192,22 +247,6 @@ class NoteFactory(factory.Factory): 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") class AudioMetadataFactory(factory.Factory): recording = factory.LazyAttribute( diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index ff7575ba5a006aee54826ed86ea62875dc215e15..658bd411cbe3e420ca043a2d62cc61765e763bbb 100644 --- a/api/funkwhale_api/federation/filters.py +++ b/api/funkwhale_api/federation/filters.py @@ -1,68 +1,10 @@ import django_filters from funkwhale_api.common import fields -from funkwhale_api.common import search 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): pending = django_filters.CharFilter(method="filter_pending") ordering = django_filters.OrderingFilter( @@ -84,3 +26,9 @@ class FollowFilter(django_filters.FilterSet): if value.lower() in ["true", "1", "yes"]: queryset = queryset.filter(approved__isnull=True) return queryset + + +class LibraryFollowFilter(django_filters.FilterSet): + class Meta: + model = models.LibraryFollow + fields = ["approved"] diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py index d2ccb19524cac60d52469cc795c556cf960f51b9..997790ed0d3f59117d2ce0344c980d60a524856a 100644 --- a/api/funkwhale_api/federation/library.py +++ b/api/funkwhale_api/federation/library.py @@ -71,8 +71,7 @@ def scan_from_account_name(account_name): return data -def get_library_data(library_url): - actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() +def get_library_data(library_url, actor): auth = signing.get_auth(actor.private_key, actor.private_key_id) try: response = session.get_session().get( @@ -98,8 +97,7 @@ def get_library_data(library_url): return serializer.validated_data -def get_library_page(library, page_url): - actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() +def get_library_page(library, page_url, actor): auth = signing.get_auth(actor.private_key, actor.private_key_id) response = session.get_session().get( page_url, diff --git a/api/funkwhale_api/federation/migrations/0007_auto_20180807_1748.py b/api/funkwhale_api/federation/migrations/0007_auto_20180807_1748.py new file mode 100644 index 0000000000000000000000000000000000000000..65bbc4cd8d9f58897b89871f7d242e860ea1e8eb --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0007_auto_20180807_1748.py @@ -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", + ), + ), + ] diff --git a/api/funkwhale_api/federation/migrations/0008_auto_20180807_1748.py b/api/funkwhale_api/federation/migrations/0008_auto_20180807_1748.py new file mode 100644 index 0000000000000000000000000000000000000000..f86a1e31cc927068288422be9d85ee95626e35ec --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0008_auto_20180807_1748.py @@ -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")} + ), + ] diff --git a/api/funkwhale_api/federation/migrations/0009_auto_20180822_1956.py b/api/funkwhale_api/federation/migrations/0009_auto_20180822_1956.py new file mode 100644 index 0000000000000000000000000000000000000000..042a861436b5eb459f4271afd9db3e008959720e --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0009_auto_20180822_1956.py @@ -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, + ), + ), + ] diff --git a/api/funkwhale_api/federation/migrations/0010_auto_20180904_2011.py b/api/funkwhale_api/federation/migrations/0010_auto_20180904_2011.py new file mode 100644 index 0000000000000000000000000000000000000000..62b4c73fb2a0af85ece88b0d90628b812f42d891 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0010_auto_20180904_2011.py @@ -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", + ), + ), + ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 17ae0137657415243581afe4fdce57edfd1f5be5..8dbf63916151ad31f8df0f431067dfc7032efda6 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -3,6 +3,7 @@ import uuid from django.conf import settings from django.contrib.postgres.fields import JSONField +from django.core.exceptions import ObjectDoesNotExist from django.core.serializers.json import DjangoJSONEncoder from django.db import models 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.music import utils as music_utils +from . import utils as federation_utils + TYPE_CHOICES = [ ("Person", "Person"), ("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): def local(self, include=True): 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): 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) inbox_url = models.URLField(max_length=500) following_url = models.URLField(max_length=500, null=True, blank=True) @@ -63,11 +94,14 @@ class Actor(models.Model): @property def private_key_id(self): - return "{}#main-key".format(self.url) + return "{}#main-key".format(self.fid) @property - def mention_username(self): - return "@{}@{}".format(self.preferred_username, self.domain) + def full_username(self): + return "{}@{}".format(self.preferred_username, self.domain) + + def __str__(self): + return "{}@{}".format(self.preferred_username, self.domain) def save(self, **kwargs): lowercase_fields = ["domain"] @@ -104,26 +138,98 @@ class Actor(models.Model): follows = self.received_follows.filter(approved=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): - ap_type = "Follow" + def get_user(self): + 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) + 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, related_name="emitted_follows", on_delete=models.CASCADE ) target = models.ForeignKey( 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: 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): @@ -167,7 +273,9 @@ class LibraryTrack(models.Model): artist_name = models.CharField(max_length=500) album_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 def mbid(self): diff --git a/api/funkwhale_api/federation/permissions.py b/api/funkwhale_api/federation/permissions.py deleted file mode 100644 index a08d57e5f354dc6dd77e8ef3a477b5886a9cbde1..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/federation/permissions.py +++ /dev/null @@ -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() diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..d247b3d9931cddfd4e4dda48f3ab81e01a97a73c --- /dev/null +++ b/api/funkwhale_api/federation/routes.py @@ -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])} diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 304253aa0716e1d14669a3829395f986ad8f93da..30b5dad4d5abd42332c0a64b2739ec808edab89f 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -4,15 +4,12 @@ import urllib.parse from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator -from django.db import transaction 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.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 = [ "https://www.w3.org/ns/activitystreams", @@ -38,7 +35,7 @@ class ActorSerializer(serializers.Serializer): def to_representation(self, instance): ret = { - "id": instance.url, + "id": instance.fid, "outbox": instance.outbox_url, "inbox": instance.inbox_url, "preferredUsername": instance.preferred_username, @@ -58,9 +55,9 @@ class ActorSerializer(serializers.Serializer): ret["@context"] = AP_CONTEXT if instance.public_key: ret["publicKey"] = { - "owner": instance.url, + "owner": instance.fid, "publicKeyPem": instance.public_key, - "id": "{}#main-key".format(instance.url), + "id": "{}#main-key".format(instance.fid), } ret["endpoints"] = {} if instance.shared_inbox_url: @@ -78,7 +75,7 @@ class ActorSerializer(serializers.Serializer): def prepare_missing_fields(self): kwargs = { - "url": self.validated_data["id"], + "fid": self.validated_data["id"], "outbox_url": self.validated_data["outbox"], "inbox_url": self.validated_data["inbox"], "following_url": self.validated_data.get("following"), @@ -91,7 +88,7 @@ class ActorSerializer(serializers.Serializer): maf = self.validated_data.get("manuallyApprovesFollowers") if maf is not None: kwargs["manually_approves_followers"] = maf - domain = urllib.parse.urlparse(kwargs["url"]).netloc + domain = urllib.parse.urlparse(kwargs["fid"]).netloc kwargs["domain"] = domain for endpoint, url in self.initial_data.get("endpoints", {}).items(): if endpoint == "sharedInbox": @@ -110,7 +107,7 @@ class ActorSerializer(serializers.Serializer): def save(self, **kwargs): d = self.prepare_missing_fields() 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): if value: @@ -122,6 +119,7 @@ class APIActorSerializer(serializers.ModelSerializer): model = models.Actor fields = [ "id", + "fid", "url", "creation_date", "summary", @@ -131,190 +129,73 @@ class APIActorSerializer(serializers.ModelSerializer): "domain", "type", "manually_approves_followers", + "full_username", ] -class LibraryActorSerializer(ActorSerializer): - url = serializers.ListField(child=serializers.JSONField()) - - 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 +class BaseActivitySerializer(serializers.Serializer): + id = serializers.URLField(max_length=500, required=False) + type = serializers.CharField(max_length=100) + actor = serializers.URLField(max_length=500) - library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() - qs = models.Follow.objects.filter(pk=value, target=library_actor) + def validate_actor(self, v): + expected = self.context.get("actor") + if expected and expected.fid != v: + raise serializers.ValidationError("Invalid actor") + if expected: + # avoid a DB lookup + return expected 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 - + return models.Actor.objects.get(fid=v) + except models.Actor.DoesNotExist: + raise serializers.ValidationError("Actor not found") -class APILibraryCreateSerializer(serializers.ModelSerializer): - actor = serializers.URLField(max_length=500) - federation_enabled = serializers.BooleanField() - uuid = serializers.UUIDField(read_only=True) + def create(self, validated_data): + return models.Activity.objects.create( + fid=validated_data.get("id"), + actor=validated_data["actor"], + payload=self.initial_data, + ) - class Meta: - model = models.Library - fields = ["uuid", "actor", "autoimport", "federation_enabled", "download_files"] + def validate(self, data): + data["recipients"] = self.validate_recipients(self.initial_data) + return super().validate(data) - def validate(self, validated_data): - from . import actors - from . import library + def validate_recipients(self, payload): + """ + Ensure we have at least a to/cc field with valid actors + """ + to = payload.get("to", []) + cc = payload.get("cc", []) - 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: - actor = models.Actor.objects.get(url=actor_url) - except models.Actor.DoesNotExist: - actor = acs.save() - 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], + if not to and not cc: + raise serializers.ValidationError( + "We cannot handle an activity with no recipient" ) - 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): - library = models.Library.objects.update_or_create( - url=validated_data["library"]["id"], - defaults={ - "actor": validated_data["actor"], - "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 + matching = models.Actor.objects.filter(fid__in=to + cc) + if self.context.get("local_recipients", False): + matching = matching.local() + if not len(matching): + raise serializers.ValidationError("No matching recipients found") -class APILibraryTrackSerializer(serializers.ModelSerializer): - library = APILibrarySerializer() - status = serializers.SerializerMethodField() + actors_by_fid = {a.fid: a for a in matching} - class Meta: - model = models.LibraryTrack - fields = [ - "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 match(recipients, actors): + for r in recipients: + if r == activity.PUBLIC_ADDRESS: + yield r + else: + try: + yield actors[r] + except KeyError: + pass - def get_status(self, o): - try: - if o.local_track_file is not None: - return "imported" - except music_models.TrackFile.DoesNotExist: - pass - for job in o.import_jobs.all(): - if job.status == "pending": - return "import_pending" - return "not_imported" + return { + "to": list(match(to, actors_by_fid)), + "cc": list(match(cc, actors_by_fid)), + } class FollowSerializer(serializers.Serializer): @@ -325,35 +206,61 @@ class FollowSerializer(serializers.Serializer): def validate_object(self, v): 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") 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: - 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): expected = self.context.get("follow_actor") - if expected and expected.url != v: + if expected and expected.fid != v: raise serializers.ValidationError("Invalid actor") try: - return models.Actor.objects.get(url=v) + return models.Actor.objects.get(fid=v) except models.Actor.DoesNotExist: raise serializers.ValidationError("Actor not found") 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"], target=self.validated_data["object"], - **kwargs, # noqa + defaults=defaults, )[0] def to_representation(self, instance): return { "@context": AP_CONTEXT, - "actor": instance.actor.url, - "id": instance.get_federation_url(), - "object": instance.target.url, + "actor": instance.actor.fid, + "id": instance.get_federation_id(), + "object": instance.target.fid, "type": "Follow", } @@ -376,50 +283,66 @@ class APIFollowSerializer(serializers.ModelSerializer): class AcceptFollowSerializer(serializers.Serializer): - id = serializers.URLField(max_length=500) + id = serializers.URLField(max_length=500, required=False) actor = serializers.URLField(max_length=500) object = FollowSerializer() type = serializers.ChoiceField(choices=["Accept"]) def validate_actor(self, v): - expected = self.context.get("follow_target") - if expected and expected.url != v: + expected = self.context.get("actor") + if expected and expected.fid != v: raise serializers.ValidationError("Invalid actor") try: - return models.Actor.objects.get(url=v) + return models.Actor.objects.get(fid=v) except models.Actor.DoesNotExist: raise serializers.ValidationError("Actor not found") def validate(self, validated_data): - # we ensure the accept actor actually match the follow target - if validated_data["actor"] != validated_data["object"]["object"]: + # we ensure the accept actor actually match the follow target / library owner + 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") try: validated_data["follow"] = ( - models.Follow.objects.filter( - target=validated_data["actor"], - actor=validated_data["object"]["actor"], + follow_class.objects.filter( + target=target, actor=validated_data["object"]["actor"] ) .exclude(approved=True) + .select_related() .get() ) - except models.Follow.DoesNotExist: + except follow_class.DoesNotExist: raise serializers.ValidationError("No follow to accept") return validated_data def to_representation(self, instance): + if instance.target._meta.label == "music.Library": + actor = instance.target.actor + else: + actor = instance.target + return { "@context": AP_CONTEXT, - "id": instance.get_federation_url() + "/accept", + "id": instance.get_federation_id() + "/accept", "type": "Accept", - "actor": instance.target.url, + "actor": actor.fid, "object": FollowSerializer(instance).data, } def save(self): - self.validated_data["follow"].approved = True - self.validated_data["follow"].save() - return self.validated_data["follow"] + follow = self.validated_data["follow"] + follow.approved = True + follow.save() + if follow.target._meta.label == "music.Library": + follow.target.schedule_scan() + return follow class UndoFollowSerializer(serializers.Serializer): @@ -430,10 +353,10 @@ class UndoFollowSerializer(serializers.Serializer): def validate_actor(self, v): expected = self.context.get("follow_target") - if expected and expected.url != v: + if expected and expected.fid != v: raise serializers.ValidationError("Invalid actor") try: - return models.Actor.objects.get(url=v) + return models.Actor.objects.get(fid=v) except models.Actor.DoesNotExist: raise serializers.ValidationError("Actor not found") @@ -452,9 +375,9 @@ class UndoFollowSerializer(serializers.Serializer): def to_representation(self, instance): return { "@context": AP_CONTEXT, - "id": instance.get_federation_url() + "/undo", + "id": instance.get_federation_id() + "/undo", "type": "Undo", - "actor": instance.actor.url, + "actor": instance.actor.fid, "object": FollowSerializer(instance).data, } @@ -488,9 +411,9 @@ class ActorWebfingerSerializer(serializers.Serializer): data = {} data["subject"] = "acct:{}".format(instance.webfinger_subject) 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 @@ -519,7 +442,7 @@ class ActivitySerializer(serializers.Serializer): def validate_actor(self, value): 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( "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} +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): type = serializers.ChoiceField(choices=["Collection"]) 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) d = { "id": conf["id"], - "actor": conf["actor"].url, + "actor": conf["actor"].fid, "totalItems": paginator.count, - "type": "Collection", + "type": conf.get("type", "Collection"), "current": current, "first": first, "last": last, } + d.update(get_additional_fields(conf)) if self.context.get("include_ap_context", True): d["@context"] = AP_CONTEXT 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): type = serializers.ChoiceField(choices=["CollectionPage"]) totalItems = serializers.IntegerField(min_value=0) @@ -623,7 +610,7 @@ class CollectionPageSerializer(serializers.Serializer): d = { "id": id, "partOf": conf["id"], - "actor": conf["actor"].url, + "actor": conf["actor"].fid, "totalItems": page.paginator.count, "type": "CollectionPage", "first": first, @@ -645,7 +632,7 @@ class CollectionPageSerializer(serializers.Serializer): d["next"] = funkwhale_utils.set_query_parameter( conf["id"], page=page.next_page_number() ) - + d.update(get_additional_fields(conf)) if self.context.get("include_ap_context", True): d["@context"] = AP_CONTEXT return d @@ -678,6 +665,7 @@ class AudioMetadataSerializer(serializers.Serializer): class AudioSerializer(serializers.Serializer): type = serializers.CharField() id = serializers.URLField(max_length=500) + library = serializers.URLField(max_length=500) url = serializers.JSONField() published = serializers.DateTimeField() updated = serializers.DateTimeField(required=False) @@ -704,32 +692,40 @@ class AudioSerializer(serializers.Serializer): 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): defaults = { - "audio_mimetype": validated_data["url"]["mediaType"], - "audio_url": validated_data["url"]["href"], - "metadata": validated_data["metadata"], - "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"], + "mimetype": validated_data["url"]["mediaType"], + "source": validated_data["url"]["href"], + "creation_date": validated_data["published"], "modification_date": validated_data.get("updated"), + "metadata": self.initial_data, } - return models.LibraryTrack.objects.get_or_create( - library=self.context["library"], url=validated_data["id"], defaults=defaults - )[0] + tf, created = validated_data["library"].files.update_or_create( + fid=validated_data["id"], defaults=defaults + ) + return tf def to_representation(self, instance): track = instance.track album = instance.track.album artist = instance.track.artist - d = { "type": "Audio", - "id": instance.get_federation_url(), + "id": instance.get_federation_id(), + "library": instance.library.get_federation_id(), "name": instance.track.full_name, "published": instance.creation_date.isoformat(), - "updated": instance.modification_date.isoformat(), "metadata": { "artist": { "musicbrainz_id": str(artist.mbid) if artist.mbid else None, @@ -748,12 +744,14 @@ class AudioSerializer(serializers.Serializer): "length": instance.duration, }, "url": { - "href": utils.full_url(instance.path), + "href": utils.full_url(instance.listen_url), "type": "Link", "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): d["@context"] = AP_CONTEXT return d @@ -763,7 +761,7 @@ class CollectionSerializer(serializers.Serializer): def to_representation(self, conf): d = { "id": conf["id"], - "actor": conf["actor"].url, + "actor": conf["actor"].fid, "totalItems": len(conf["items"]), "type": "Collection", "items": [ @@ -777,27 +775,3 @@ class CollectionSerializer(serializers.Serializer): if self.context.get("include_ap_context", True): d["@context"] = AP_CONTEXT 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}} diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index d1b5b7bd21b4a588008bf531003c25e666cd3313..2d7e37a17065b1d0eb056793f63bcf32dc1c8cf5 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -1,92 +1,23 @@ import datetime -import json import logging import os from django.conf import settings -from django.db.models import Q +from django.db.models import Q, F from django.utils import timezone from dynamic_preferences.registries import global_preferences_registry from requests.exceptions import RequestException from funkwhale_api.common import session +from funkwhale_api.music import models as music_models from funkwhale_api.taskapp import celery -from . import actors -from . import library as lb from . import models, signing +from . import routes 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") def clean_music_cache(): preferences = global_preferences_registry.manager() @@ -96,23 +27,22 @@ def clean_music_cache(): limit = timezone.now() - datetime.timedelta(minutes=delay) candidates = ( - models.LibraryTrack.objects.filter( + music_models.TrackFile.objects.filter( Q(audio_file__isnull=False) - & ( - Q(local_track_file__accessed_date__lt=limit) - | Q(local_track_file__accessed_date=None) - ) + & (Q(accessed_date__lt=limit) | Q(accessed_date=None)) ) + .local(False) .exclude(audio_file="") .only("audio_file", "id") + .order_by("id") ) - for lt in candidates: - lt.audio_file.delete() + for tf in candidates: + tf.audio_file.delete() # we also delete orphaned files, if any storage = models.LibraryTrack._meta.get_field("audio_file").storage - files = get_files(storage, "federation_cache") - existing = models.LibraryTrack.objects.filter(audio_file__in=files) + files = get_files(storage, "federation_cache/tracks") + existing = music_models.TrackFile.objects.filter(audio_file__in=files) missing = set(files) - set(existing.values_list("audio_file", flat=True)) for m in missing: storage.delete(m) @@ -130,3 +60,100 @@ def get_files(storage, *parts): for dir in dirs: files += get_files(storage, *(list(parts) + [dir])) 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) diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index 319e37bedbb2ba0ebb5ccd59d545613234e74c55..c83728b26541f1dbbb29f2ae23349ca47f6bd274 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -11,7 +11,7 @@ router.register( router.register(r"federation/actors", views.ActorViewSet, "actors") 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 + [ url("federation/music/", include((music_router.urls, "music"), namespace="music")) ] diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index c87bde6a5a785653f0c40b06c33bc81d216b090d..d02c8bf6874cf83240ab7cbe3a5835b2dab3bdea 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -2,6 +2,10 @@ import unicodedata import re from django.conf import settings +from funkwhale_api.common import session + +from . import signing + def full_url(path): """ @@ -52,3 +56,35 @@ def slugify_username(username): ) value = re.sub(r"[^\w\s-]", "", value).strip() 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() diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index ae20ab1d8b2162140c734219c7edc000a0bb0ec8..30fdb9a9f2c46c9d65ace923703cca28cde519b3 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -1,25 +1,20 @@ from django import forms from django.core import paginator -from django.db import transaction from django.http import HttpResponse, Http404 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 funkwhale_api.common import preferences from funkwhale_api.music import models as music_models -from funkwhale_api.users.permissions import HasUserPermission from . import ( + activity, actors, authentication, - filters, - library, models, - permissions, renderers, serializers, - tasks, utils, webfinger, ) @@ -34,7 +29,6 @@ class FederationMixin(object): class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = "preferred_username" - lookup_value_regex = ".*" authentication_classes = [authentication.SignatureAuthentication] permission_classes = [] renderer_classes = [renderers.ActivityPubRenderer] @@ -43,6 +37,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV @detail_route(methods=["get", "post"]) 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) @detail_route(methods=["get", "post"]) @@ -143,161 +146,64 @@ class WellKnownViewSet(viewsets.GenericViewSet): return serializers.ActorWebfingerSerializer(actor).data -class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): +def has_library_access(request, library): + if library.privacy_level == "everyone": + return True + if request.user.is_authenticated and request.user.is_superuser: + return True + + try: + actor = request.actor + except AttributeError: + return False + + return library.received_follows.filter(actor=actor, approved=True).exists() + + +class MusicLibraryViewSet( + FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): authentication_classes = [authentication.SignatureAuthentication] - permission_classes = [permissions.LibraryFollower] + permission_classes = [] renderer_classes = [renderers.ActivityPubRenderer] + serializer_class = serializers.PaginatedCollectionSerializer + queryset = music_models.Library.objects.all().select_related("actor") + lookup_field = "uuid" - def list(self, request, *args, **kwargs): + 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") - library = actors.SYSTEM_ACTORS["library"].get_actor_instance() - qs = ( - music_models.TrackFile.objects.order_by("-creation_date") - .select_related("track__artist", "track__album__artist") - .filter(library_track__isnull=True) - ) if page is None: - conf = { - "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) + serializer = serializers.LibrarySerializer(lb) data = serializer.data 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: page_number = int(page) except Exception: return response.Response({"page": ["Invalid page number"]}, status=400) - p = paginator.Paginator( - qs, preferences.get("federation__collection_page_size") - ) + conf["page_size"] = preferences.get("federation__collection_page_size") + p = paginator.Paginator(conf["items"], conf["page_size"]) try: page = p.page(page_number) - conf = { - "id": utils.full_url(reverse("federation:music:files-list")), - "page": page, - "item_serializer": serializers.AudioSerializer, - "actor": library, - } + conf["page"] = page serializer = serializers.CollectionPageSerializer(conf) data = serializer.data except paginator.EmptyPage: return response.Response(status=404) 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) diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index 6c7ef39914bf9fdbdda9a896d59347ac27eb3666..ec3b4f3c080fabe174a340e3326b7365668f9c9e 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -1,9 +1,12 @@ from rest_framework import mixins, viewsets from rest_framework.permissions import IsAuthenticatedOrReadOnly +from django.db.models import Prefetch + from funkwhale_api.activity import record 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 @@ -15,11 +18,7 @@ class ListeningViewSet( ): serializer_class = serializers.ListeningSerializer - queryset = ( - models.Listening.objects.all() - .select_related("track__artist", "track__album__artist", "user") - .prefetch_related("track__files") - ) + queryset = models.Listening.objects.all().select_related("user") permission_classes = [ permissions.ConditionalAuthentication, permissions.OwnerPermission, @@ -39,9 +38,13 @@ class ListeningViewSet( def get_queryset(self): queryset = super().get_queryset() - return queryset.filter( + queryset = queryset.filter( 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): context = super().get_serializer_context() diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 8098ef1a2f49ee8b6275351a56faae77eb7e8dd6..b5ce3ef4df4b1f8b0c3c2ccbbe4b0b7fb46135ea 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -18,7 +18,7 @@ class ManageTrackFileFilterSet(filters.FilterSet): class Meta: model = music_models.TrackFile - fields = ["q", "track__album", "track__artist", "track", "library_track"] + fields = ["q", "track__album", "track__artist", "track"] class ManageUserFilterSet(filters.FilterSet): diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 42585d6a7ed173b4c56dd17e0a1e973d3757bccc..1605aea878e7c004cc265a343030488e35bebc12 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -59,7 +59,6 @@ class ManageTrackFileSerializer(serializers.ModelSerializer): "bitrate", "size", "path", - "library_track", ) diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 89d2afe4593f5fe0c34118af9976e43307feaf05..29aed270fe511d7fee7f99a42617543f92d611c5 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -15,7 +15,7 @@ class ManageTrackFileViewSet( ): queryset = ( music_models.TrackFile.objects.all() - .select_related("track__artist", "track__album__artist", "library_track") + .select_related("track__artist", "track__album__artist") .order_by("-id") ) serializer_class = serializers.ManageTrackFileSerializer diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py index a5775acd68022e7530a586bfd21111da4125a104..26d3e34e8d174f53d2bd27e17aba295afcf9d858 100644 --- a/api/funkwhale_api/music/admin.py +++ b/api/funkwhale_api/music/admin.py @@ -65,6 +65,7 @@ class TrackFileAdmin(admin.ModelAdmin): "mimetype", "size", "bitrate", + "import_status", ] list_select_related = ["track"] search_fields = [ @@ -74,4 +75,12 @@ class TrackFileAdmin(admin.ModelAdmin): "track__album__title", "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"] diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 9bcc4350ffae3e56ddef431b9c33960b9899364a..e65ca151b4ec2ada45a97b5bf007fa066e9f94e9 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -3,8 +3,8 @@ import os import factory from funkwhale_api.factories import ManyToManyFromList, registry -from funkwhale_api.federation.factories import LibraryTrackFactory -from funkwhale_api.users.factories import UserFactory +from funkwhale_api.federation import factories as federation_factories + SAMPLES_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), @@ -51,6 +51,7 @@ class TrackFactory(factory.django.DjangoModelFactory): @registry.register class TrackFileFactory(factory.django.DjangoModelFactory): track = factory.SubFactory(TrackFactory) + library = factory.SubFactory(federation_factories.MusicLibraryFactory) audio_file = factory.django.FileField( from_path=os.path.join(SAMPLES_PATH, "test.ogg") ) @@ -58,67 +59,13 @@ class TrackFileFactory(factory.django.DjangoModelFactory): bitrate = None size = None duration = None + mimetype = "audio/ogg" class Meta: model = "music.TrackFile" class Params: 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 diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 87537b675ed95dcfe0b3cfa48d792a5ddb7063ce..1f330c1a500c76c7f6f2fb4736e7419265f17430 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -1,85 +1,97 @@ -from django.db.models import Count from django_filters import rest_framework as filters from funkwhale_api.common import fields +from funkwhale_api.common import search from . import models +from . import utils class ArtistFilter(filters.FilterSet): q = fields.SearchFilter(search_fields=["name"]) - listenable = filters.BooleanFilter(name="_", method="filter_listenable") + playable = filters.BooleanFilter(name="_", method="filter_playable") class Meta: model = models.Artist fields = { "name": ["exact", "iexact", "startswith", "icontains"], - "listenable": "exact", + "playable": "exact", } - def filter_listenable(self, queryset, name, value): - queryset = queryset.annotate(files_count=Count("albums__tracks__files")) - if value: - return queryset.filter(files_count__gt=0) - else: - return queryset.filter(files_count=0) + def filter_playable(self, queryset, name, value): + actor = utils.get_actor_from_request(self.request) + return queryset.playable_by(actor, value) class TrackFilter(filters.FilterSet): 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: model = models.Track fields = { "title": ["exact", "iexact", "startswith", "icontains"], - "listenable": ["exact"], + "playable": ["exact"], "artist": ["exact"], "album": ["exact"], } - def filter_listenable(self, queryset, name, value): - queryset = queryset.annotate(files_count=Count("files")) - if value: - return queryset.filter(files_count__gt=0) - else: - return queryset.filter(files_count=0) - - -class ImportBatchFilter(filters.FilterSet): - q = fields.SearchFilter(search_fields=["submitted_by__username", "source"]) + def filter_playable(self, queryset, name, value): + actor = utils.get_actor_from_request(self.request) + return queryset.playable_by(actor, value) + + +class TrackFileFilter(filters.FilterSet): + 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: - model = models.ImportBatch - fields = {"status": ["exact"], "source": ["exact"], "submitted_by": ["exact"]} - - -class ImportJobFilter(filters.FilterSet): - q = fields.SearchFilter(search_fields=["batch__submitted_by__username", "source"]) - - class Meta: - model = models.ImportJob - fields = { - "batch": ["exact"], - "batch__status": ["exact"], - "batch__source": ["exact"], - "batch__submitted_by": ["exact"], - "status": ["exact"], - "source": ["exact"], - } + model = models.TrackFile + fields = [ + "playable", + "import_status", + "mimetype", + "track", + "track_artist", + "album_artist", + "library", + "import_reference", + ] + + def filter_playable(self, queryset, name, value): + actor = utils.get_actor_from_request(self.request) + return queryset.playable_by(actor, value) 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"]) class Meta: model = models.Album - fields = ["listenable", "q", "artist"] - - def filter_listenable(self, queryset, name, value): - queryset = queryset.annotate(files_count=Count("tracks__files")) - if value: - return queryset.filter(files_count__gt=0) - else: - return queryset.filter(files_count=0) + fields = ["playable", "q", "artist"] + + def filter_playable(self, queryset, name, value): + actor = utils.get_actor_from_request(self.request) + return queryset.playable_by(actor, value) diff --git a/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py index d02a17ad2299cd1da67d514e3abc99271ade788a..1878ed020a4bc986131bb036f89746aab21c1b7a 100644 --- a/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py +++ b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py @@ -5,14 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0027_auto_20180515_1808'), - ] + dependencies = [("music", "0027_auto_20180515_1808")] operations = [ migrations.AddField( - model_name='importjob', - name='replace_if_duplicate', + model_name="importjob", + name="replace_if_duplicate", field=models.BooleanField(default=False), - ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0029_auto_20180807_1748.py b/api/funkwhale_api/music/migrations/0029_auto_20180807_1748.py new file mode 100644 index 0000000000000000000000000000000000000000..9dec6ef35c6e5534ec6b61a7a279cc032aad8159 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0029_auto_20180807_1748.py @@ -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", + ), + ), + ] diff --git a/api/funkwhale_api/music/migrations/0030_auto_20180825_1411.py b/api/funkwhale_api/music/migrations/0030_auto_20180825_1411.py new file mode 100644 index 0000000000000000000000000000000000000000..8c799dacbb3a2b139f5a5300bda46eb478b9492d --- /dev/null +++ b/api/funkwhale_api/music/migrations/0030_auto_20180825_1411.py @@ -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", + ), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 8e1577ea672576a228e353d0781aaff699775695..6e9bd3c2c1b1c61abbd7f5c814574de7559d0192 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1,13 +1,14 @@ +import datetime import os -import shutil import tempfile import uuid import markdown import pendulum 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.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver @@ -18,12 +19,18 @@ from taggit.managers import TaggableManager from versatileimagefield.fields import VersatileImageField 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 . import importers, metadata, utils +def empty_dict(): + return {} + + class APIModelMixin(models.Model): mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True) 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()) ) + 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): name = models.CharField(max_length=255) @@ -140,6 +164,23 @@ class AlbumQuerySet(models.QuerySet): def with_tracks_count(self): 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): title = models.CharField(max_length=255) @@ -287,11 +328,24 @@ class Lyrics(models.Model): class TrackQuerySet(models.QuerySet): def for_nested_serialization(self): - return ( - self.select_related() - .select_related("album__artist", "artist") - .prefetch_related("files") + return self.select_related().select_related("album__artist", "artist") + + def annotate_playable_by_actor(self, actor): + 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): @@ -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): + fid = models.URLField(unique=True, max_length=500, null=True, blank=True) uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) - track = models.ForeignKey(Track, related_name="files", on_delete=models.CASCADE) - audio_file = models.FileField(upload_to="tracks/%Y/%m/%d", max_length=255) - source = models.URLField(null=True, blank=True, max_length=500) + track = models.ForeignKey( + Track, related_name="files", on_delete=models.CASCADE, null=True, blank=True + ) + 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) modification_date = models.DateTimeField(auto_now=True) accessed_date = models.DateTimeField(null=True, blank=True) @@ -437,34 +544,68 @@ class TrackFile(models.Model): bitrate = models.IntegerField(null=True, blank=True) acoustid_track_id = models.UUIDField(null=True, blank=True) mimetype = models.CharField(null=True, blank=True, max_length=200) + library = models.ForeignKey( + "library", null=True, blank=True, related_name="files", on_delete=models.CASCADE + ) - library_track = models.OneToOneField( - "federation.LibraryTrack", - related_name="local_track_file", - on_delete=models.CASCADE, - null=True, - blank=True, + # metadata from federation + metadata = JSONField( + default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder + ) + import_date = models.DateTimeField(null=True, blank=True) + # optionnal metadata provided during import + import_metadata = JSONField( + default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder + ) + # status / error details for the import + import_status = models.CharField( + default="pending", choices=TRACK_FILE_IMPORT_STATUS_CHOICES, max_length=25 + ) + # a short reference provided by the client to group multiple files + # in the same import + import_reference = models.CharField(max_length=50, default=get_import_reference) + + # optionnal metadata about import results (error messages, etc.) + import_details = JSONField( + default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder ) - def download_file(self): - # import the track file, since there is not any - # we create a tmp dir for the download - tmp_dir = tempfile.mkdtemp() - data = downloader.download(self.source, target_directory=tmp_dir) - self.duration = data.get("duration", None) - self.audio_file.save( - os.path.basename(data["audio_file_path"]), - File(open(data["audio_file_path"], "rb")), + 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) - return self.audio_file + with remote_response as r: + remote_response.raise_for_status() + extension = utils.get_ext_from_type(self.mimetype) + title = " - ".join( + [self.track.title, self.track.album.title, self.track.artist.name] + ) + filename = "{}.{}".format(title, extension) + tmp_file = tempfile.TemporaryFile() + for chunk in r.iter_content(chunk_size=512): + tmp_file.write(chunk) + self.audio_file.save(filename, tmp_file, save=False) + self.save(update_fields=["audio_file"]) - def get_federation_url(self): - return federation_utils.full_url("/federation/music/file/{}".format(self.uuid)) + def get_federation_id(self): + if self.fid: + return self.fid - @property - def path(self): - return reverse("api:v1:trackfiles-serve", kwargs={"pk": self.pk}) + return federation_utils.full_url("/federation/music/file/{}".format(self.uuid)) @property def filename(self): @@ -483,37 +624,30 @@ class TrackFile(models.Model): if self.source.startswith("file://"): 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): if self.audio_file: return self.audio_file.open() if self.source.startswith("file://"): 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() - if audio_file: - with audio_file as f: - audio_data = utils.get_audio_file_data(f) - if not audio_data: - return - self.duration = int(audio_data["length"]) - self.bitrate = audio_data["bitrate"] - self.size = self.get_file_size() - else: - lt = self.library_track - if lt: - self.duration = lt.get_metadata("length") - self.size = lt.get_metadata("size") - self.bitrate = lt.get_metadata("bitrate") + if not audio_file: + return + audio_data = utils.get_audio_file_data(audio_file) + if not audio_data: + return + return { + "duration": int(audio_data["length"]), + "bitrate": audio_data["bitrate"], + "size": self.get_file_size(), + } def save(self, **kwargs): if not self.mimetype and 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) def get_metadata(self): @@ -522,6 +656,10 @@ class TrackFile(models.Model): return return metadata.Metadata(audio_file) + @property + def listen_url(self): + return self.track.listen_url + "?file={}".format(self.uuid) + IMPORT_STATUS_CHOICES = ( ("pending", "Pending"), @@ -559,6 +697,13 @@ class ImportBatch(models.Model): blank=True, on_delete=models.SET_NULL, ) + library = models.ForeignKey( + "Library", + related_name="import_batches", + null=True, + blank=True, + on_delete=models.CASCADE, + ) class Meta: ordering = ["-creation_date"] @@ -577,7 +722,7 @@ class ImportBatch(models.Model): 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( "/federation/music/import/batch/{}".format(self.uuid) ) @@ -609,10 +754,100 @@ class ImportJob(models.Model): null=True, blank=True, ) + audio_file_size = models.IntegerField(null=True, blank=True) class Meta: 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) def update_batch_status(sender, instance, **kwargs): diff --git a/api/funkwhale_api/music/permissions.py b/api/funkwhale_api/music/permissions.py deleted file mode 100644 index dc589b5dde4b3dbbd7c720cc73684d1b648497e4..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/music/permissions.py +++ /dev/null @@ -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() diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 0661eb8f4dcd8dadc27c52dacec793d3ba9bab9b..c8300b3bf30dc3ac8ca04ad9e12b987487c7d491 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -1,12 +1,13 @@ -from django.db.models import Q +from django.db import transaction from rest_framework import serializers from taggit.models import Tag from versatileimagefield.serializers import VersatileImageFieldSerializer 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") @@ -15,6 +16,7 @@ cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") class ArtistAlbumSerializer(serializers.ModelSerializer): tracks_count = serializers.SerializerMethodField() cover = cover_field + is_playable = serializers.SerializerMethodField() class Meta: model = models.Album @@ -27,11 +29,18 @@ class ArtistAlbumSerializer(serializers.ModelSerializer): "cover", "creation_date", "tracks_count", + "is_playable", ) def get_tracks_count(self, o): 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): albums = ArtistAlbumSerializer(many=True, read_only=True) @@ -41,30 +50,6 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer): 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 Meta: model = models.Artist @@ -72,8 +57,9 @@ class ArtistSimpleSerializer(serializers.ModelSerializer): class AlbumTrackSerializer(serializers.ModelSerializer): - files = TrackFileSerializer(many=True, read_only=True) artist = ArtistSimpleSerializer(read_only=True) + is_playable = serializers.SerializerMethodField() + listen_url = serializers.SerializerMethodField() class Meta: model = models.Track @@ -84,15 +70,26 @@ class AlbumTrackSerializer(serializers.ModelSerializer): "album", "artist", "creation_date", - "files", "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): tracks = serializers.SerializerMethodField() artist = ArtistSimpleSerializer(read_only=True) cover = cover_field + is_playable = serializers.SerializerMethodField() class Meta: model = models.Album @@ -105,6 +102,7 @@ class AlbumSerializer(serializers.ModelSerializer): "release_date", "cover", "creation_date", + "is_playable", ) def get_tracks(self, o): @@ -114,6 +112,12 @@ class AlbumSerializer(serializers.ModelSerializer): ) 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): artist = ArtistSimpleSerializer(read_only=True) @@ -133,10 +137,11 @@ class TrackAlbumSerializer(serializers.ModelSerializer): class TrackSerializer(serializers.ModelSerializer): - files = TrackFileSerializer(many=True, read_only=True) artist = ArtistSimpleSerializer(read_only=True) album = TrackAlbumSerializer(read_only=True) lyrics = serializers.SerializerMethodField() + is_playable = serializers.SerializerMethodField() + listen_url = serializers.SerializerMethodField() class Meta: model = models.Track @@ -147,14 +152,146 @@ class TrackSerializer(serializers.ModelSerializer): "album", "artist", "creation_date", - "files", "position", "lyrics", + "is_playable", + "listen_url", ) def get_lyrics(self, obj): 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 Meta: @@ -176,40 +313,6 @@ class LyricsSerializer(serializers.ModelSerializer): 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): type = serializers.SerializerMethodField() name = serializers.CharField(source="title") @@ -222,33 +325,3 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): def get_type(self, obj): 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)} diff --git a/api/funkwhale_api/music/signals.py b/api/funkwhale_api/music/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..6a68fe60cd8a4661c368a34c7e42de1ac4337bee --- /dev/null +++ b/api/funkwhale_api/music/signals.py @@ -0,0 +1,5 @@ +import django.dispatch + +track_file_import_status_updated = django.dispatch.Signal( + providing_args=["old_status", "new_status", "track_file"] +) diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 2092b6ee76e0ea8db8dff489b95c14bd6837117f..2f5e140cbaff7d055fdc008b21fce3f194b314b2 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -1,20 +1,27 @@ import logging import os -from django.conf import settings -from django.core.files.base import ContentFile +from django.utils import timezone +from django.db import transaction +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.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.audiofile import tasks as audiofile_tasks from funkwhale_api.taskapp import celery from . import lyrics as lyrics_utils from . import models -from . import utils as music_utils +from . import metadata +from . import signals +from . import serializers logger = logging.getLogger(__name__) @@ -34,8 +41,7 @@ def set_acoustid_on_track_file(track_file): return update(result["id"]) -def import_track_from_remote(library_track): - metadata = library_track.metadata +def import_track_from_remote(metadata): try: track_mbid = metadata["recording"]["musicbrainz_id"] assert track_mbid # for null/empty values @@ -52,7 +58,7 @@ def import_track_from_remote(library_track): else: album, _ = models.Album.get_or_create_from_api(mbid=album_mbid) return models.Track.get_or_create_from_title( - library_track.title, artist=album.artist, album=album + metadata["title"], artist=album.artist, album=album )[0] try: @@ -63,130 +69,23 @@ def import_track_from_remote(library_track): else: artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid) 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( - library_track.title, artist=artist, album=album + metadata["title"], artist=artist, album=album )[0] # worst case scenario, we have absolutely no way to link to a # 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( - library_track.album_title, artist=artist + metadata["album_title"], artist=artist ) return models.Track.get_or_create_from_title( - library_track.title, artist=artist, album=album + metadata["title"], artist=artist, album=album )[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): if album.cover and not replace: return @@ -240,37 +139,6 @@ def get_cover_from_fs(dir_path): 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.require_instance(models.Lyrics, "lyrics") def fetch_content(lyrics): @@ -301,7 +169,7 @@ def import_batch_notify_followers(import_batch): collection = federation_serializers.CollectionSerializer( { "actor": library_actor, - "id": import_batch.get_federation_url(), + "id": import_batch.get_federation_id(), "items": track_files, "item_serializer": federation_serializers.AudioSerializer, } @@ -312,9 +180,266 @@ def import_batch_notify_followers(import_batch): "type": "Create", "id": collection["id"], "object": collection, - "actor": library_actor.url, + "actor": library_actor.fid, "to": [f.url], } ).data 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, + }, + }, + ) diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 92187d69a12f004086acc4da7d5cada9cc43c630..6da9ad9493647ad398a35e27faa8f7d04fb0eb7f 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -58,3 +58,13 @@ def get_audio_file_data(f): d["length"] = data.info.length 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 diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 77a82dd21d36b2502eec37d2f188e58d1ab31501..e8ddd00a74040716ad18789922a94ab72dc8795c 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -1,32 +1,25 @@ -import json import logging import urllib from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist 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.utils import timezone -from musicbrainzngs import ResponseError + from rest_framework import mixins +from rest_framework import permissions from rest_framework import settings as rest_settings from rest_framework import views, viewsets from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from taggit.models import Tag -from funkwhale_api.common import utils as funkwhale_utils -from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.common import utils as common_utils +from funkwhale_api.common import permissions as common_permissions 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 permissions as music_permissions -from . import serializers, tasks, utils +from . import filters, models, serializers, tasks, utils logger = logging.getLogger(__name__) @@ -41,107 +34,65 @@ class TagViewSetMixin(object): class ArtistViewSet(viewsets.ReadOnlyModelViewSet): - queryset = models.Artist.objects.with_albums() + queryset = models.Artist.objects.all() serializer_class = serializers.ArtistWithAlbumsSerializer - permission_classes = [ConditionalAuthentication] + permission_classes = [common_permissions.ConditionalAuthentication] filter_class = filters.ArtistFilter 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): queryset = ( - models.Album.objects.all() - .order_by("artist", "release_date") - .select_related() - .prefetch_related("tracks__artist", "tracks__files") + models.Album.objects.all().order_by("artist", "release_date").select_related() ) serializer_class = serializers.AlbumSerializer - permission_classes = [ConditionalAuthentication] + permission_classes = [common_permissions.ConditionalAuthentication] ordering_fields = ("creation_date", "release_date", "title") 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.ListModelMixin, mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, viewsets.GenericViewSet, ): + lookup_field = "uuid" queryset = ( - models.ImportBatch.objects.select_related() + models.Library.objects.all() .order_by("-creation_date") - .annotate(job_count=Count("jobs")) + .annotate(_files_count=Count("files")) + .annotate(_size=Sum("files__size")) ) - serializer_class = serializers.ImportBatchSerializer - permission_classes = (HasUserPermission,) - required_permissions = ["library", "upload"] - permission_operator = "or" - filter_class = filters.ImportBatchFilter - - def perform_create(self, serializer): - serializer.save(submitted_by=self.request.user) - - 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(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 + serializer_class = serializers.LibraryForOwnerSerializer + permission_classes = [ + permissions.IsAuthenticated, + common_permissions.OwnerPermission, + ] + owner_field = "actor.user" + owner_checks = ["read", "write"] 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) + return qs.filter(actor=self.request.user.actor) def perform_create(self, serializer): - source = "file://" + serializer.validated_data["audio_file"].name - serializer.save(source=source) - funkwhale_utils.on_commit( - tasks.import_job_run.delay, import_job_id=serializer.instance.pk - ) + serializer.save(actor=self.request.user.actor) class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): @@ -151,14 +102,13 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): queryset = models.Track.objects.all().for_nested_serialization() serializer_class = serializers.TrackSerializer - permission_classes = [ConditionalAuthentication] + permission_classes = [common_permissions.ConditionalAuthentication] filter_class = filters.TrackFilter ordering_fields = ( "creation_date", "title", - "album__title", "album__release_date", - "position", + "size", "artist__name", ) @@ -169,7 +119,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): if user.is_authenticated and filter_favorites == "true": 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"]) @transaction.non_atomic_requests @@ -228,40 +181,37 @@ def get_file_path(audio_file): return path.encode("utf-8") -def handle_serve(track_file): +def handle_serve(track_file, user): f = track_file # we update the accessed_date f.accessed_date = timezone.now() f.save(update_fields=["accessed_date"]) - mt = f.mimetype - audio_file = 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 - file_path = get_file_path(audio_file) - mt = library_track.audio_mimetype - elif audio_file: - file_path = get_file_path(audio_file) + if f.audio_file: + file_path = get_file_path(f.audio_file) + + elif f.source and ( + f.source.startswith("http://") or f.source.startswith("https://") + ): + # 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 = 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://"): file_path = get_file_path(f.source.replace("file://", "", 1)) + mt = f.mimetype if mt: response = Response(content_type=mt) else: @@ -278,39 +228,93 @@ def handle_serve(track_file): return response -class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ( - models.TrackFile.objects.all() - .select_related("track__artist", "track__album") - .order_by("-id") - ) - serializer_class = serializers.TrackFileSerializer +class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = models.Track.objects.all() + serializer_class = serializers.TrackSerializer authentication_classes = ( rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [SignatureAuthentication] ) - permission_classes = [music_permissions.Listen] + permission_classes = [common_permissions.ConditionalAuthentication] + lookup_field = "uuid" + + def retrieve(self, request, *args, **kwargs): + track = self.get_object() + actor = utils.get_actor_from_request(request) + queryset = track.files.select_related("track__album__artist", "track__artist") + explicit_file = request.GET.get("file") + if explicit_file: + queryset = queryset.filter(uuid=explicit_file) + queryset = queryset.playable_by(actor) + tf = queryset.first() + if not tf: + return Response(status=404) - @detail_route(methods=["get"]) - def serve(self, request, *args, **kwargs): - queryset = models.TrackFile.objects.select_related( - "library_track", "track__album__artist", "track__artist" + 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 ) - try: - return handle_serve(queryset.get(pk=kwargs["pk"])) - except models.TrackFile.DoesNotExist: - return Response(status=404) + 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): queryset = Tag.objects.all().order_by("name") serializer_class = serializers.TagSerializer - permission_classes = [ConditionalAuthentication] + permission_classes = [common_permissions.ConditionalAuthentication] class Search(views.APIView): max_results = 3 - permission_classes = [ConditionalAuthentication] + permission_classes = [common_permissions.ConditionalAuthentication] def get(self, request, *args, **kwargs): query = request.GET["query"] @@ -340,7 +344,6 @@ class Search(views.APIView): models.Track.objects.all() .filter(query_obj) .select_related("artist", "album__artist") - .prefetch_related("files") )[: self.max_results] def get_albums(self, query): @@ -350,7 +353,7 @@ class Search(views.APIView): models.Album.objects.all() .filter(query_obj) .select_related() - .prefetch_related("tracks__files") + .prefetch_related("tracks") )[: self.max_results] def get_artists(self, query): @@ -372,99 +375,3 @@ class Search(views.APIView): ) 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]) diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py index 144b0f049b23e78002fc3f79a87d395c87b141d1..1f12521f050cd5bd4d1236bda53df8dbb52ab330 100644 --- a/api/funkwhale_api/playlists/filters.py +++ b/api/funkwhale_api/playlists/filters.py @@ -8,7 +8,7 @@ from . import models class PlaylistFilter(filters.FilterSet): q = filters.CharFilter(name="_", method="filter_q") - listenable = filters.BooleanFilter(name="_", method="filter_listenable") + playable = filters.BooleanFilter(name="_", method="filter_playable") class Meta: model = models.Playlist @@ -16,10 +16,10 @@ class PlaylistFilter(filters.FilterSet): "user": ["exact"], "name": ["exact", "icontains"], "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")) if value: return queryset.filter(plts_count__gt=0) diff --git a/api/funkwhale_api/providers/audiofile/tasks.py b/api/funkwhale_api/providers/audiofile/tasks.py index ee486345a7cc3535e64edec9a994df7e7b8f935c..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/api/funkwhale_api/providers/audiofile/tasks.py +++ b/api/funkwhale_api/providers/audiofile/tasks.py @@ -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 diff --git a/api/funkwhale_api/providers/urls.py b/api/funkwhale_api/providers/urls.py index 55a1193f503c33784a1876fd6700ab0f28d79041..dc8afeee7ff7fda6afbc41f54964b627747c50b9 100644 --- a/api/funkwhale_api/providers/urls.py +++ b/api/funkwhale_api/providers/urls.py @@ -1,16 +1,10 @@ from django.conf.urls import include, url urlpatterns = [ - url( - r"^youtube/", - include( - ("funkwhale_api.providers.youtube.urls", "youtube"), namespace="youtube" - ), - ), url( r"^musicbrainz/", include( ("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz" ), - ), + ) ] diff --git a/api/funkwhale_api/providers/youtube/__init__.py b/api/funkwhale_api/providers/youtube/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/funkwhale_api/providers/youtube/client.py b/api/funkwhale_api/providers/youtube/client.py deleted file mode 100644 index 2235fcdc83a994ea0da1024962e9d6bd45704184..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/providers/youtube/client.py +++ /dev/null @@ -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() diff --git a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py deleted file mode 100644 index 2d950eb6b202f1f728b4f345733488e689261695..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py +++ /dev/null @@ -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} diff --git a/api/funkwhale_api/providers/youtube/urls.py b/api/funkwhale_api/providers/youtube/urls.py deleted file mode 100644 index d9687ac9f8f0e1af94eef00868ce7c64dbe3d406..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/providers/youtube/urls.py +++ /dev/null @@ -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"), -] diff --git a/api/funkwhale_api/providers/youtube/views.py b/api/funkwhale_api/providers/youtube/views.py deleted file mode 100644 index 5e1982f48e80ca5722c5ebbb712151cd12a02eb2..0000000000000000000000000000000000000000 --- a/api/funkwhale_api/providers/youtube/views.py +++ /dev/null @@ -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() - } - ) diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index b2469a8cade7aad9c27d3aa6e5aed649704477ee..b67bfb83aa355a7831c9be595e517e0107b7d64b 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -177,13 +177,11 @@ class SubsonicViewSet(viewsets.GenericViewSet): @find_object(music_models.Track.objects.all()) def stream(self, request, *args, **kwargs): track = kwargs.pop("obj") - queryset = track.files.select_related( - "library_track", "track__album__artist", "track__artist" - ) + queryset = track.files.select_related("track__album__artist", "track__artist") track_file = queryset.first() if not track_file: 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") @find_object(music_models.Track.objects.all()) diff --git a/api/funkwhale_api/taskapp/celery.py b/api/funkwhale_api/taskapp/celery.py index 98e980f07273c640c08871bf8de0c42abcd344a1..bf7d4da697d0fb541a65a277fcc87b3d7b11c565 100644 --- a/api/funkwhale_api/taskapp/celery.py +++ b/api/funkwhale_api/taskapp/celery.py @@ -2,20 +2,29 @@ from __future__ import absolute_import import functools +import traceback as tb import os - -from celery import Celery +import logging +import celery.app.task from django.apps import AppConfig from django.conf import settings + +logger = logging.getLogger("celery") + if not settings.configured: # set the default Django settings module for the 'celery' program. os.environ.setdefault( "DJANGO_SETTINGS_MODULE", "config.settings.local" ) # 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): diff --git a/api/funkwhale_api/users/dynamic_preferences_registry.py b/api/funkwhale_api/users/dynamic_preferences_registry.py index 08f5730a81f7f9791fbc109eb14321f3de9dad64..4e910577fbf97b062d341b88541473d786da8405 100644 --- a/api/funkwhale_api/users/dynamic_preferences_registry.py +++ b/api/funkwhale_api/users/dynamic_preferences_registry.py @@ -28,3 +28,13 @@ class DefaultPermissions(common_preferences.StringListPreference): 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()] 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." diff --git a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py index b731e327951573b3092d098ab9c9b3c0dfcdf9df..b000024d0d093d5a9a063c14da1f11538edc8a83 100644 --- a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py +++ b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py @@ -5,19 +5,21 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('users', '0007_auto_20180524_2009'), - ] + dependencies = [("users", "0007_auto_20180524_2009")] operations = [ migrations.AddField( - model_name='user', - name='last_activity', + model_name="user", + name="last_activity", field=models.DateTimeField(blank=True, default=None, null=True), ), migrations.AlterField( - model_name='user', - name='permission_library', - field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'), + model_name="user", + name="permission_library", + field=models.BooleanField( + default=False, + help_text="Manage library, delete files, tracks, artists, albums...", + verbose_name="Manage library", + ), ), ] diff --git a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py index e8204c4e4748371b7906d9115b0d529f30a35db7..cb9f12c60566feff60388d0ff92e8dedab77472e 100644 --- a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py +++ b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py @@ -8,24 +8,46 @@ import django.utils.timezone class Migration(migrations.Migration): - dependencies = [ - ('users', '0008_auto_20180617_1531'), - ] + dependencies = [("users", "0008_auto_20180617_1531")] operations = [ migrations.CreateModel( - name='Invitation', + name="Invitation", fields=[ - ('id', models.AutoField(auto_created=True, 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)), + ( + "id", + models.AutoField( + auto_created=True, + 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( - model_name='user', - name='invitation', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'), + model_name="user", + name="invitation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="users", + to="users.Invitation", + ), ), ] diff --git a/api/funkwhale_api/users/migrations/0010_user_avatar.py b/api/funkwhale_api/users/migrations/0010_user_avatar.py index da60439becbe72c694129bc9f372927727810751..e50aa40611d3f2602f864b16b599859c3840999d 100644 --- a/api/funkwhale_api/users/migrations/0010_user_avatar.py +++ b/api/funkwhale_api/users/migrations/0010_user_avatar.py @@ -7,14 +7,22 @@ import funkwhale_api.common.validators class Migration(migrations.Migration): - dependencies = [ - ('users', '0009_auto_20180619_2024'), - ] + dependencies = [("users", "0009_auto_20180619_2024")] operations = [ migrations.AddField( - model_name='user', - 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)]), - ), + model_name="user", + 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 + ) + ], + ), + ) ] diff --git a/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py b/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py index 5b5a1cabc163e2a77679683a98c08e80a9ac9144..5115eed8608e7d1bc46c583f6b0584905e5c59c4 100644 --- a/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py +++ b/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py @@ -10,19 +10,41 @@ import versatileimagefield.fields class Migration(migrations.Migration): dependencies = [ - ('federation', '0006_auto_20180521_1702'), - ('users', '0010_user_avatar'), + ("federation", "0006_auto_20180521_1702"), + ("users", "0010_user_avatar"), ] operations = [ migrations.AddField( - model_name='user', - name='actor', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='federation.Actor'), + model_name="user", + name="actor", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user", + to="federation.Actor", + ), ), migrations.AlterField( - model_name='user', - 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)]), + model_name="user", + 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, + ), + ], + ), ), ] diff --git a/api/funkwhale_api/users/migrations/0012_user_upload_quota.py b/api/funkwhale_api/users/migrations/0012_user_upload_quota.py new file mode 100644 index 0000000000000000000000000000000000000000..64e47343c59591d5e0df9289ae389e5477693355 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0012_user_upload_quota.py @@ -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), + ) + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 3ad56ea6428815d7c5969dcde466b554cf752391..d2e1ac65ca4f1c3fa74c53267c280869f337ca30 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -122,6 +122,8 @@ class User(AbstractUser): blank=True, ) + upload_quota = models.PositiveIntegerField(null=True, blank=True) + def __str__(self): return self.username @@ -182,6 +184,32 @@ class User(AbstractUser): self.last_activity = now 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): return "".join( @@ -229,7 +257,7 @@ def create_actor(user): "type": "Person", "name": user.username, "manually_approves_followers": False, - "url": federation_utils.full_url( + "fid": federation_utils.full_url( reverse("federation:actors-detail", kwargs={"preferred_username": username}) ), "shared_inbox_url": federation_utils.full_url( diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 2f227158448ac69a13ec4fe503219b33408a036b..bcacc3144614c9780034f9a5db1c1c044306671b 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -109,6 +109,16 @@ class UserReadSerializer(serializers.ModelSerializer): 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): def get_email_options(self): return {"extra_email_context": {"funkwhale_url": settings.FUNKWHALE_URL}} diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 20d63d788f349643b9065038102cabfd60a49ce0..3ca0c6b611e24013d81bc2d726f8f4aa1a510bf2 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -31,7 +31,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): @list_route(methods=["get"]) def me(self, request, *args, **kwargs): """Return information about the current user""" - serializer = serializers.UserReadSerializer(request.user) + serializer = serializers.MeSerializer(request.user) return Response(serializer.data) @detail_route(methods=["get", "post", "delete"], url_path="subsonic-token") diff --git a/api/tests/common/test_scripts.py b/api/tests/common/test_scripts.py index 40d9ea0a7aae1f18611a74e956bcd2ac5d77dbdd..cd33cb57e2cf2eb29a83bcf5c931b3967065026f 100644 --- a/api/tests/common/test_scripts.py +++ b/api/tests/common/test_scripts.py @@ -42,3 +42,31 @@ def test_django_permissions_to_user_permissions(factories, command): assert user2.permission_settings is False assert user2.permission_library 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] + ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index fc7e11947c84ed72bbf9446d22d08a6c41e737f9..8ad554d81ceaf13fd7089f60f8c4e3ed9bf1aa06 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,5 +1,7 @@ +import contextlib import datetime import io +import os import PIL import random import shutil @@ -10,6 +12,7 @@ import pytest import requests_mock from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache +from django.core.files import uploadedfile from django.utils import timezone from django.test import client from dynamic_preferences.registries import global_preferences_registry @@ -272,3 +275,38 @@ def avatar(): f.seek(0) yield f 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 diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index 0b99c93409a7ca1eb542fe2607d29a3b54c1293c..6ac244c69fac164b49171b3d0f8dba20f13aa02d 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -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"), } ] + expected[0]["track"]["is_playable"] = False assert response.status_code == 200 assert response.data["results"] == expected diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index 9c7bb70ecc43400215119a2cef9f8403c6a02783..7ca3371943af10b8cdd18adaf05b58e071e64af2 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -1,32 +1,7 @@ -from funkwhale_api.federation import activity, serializers - - -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] +import pytest - 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" +from funkwhale_api.federation import activity, serializers, tasks def test_accept_follow(mocker, factories): @@ -35,5 +10,125 @@ def test_accept_follow(mocker, factories): expected_accept = serializers.AcceptFollowSerializer(follow).data activity.accept_follow(follow) 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 diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index 1244533de785ca227fdaf47258cd25aa10448703..f7d70f1771eb8c907424d8eca0ebe1c038c7cfc5 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -1,12 +1,9 @@ -import pendulum import pytest from django.urls import reverse from django.utils import timezone from rest_framework import exceptions 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): @@ -25,8 +22,8 @@ def test_actor_fetching(r_mock): def test_get_actor(factories, r_mock): actor = factories["federation.Actor"].build() payload = serializers.ActorSerializer(actor).data - r_mock.get(actor.url, json=payload) - new_actor = actors.get_actor(actor.url) + r_mock.get(actor.fid, json=payload) + new_actor = actors.get_actor(actor.fid) assert new_actor.pk is not None 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 actor = factories["federation.Actor"]() 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 get_data.assert_not_called() @@ -49,46 +46,13 @@ def test_get_actor_refresh(factories, preferences, mocker): # actor changed their username in the meantime payload["preferredUsername"] = "New me" 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.last_fetch_date > actor.last_fetch_date 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): mocker.patch( "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), "manually_approves_followers": False, "public_key": "public", - "url": utils.full_url( + "fid": utils.full_url( reverse("federation:instance-actors-detail", kwargs={"actor": "test"}) ), "shared_inbox_url": utils.full_url( @@ -162,7 +126,7 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories): now = timezone.now() mocker.patch("django.utils.timezone.now", return_value=now) data = { - "actor": actor.url, + "actor": actor.fid, "type": "Create", "id": "http://test.federation/activity", "object": { @@ -180,21 +144,21 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories): cc=[], summary=None, sensitive=False, - attributedTo=test_actor.url, + attributedTo=test_actor.fid, attachment=[], - to=[actor.url], + to=[actor.fid], url="https://{}/activities/note/{}".format( 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 = { "@context": serializers.AP_CONTEXT, - "actor": test_actor.url, + "actor": test_actor.fid, "id": "https://{}/activities/note/{}/activity".format( settings.FEDERATION_HOSTNAME, now.timestamp() ), - "to": actor.url, + "to": actor.fid, "type": "Create", "published": now.isoformat(), "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) deliver.assert_called_once_with( expected_activity, - to=[actor.url], + to=[actor.fid], on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(), ) def test_getting_actor_instance_persists_in_db(db): 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: 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 -@pytest.mark.parametrize("value", [False, True]) -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 - - +@pytest.mark.skip("Refactoring in progress") def test_system_actor_handle(mocker, nodb_factories): handler = mocker.patch("funkwhale_api.federation.actors.TestActor.handle_create") 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) assert serializer.is_valid() 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") test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() data = { - "actor": actor.url, + "actor": actor.fid, "type": "Follow", "id": "http://test.federation/user#follows/267", - "object": test_actor.url, + "object": test_actor.fid, } actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) 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( serializers.FollowSerializer(follow_back).data, 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, "type": "Undo", "id": follow_serializer.data["id"] + "/undo", - "actor": follow.actor.url, + "actor": follow.actor.fid, "object": follow_serializer.data, } expected_undo = { "@context": serializers.AP_CONTEXT, "type": "Undo", "id": reverse_follow_serializer.data["id"] + "/undo", - "actor": reverse_follow.actor.url, + "actor": reverse_follow.actor.fid, "object": reverse_follow_serializer.data, } actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor) 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 - - -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 - ) diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..f3acc87310058cd4bc76d78d8dee3a974036af94 --- /dev/null +++ b/api/tests/federation/test_api_serializers.py @@ -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 diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py new file mode 100644 index 0000000000000000000000000000000000000000..44c7a882da87dea6a491bad9fc3f9f16d5ec78e4 --- /dev/null +++ b/api/tests/federation/test_api_views.py @@ -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}) diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py index 95cec5d2ac80d94a7b04de1dd5121c3d270b883b..0d761e70c33fb681f90f168227567836b48bbab6 100644 --- a/api/tests/federation/test_authentication.py +++ b/api/tests/federation/test_authentication.py @@ -36,4 +36,4 @@ def test_authenticate(factories, mocker, api_request): assert user.is_anonymous is True assert actor.public_key == public.decode("utf-8") - assert actor.url == actor_url + assert actor.fid == actor_url diff --git a/api/tests/federation/test_library.py b/api/tests/federation/test_library.py deleted file mode 100644 index 4e187e4792005e82cfe18cf9a066d31a10f8bbd3..0000000000000000000000000000000000000000 --- a/api/tests/federation/test_library.py +++ /dev/null @@ -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"] diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index 61d0aea96dc9c66a3a96ce47230d62ceef6e5e57..eacc88c7c0dd68a98d337ee54c00cf271c7f50ed 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -20,12 +20,37 @@ def test_cannot_duplicate_follow(factories): def test_follow_federation_url(factories): follow = factories["federation.Follow"](local=True) - expected = "{}#follows/{}".format(follow.actor.url, follow.uuid) - - assert follow.get_federation_url() == expected - - -def test_library_model_unique_per_actor(factories): - library = factories["federation.Library"]() - with pytest.raises(db.IntegrityError): - factories["federation.Library"](actor=library.actor) + expected = "{}#follows/{}".format(follow.actor.fid, follow.uuid) + + assert follow.get_federation_id() == expected + + +def test_actor_get_quota(factories): + library = factories["music.Library"]() + factories["music.TrackFile"]( + 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 diff --git a/api/tests/federation/test_permissions.py b/api/tests/federation/test_permissions.py deleted file mode 100644 index 75f76077cf4828049245e82a553f61e11e1e496b..0000000000000000000000000000000000000000 --- a/api/tests/federation/test_permissions.py +++ /dev/null @@ -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 diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..e7ac88bba4be674a2cc89ab63296a2212f1c145c --- /dev/null +++ b/api/tests/federation/test_routes.py @@ -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 diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index b42c843ee6e402e19a8c32d88ba816fc2f5421fd..b67364a2aa671b57a8ea3e20a0794faaff37c940 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -1,8 +1,7 @@ -import pendulum import pytest 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): @@ -31,7 +30,7 @@ def test_actor_serializer_from_ap(db): actor = serializer.build() - assert actor.url == payload["id"] + assert actor.fid == payload["id"] assert actor.inbox_url == payload["inbox"] assert actor.outbox_url == payload["outbox"] 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() - assert actor.url == payload["id"] + assert actor.fid == payload["id"] assert actor.inbox_url == payload["inbox"] assert actor.outbox_url == payload["outbox"] assert actor.followers_url == payload["followers"] @@ -98,7 +97,7 @@ def test_actor_serializer_to_ap(): "endpoints": {"sharedInbox": "https://test.federation/inbox"}, } ac = models.Actor( - url=expected["id"], + fid=expected["id"], inbox_url=expected["inbox"], outbox_url=expected["outbox"], shared_inbox_url=expected["endpoints"]["sharedInbox"], @@ -130,7 +129,7 @@ def test_webfinger_serializer(): "aliases": ["https://test.federation/federation/instance/actor"], } actor = models.Actor( - url=expected["links"][0]["href"], + fid=expected["links"][0]["href"], preferred_username="service", domain="test.federation", ) @@ -149,10 +148,10 @@ def test_follow_serializer_to_ap(factories): "https://w3id.org/security/v1", {}, ], - "id": follow.get_federation_url(), + "id": follow.get_federation_id(), "type": "Follow", - "actor": follow.actor.url, - "object": follow.target.url, + "actor": follow.actor.fid, + "object": follow.target.fid, } assert serializer.data == expected @@ -165,8 +164,8 @@ def test_follow_serializer_save(factories): data = { "id": "https://test.follow", "type": "Follow", - "actor": actor.url, - "object": target.url, + "actor": actor.fid, + "object": target.fid, } serializer = serializers.FollowSerializer(data=data) @@ -188,8 +187,8 @@ def test_follow_serializer_save_validates_on_context(factories): data = { "id": "https://test.follow", "type": "Follow", - "actor": actor.url, - "object": target.url, + "actor": actor.fid, + "object": target.fid, } serializer = serializers.FollowSerializer( 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", {}, ], - "id": follow.get_federation_url() + "/accept", + "id": follow.get_federation_id() + "/accept", "type": "Accept", - "actor": follow.target.url, + "actor": follow.target.fid, "object": serializers.FollowSerializer(follow).data, } @@ -230,9 +229,9 @@ def test_accept_follow_serializer_save(factories): "https://w3id.org/security/v1", {}, ], - "id": follow.get_federation_url() + "/accept", + "id": follow.get_federation_id() + "/accept", "type": "Accept", - "actor": follow.target.url, + "actor": follow.target.fid, "object": serializers.FollowSerializer(follow).data, } @@ -254,7 +253,7 @@ def test_accept_follow_serializer_validates_on_context(factories): "https://w3id.org/security/v1", {}, ], - "id": follow.get_federation_url() + "/accept", + "id": follow.get_federation_id() + "/accept", "type": "Accept", "actor": impostor.url, "object": serializers.FollowSerializer(follow).data, @@ -278,9 +277,9 @@ def test_undo_follow_serializer_representation(factories): "https://w3id.org/security/v1", {}, ], - "id": follow.get_federation_url() + "/undo", + "id": follow.get_federation_id() + "/undo", "type": "Undo", - "actor": follow.actor.url, + "actor": follow.actor.fid, "object": serializers.FollowSerializer(follow).data, } @@ -298,9 +297,9 @@ def test_undo_follow_serializer_save(factories): "https://w3id.org/security/v1", {}, ], - "id": follow.get_federation_url() + "/undo", + "id": follow.get_federation_id() + "/undo", "type": "Undo", - "actor": follow.actor.url, + "actor": follow.actor.fid, "object": serializers.FollowSerializer(follow).data, } @@ -321,7 +320,7 @@ def test_undo_follow_serializer_validates_on_context(factories): "https://w3id.org/security/v1", {}, ], - "id": follow.get_federation_url() + "/undo", + "id": follow.get_federation_id() + "/undo", "type": "Undo", "actor": impostor.url, "object": serializers.FollowSerializer(follow).data, @@ -355,7 +354,7 @@ def test_paginated_collection_serializer(factories): ], "type": "Collection", "id": conf["id"], - "actor": actor.url, + "actor": actor.fid, "totalItems": len(tfs), "current": conf["id"] + "?page=1", "last": conf["id"] + "?page=3", @@ -452,7 +451,7 @@ def test_collection_page_serializer(factories): ], "type": "CollectionPage", "id": conf["id"] + "?page=2", - "actor": actor.url, + "actor": actor.fid, "totalItems": len(tfs), "partOf": conf["id"], "prev": conf["id"] + "?page=1", @@ -472,58 +471,148 @@ def test_collection_page_serializer(factories): assert serializer.data == expected -def test_activity_pub_audio_serializer_to_library_track(factories): - remote_library = factories["federation.Library"]() - audio = factories["federation.Audio"]() - serializer = serializers.AudioSerializer( - data=audio, context={"library": remote_library} +def test_activity_pub_audio_serializer_to_library_track_no_duplicate(factories): + remote_library = factories["music.Library"]() + tf = factories["music.TrackFile"].build(library=remote_library) + data = serializers.AudioSerializer(tf).data + 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) - lt = serializer.save() + library = serializer.save() - assert lt.pk is not None - assert lt.url == audio["id"] - assert lt.library == remote_library - assert lt.audio_url == audio["url"]["href"] - assert lt.audio_mimetype == audio["url"]["mediaType"] - assert lt.metadata == audio["metadata"] - assert lt.title == audio["metadata"]["recording"]["title"] - assert lt.artist_name == audio["metadata"]["artist"]["name"] - assert lt.album_title == audio["metadata"]["release"]["title"] - assert lt.published_date == pendulum.parse(audio["published"]) + assert library.actor == actor + assert library.fid == data["id"] + assert library.files_count == data["totalItems"] + assert library.privacy_level == "everyone" + 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_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} +def test_music_library_serializer_from_private(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": "", + "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 serializer1.is_valid() is True - assert serializer2.is_valid() is True + assert serializer.is_valid(raise_exception=True) - lt1 = serializer1.save() - lt2 = serializer2.save() + library = serializer.save() - assert lt1 == lt2 - assert models.LibraryTrack.objects.count() == 1 + 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): tf = factories["music.TrackFile"]( mimetype="audio/mp3", bitrate=42, duration=43, size=44 ) - library = actors.SYSTEM_ACTORS["library"].get_actor_instance() expected = { "@context": serializers.AP_CONTEXT, "type": "Audio", - "id": tf.get_federation_url(), + "id": tf.get_federation_id(), "name": tf.track.full_name, "published": tf.creation_date.isoformat(), "updated": tf.modification_date.isoformat(), @@ -542,14 +631,14 @@ def test_activity_pub_audio_serializer_to_ap(factories): "bitrate": tf.bitrate, }, "url": { - "href": utils.full_url(tf.path), + "href": utils.full_url(tf.listen_url), "type": "Link", "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 @@ -561,11 +650,10 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories): track__album__mbid=None, track__album__artist__mbid=None, ) - library = actors.SYSTEM_ACTORS["library"].get_actor_instance() expected = { "@context": serializers.AP_CONTEXT, "type": "Audio", - "id": tf.get_federation_url(), + "id": tf.get_federation_id(), "name": tf.track.full_name, "published": tf.creation_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}, "release": {"title": tf.track.album.title, "musicbrainz_id": None}, "recording": {"title": tf.track.title, "musicbrainz_id": None}, - "size": None, + "size": tf.size, "length": None, "bitrate": None, }, "url": { - "href": utils.full_url(tf.path), + "href": utils.full_url(tf.listen_url), "type": "Link", "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 -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): expected = { "@context": [ @@ -708,7 +703,7 @@ def test_local_actor_serializer_to_ap(factories): "endpoints": {"sharedInbox": "https://test.federation/inbox"}, } ac = models.Actor.objects.create( - url=expected["id"], + fid=expected["id"], inbox_url=expected["inbox"], outbox_url=expected["outbox"], shared_inbox_url=expected["endpoints"]["sharedInbox"], @@ -734,3 +729,45 @@ def test_local_actor_serializer_to_ap(factories): serializer = serializers.ActorSerializer(ac) 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]}) diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index bc10eae9564cb4a6ae89882ba6478c92ad9137fd..216163277d8be3043008308424f3879ff2890f62 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -1,155 +1,240 @@ import datetime import os import pathlib +import pytest -from django.core.paginator import Paginator 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) +def test_clean_federation_music_cache_if_no_listen(preferences, factories): + preferences["federation__music_cache_duration"] = 60 + remote_library = factories["music.Library"]() + tf1 = factories["music.TrackFile"]( + library=remote_library, accessed_date=timezone.now() + ) + tf2 = factories["music.TrackFile"]( + library=remote_library, + accessed_date=timezone.now() - datetime.timedelta(minutes=61), + ) + 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 - assert library.tracks.count() == 0 + tasks.clean_music_cache() + tf1.refresh_from_db() + tf2.refresh_from_db() + tf3.refresh_from_db() -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 bool(tf1.audio_file) is True + assert bool(tf2.audio_file) is False + assert bool(tf3.audio_file) is False + assert os.path.exists(path1) is True + assert os.path.exists(path2) is False + assert os.path.exists(path3) is False - assert library.tracks.count() == 0 +def test_clean_federation_music_cache_orphaned(settings, preferences, factories): + preferences["federation__music_cache_duration"] = 60 + path = os.path.join(settings.MEDIA_ROOT, "federation_cache", "tracks") + 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") + os.makedirs(os.path.dirname(keep_path), exist_ok=True) + os.makedirs(os.path.dirname(remove_path), exist_ok=True) + pathlib.Path(keep_path).touch() + pathlib.Path(remove_path).touch() + tf = factories["music.TrackFile"]( + accessed_date=timezone.now(), audio_file__path=keep_path + ) + + tasks.clean_music_cache() -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) + tf.refresh_from_db() + + assert bool(tf.audio_file) is True + assert os.path.exists(tf.audio_file.path) is True + assert os.path.exists(remove_path) is False - 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_handle_in(factories, mocker, now): + mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch") -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) + 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]} + ) - tasks.scan_library_page(library_id=library.pk, page_url=page.data["id"]) + ii1.refresh_from_db() + ii2.refresh_from_db() - lts = list(library.tracks.all().order_by("-published_date")) - assert len(lts) == 5 + 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_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" +def test_handle_in_error(factories, mocker, now): + mocker.patch( + "funkwhale_api.federation.routes.inbox.dispatch", side_effect=Exception() ) - 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) + r1 = factories["users.User"](with_actor=True).actor + r2 = factories["users.User"](with_actor=True).actor - tasks.scan_library_page(library_id=library.pk, page_url=data["id"]) - patched_scan.assert_not_called() + 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) -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) + assert a.inbox_items.filter(is_delivered=False).count() == 2 - tasks.scan_library_page( - library_id=library.pk, page_url=page.data["id"], until=tfs[1].creation_date + +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 ) - 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_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) -def test_clean_federation_music_cache_if_no_listen(preferences, factories): - preferences["federation__music_cache_duration"] = 60 - lt1 = factories["federation.LibraryTrack"](with_audio_file=True) - lt2 = factories["federation.LibraryTrack"](with_audio_file=True) - lt3 = factories["federation.LibraryTrack"](with_audio_file=True) - 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) - path1 = lt1.audio_file.path - path2 = lt2.audio_file.path - path3 = lt3.audio_file.path + 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 + ) - tasks.clean_music_cache() - lt1.refresh_from_db() - lt2.refresh_from_db() - lt3.refresh_from_db() +def test_deliver_to_remote_inbox_inbox_url(factories, r_mock): + activity = factories["federation.Activity"]() + url = "https://test.shared/" + r_mock.post(url) - assert bool(lt1.audio_file) is True - assert bool(lt2.audio_file) is False - assert bool(lt3.audio_file) is False - assert os.path.exists(path1) is True - assert os.path.exists(path2) is False - assert os.path.exists(path3) is False + tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url) + request = r_mock.request_history[0] -def test_clean_federation_music_cache_orphaned(settings, preferences, factories): - preferences["federation__music_cache_duration"] = 60 - path = os.path.join(settings.MEDIA_ROOT, "federation_cache") - 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") - os.makedirs(os.path.dirname(keep_path), exist_ok=True) - os.makedirs(os.path.dirname(remove_path), exist_ok=True) - pathlib.Path(keep_path).touch() - pathlib.Path(remove_path).touch() - lt = factories["federation.LibraryTrack"]( - with_audio_file=True, audio_file__path=keep_path + 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 ) - factories["music.TrackFile"](library_track=lt, accessed_date=timezone.now()) + 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) - tasks.clean_music_cache() + ii.refresh_from_db() + other_ii.refresh_from_db() - lt.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 - assert bool(lt.audio_file) is True - assert os.path.exists(lt.audio_file.path) is True - assert os.path.exists(remove_path) is False + +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 diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py index dbebe0fdc2a5b074c7d392b838df7027b77f1836..e89c52543e92df3862d0b64b196084bd2a2c7a02 100644 --- a/api/tests/federation/test_utils.py +++ b/api/tests/federation/test_utils.py @@ -1,3 +1,4 @@ +from rest_framework import serializers import pytest 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/)", } 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"} diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index a99c71ffb02a926fcccba8b8fc572840a2420749..54534e93b1b9e419e0845bbc0040cce43d3c08ed 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -1,29 +1,8 @@ import pytest from django.core.paginator import Paginator from django.urls import reverse -from django.utils import timezone - -from funkwhale_api.federation import ( - 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) + +from funkwhale_api.federation import actors, serializers, webfinger @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 -@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): clean = mocker.spy(webfinger, "clean_resource") url = reverse("federation:well-known-webfinger") @@ -110,341 +70,114 @@ def test_wellknown_nodeinfo_disabled(db, preferences, api_client): 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"]() - +def test_local_actor_detail(factories, api_client): + user = factories["users.User"](with_actor=True) url = reverse( - "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)} + "federation:actors-detail", + kwargs={"preferred_username": user.actor.preferred_username}, ) - response = superuser_api_client.get(url) + serializer = serializers.ActorSerializer(user.actor) + response = api_client.get(url) assert response.status_code == 200 - assert response.data == serializers.APILibrarySerializer(library).data + assert response.data == serializer.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, - } +def test_local_actor_inbox_post_requires_auth(factories, api_client): + user = factories["users.User"](with_actor=True) url = reverse( - "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)} + "federation:actors-inbox", + kwargs={"preferred_username": user.actor.preferred_username}, ) - response = superuser_api_client.patch(url, data) + response = api_client.post(url, {"hello": "world"}) - assert response.status_code == 200 - library.refresh_from_db() - - for k, v in data.items(): - assert getattr(library, k) == v + assert response.status_code == 403 -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} +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( - "api:v1:federation:libraries-scan", kwargs={"uuid": str(library.uuid)} + "federation:actors-inbox", + kwargs={"preferred_username": user.actor.preferred_username}, ) - response = superuser_api_client.post(url, data) + response = api_client.post(url, {"hello": "world"}, format="json") 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) - ) + patched_receive.assert_called_once_with( + activity={"hello": "world"}, + on_behalf_of=authenticated_actor, + recipient=user.actor, ) - 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() +def test_wellknown_webfinger_local(factories, api_client, settings, mocker): + user = factories["users.User"](with_actor=True) + url = reverse("federation:well-known-webfinger") + response = api_client.get( + url, + data={"resource": "acct:{}".format(user.actor.webfinger_subject)}, + HTTP_ACCEPT="application/jrd+json", + ) + serializer = serializers.ActorWebfingerSerializer(user.actor) assert response.status_code == 200 - assert follow.approved is True - patched_accept.assert_called_once_with(follow) + assert response["Content-Type"] == "application/jrd+json" + assert response.data == serializer.data -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) +@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 - params = {"pending": True} - url = reverse("api:v1:federation:libraries-followers") - response = superuser_api_client.get(url, params) + url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) + response = api_client.get(url) assert response.status_code == 200 - assert len(response.data["results"]) == 0 + assert response.data == expected -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") +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 - 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}}} + url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) + response = api_client.get(url, {"page": 1}) - 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): - user = factories["users.User"](with_actor=True) - url = reverse( - "federation:actors-detail", - kwargs={"preferred_username": user.actor.preferred_username}, - ) - serializer = serializers.ActorSerializer(user.actor) - response = api_client.get(url) +@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 == 200 - assert response.data == serializer.data + assert response.status_code == 403 -def test_wellknown_webfinger_local(factories, api_client, settings, mocker): - user = factories["users.User"](with_actor=True) - url = reverse("federation:well-known-webfinger") - response = api_client.get( - url, - data={"resource": "acct:{}".format(user.actor.webfinger_subject)}, - HTTP_ACCEPT="application/jrd+json", +@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 ) - serializer = serializers.ActorWebfingerSerializer(user.actor) + url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) + response = api_client.get(url, {"page": 1}) - assert response.status_code == 200 - assert response["Content-Type"] == "application/jrd+json" - assert response.data == serializer.data + assert response.status_code == expected diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index baf816fc860ba7d2dbc3b1f6dbdfdc8d2187b541..f8a782ebb430eb0951ee94bdd9dfcac5305fe176 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -17,6 +17,7 @@ def test_permissions(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): tfs = factories["music.TrackFile"].create_batch(size=5) qs = tfs[0].__class__.objects.order_by("-creation_date") diff --git a/api/tests/music/test_activity.py b/api/tests/music/test_activity.py index 8c119394d620b28eac7a594a89bae552e968c3ad..d0da65b92b17aa0234eaa59ad6b6385420750e1e 100644 --- a/api/tests/music/test_activity.py +++ b/api/tests/music/test_activity.py @@ -1,3 +1,7 @@ +from funkwhale_api.music import serializers +from funkwhale_api.music import signals + + def test_get_track_activity_url_mbid(factories): track = factories["music.Track"]() 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) expected = settings.FUNKWHALE_URL + "/tracks/{}".format(track.pk) 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, + }, + }, + ) diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py index 29a712ce6218db293947e98897dc287710b22d58..63bdcc28e1e49d136d970f0cfe185d60e6b14ee4 100644 --- a/api/tests/music/test_api.py +++ b/api/tests/music/test_api.py @@ -1,217 +1,12 @@ -import json import os import pytest from django.urls import reverse -from funkwhale_api.music import models, tasks 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( "route,method", [ @@ -234,9 +29,9 @@ def test_track_file_url_is_restricted_to_authenticated_users( api_client, factories, preferences ): preferences["common__api_authentication_required"] = True - f = factories["music.TrackFile"]() - assert f.audio_file is not None - url = f.path + tf = factories["music.TrackFile"](library__privacy_level="instance") + assert tf.audio_file is not None + url = tf.track.listen_url response = api_client.get(url) 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( logged_in_api_client, factories, preferences ): + actor = logged_in_api_client.user.create_actor() preferences["common__api_authentication_required"] = True - f = factories["music.TrackFile"]() - assert f.audio_file is not None - url = f.path + tf = factories["music.TrackFile"](library__actor=actor) + assert tf.audio_file is not None + url = tf.track.listen_url response = logged_in_api_client.get(url) 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) diff --git a/api/tests/music/test_commands.py b/api/tests/music/test_commands.py index 03a9420dc77e598231fe0ac9056bea67868a3897..0ec4c58d259bf70293738ce54f55101f83204d4c 100644 --- a/api/tests/music/test_commands.py +++ b/api/tests/music/test_commands.py @@ -30,8 +30,10 @@ def test_fix_track_files_bitrate_length(factories, mocker): def test_fix_track_files_size(factories, mocker): - tf1 = factories["music.TrackFile"](size=1) - tf2 = factories["music.TrackFile"](size=None) + tf1 = factories["music.TrackFile"]() + 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() mocker.patch("funkwhale_api.music.models.TrackFile.get_file_size", return_value=2) diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py index 12185f88805db6d5191e39f1c9bb510a91c91d32..1c1e5fea567202fc9e662a933954c1d348d55fc1 100644 --- a/api/tests/music/test_import.py +++ b/api/tests/music/test_import.py @@ -1,249 +1,15 @@ -import json import os import pytest import uuid 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 models -from funkwhale_api.music import tasks 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(): importer = importers.Importer(models.Artist) with pytest.raises(forms.ValidationError): diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 1bd4282fe36b9bde03094a8e6304283ea97e9b59..e4fc6d9cf6d77fea390d3f3ed2affcbe4b783e49 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -2,6 +2,8 @@ import os import pytest +from django.utils import timezone + from funkwhale_api.music import importers, models, tasks 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 -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( "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]) 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 @@ -199,14 +178,6 @@ def test_track_get_file_size(factories): 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): name = "test.mp3" path = os.path.join(DATA_DIR, name) @@ -221,3 +192,230 @@ def test_album_get_image_content(factories): album.refresh_from_db() 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] diff --git a/api/tests/music/test_permissions.py b/api/tests/music/test_permissions.py deleted file mode 100644 index 5f73a361e05bef73a51685f351a4d6a4fcb264d9..0000000000000000000000000000000000000000 --- a/api/tests/music/test_permissions.py +++ /dev/null @@ -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 diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 8705354f7b2090bb756690c7d9283c96e6e33da7..e9b00fe4e49822ba80d2caa643e03669549dbda8 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -1,4 +1,6 @@ +from funkwhale_api.music import models from funkwhale_api.music import serializers +from funkwhale_api.music import tasks 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, "creation_date": to_api_date(album.creation_date), "tracks_count": 1, + "is_playable": None, "cover": { "original": album.cover.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), "title": track.title, "position": track.position, + "is_playable": None, "creation_date": to_api_date(track.creation_date), - "files": [serializers.TrackFileSerializer(tf).data], + "listen_url": track.listen_url, } serializer = serializers.AlbumTrackSerializer(track) assert serializer.data == expected @@ -64,20 +68,54 @@ def test_track_file_serializer(factories, to_api_date): tf = factories["music.TrackFile"]() expected = { - "id": tf.id, - "path": tf.path, - "source": tf.source, + "uuid": str(tf.uuid), "filename": tf.filename, - "track": tf.track.pk, + "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", + "import_date": None, + "import_status": "pending", } serializer = serializers.TrackFileSerializer(tf) 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): track1 = factories["music.Track"](position=2) track2 = factories["music.Track"](position=1, album=track1.album) @@ -88,6 +126,7 @@ def test_album_serializer(factories, to_api_date): "title": album.title, "artist": serializers.ArtistSimpleSerializer(album.artist).data, "creation_date": to_api_date(album.creation_date), + "is_playable": None, "cover": { "original": album.cover.url, "square_crop": album.cover.crop["400x400"].url, @@ -113,9 +152,94 @@ def test_track_serializer(factories, to_api_date): "mbid": str(track.mbid), "title": track.title, "position": track.position, + "is_playable": None, "creation_date": to_api_date(track.creation_date), "lyrics": track.get_lyrics_url(), - "files": [serializers.TrackFileSerializer(tf).data], + "listen_url": track.listen_url, } serializer = serializers.TrackSerializer(track) 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 diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index e91594d4727146c45f7cc36123c406e4524a8f5d..48c524ea7ad9379c21f92a9632ad3af7a071e727 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -1,227 +1,212 @@ +import datetime import os - import pytest +import uuid + +from django.core.paginator import Paginator -from funkwhale_api.music import tasks +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__)) -def test_set_acoustid_on_track_file(factories, mocker, preferences): - preferences["providers_acoustid__api_key"] = "test" - track_file = factories["music.TrackFile"](acoustid_track_id=None) - id = "e475bf79-c1ce-4441-bed7-1e33f226c0a2" - payload = { - "results": [ - { - "id": id, - "recordings": [ - { - "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) - r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk) - track_file.refresh_from_db() - - assert str(track_file.acoustid_track_id) == id - assert r == id - m.assert_called_once_with("test", track_file.audio_file.path, parse=False) - - -def test_set_acoustid_on_track_file_required_high_score(factories, mocker): - track_file = factories["music.TrackFile"](acoustid_track_id=None) - payload = {"results": [{"score": 0.79}], "status": "ok"} - mocker.patch("acoustid.match", return_value=payload) - tasks.set_acoustid_on_track_file(track_file_id=track_file.pk) - track_file.refresh_from_db() - - assert track_file.acoustid_track_id is None - - -def test_import_batch_run(factories, mocker): - job = factories["music.ImportJob"]() - mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") - tasks.import_batch_run(import_batch_id=job.batch.pk) - - mocked_job_run.assert_called_once_with(import_job_id=job.pk) - - -@pytest.mark.skip("Acoustid is disabled") -def test_import_job_can_run_with_file_and_acoustid( - artists, albums, tracks, preferences, factories, mocker -): - preferences["providers_acoustid__api_key"] = "test" - path = os.path.join(DATA_DIR, "test.ogg") - mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" - acoustid_payload = { - "results": [ - { - "id": "e475bf79-c1ce-4441-bed7-1e33f226c0a2", - "recordings": [{"duration": 268, "id": mbid}], - "score": 0.860825, - } - ], - "status": "ok", +# 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.utils.get_audio_file_data", - return_value={"bitrate": 42, "length": 43}, - ) - mocker.patch( - "funkwhale_api.musicbrainz.api.artists.get", - return_value=artists["get"]["adhesive_wombat"], + "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" ) + track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg")) + + assert track.title == metadata["title"][0] + assert track.mbid is None + assert track.position == 4 + assert track.album.title == metadata["album"][0] + assert track.album.mbid is None + assert track.album.release_date == datetime.date(2012, 8, 15) + assert track.artist.name == metadata["artist"][0] + assert track.artist.mbid is None + + +def test_can_create_track_from_file_metadata_mbid(factories, mocker): + album = factories["music.Album"]() + artist = factories["music.Artist"]() mocker.patch( - "funkwhale_api.musicbrainz.api.releases.get", - return_value=albums["get"]["marsupial"], + "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.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, + "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" ) - 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) + 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__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, + +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}} ) - job = factories["music.FileImportJob"]( - replace_if_duplicate=True, audio_file__path=path + + 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, ) - 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) +def test_track_file_import_get_audio_data(factories, mocker): mocker.patch( - "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", - return_value=track_file.track, + "funkwhale_api.music.models.TrackFile.get_audio_data", + return_value={"size": 23, "duration": 42, "bitrate": 66}, + ) + track = factories["music.Track"]() + tf = factories["music.TrackFile"]( + track=None, import_metadata={"track": {"mbid": track.mbid}} ) - job = factories["music.FileImportJob"](audio_file__path=path) - tasks.import_job_run(import_job_id=job.pk) - job.refresh_from_db() + tasks.import_track_file(track_file_id=tf.pk) - assert job.track_file is None - # audio file is deleted from import job once persisted to audio file - assert not job.audio_file - assert job.status == "skipped" + tf.refresh_from_db() + assert tf.size == 23 + assert tf.duration == 42 + assert tf.bitrate == 66 -def test_import_job_can_be_errored(factories, mocker, preferences): - path = os.path.join(DATA_DIR, "test.ogg") - mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" - factories["music.TrackFile"](track__mbid=mbid) +def test_track_file_import_skip_existing_track_in_own_library(factories, temp_signal): + track = factories["music.Track"]() + library = factories["music.Library"]() + existing = factories["music.TrackFile"]( + track=track, + import_status="finished", + library=library, + import_metadata={"track": {"mbid": track.mbid}}, + ) + duplicate = factories["music.TrackFile"]( + track=track, + import_status="pending", + library=library, + import_metadata={"track": {"mbid": track.mbid}}, + ) + with temp_signal(signals.track_file_import_status_updated) as handler: + tasks.import_track_file(track_file_id=duplicate.pk) - class MyException(Exception): - pass + duplicate.refresh_from_db() - 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, + old_status="pending", + new_status="skipped", + sender=None, + signal=signals.track_file_import_status_updated, + ) - with pytest.raises(MyException): - tasks.import_job_run(import_job_id=job.pk) - job.refresh_from_db() +def test_track_file_import_track_uuid(now, factories): + track = factories["music.Track"]() + tf = factories["music.TrackFile"]( + track=None, import_metadata={"track": {"uuid": track.uuid}} + ) - assert job.track_file is None - assert job.status == "errored" + tasks.import_track_file(track_file_id=tf.pk) + tf.refresh_from_db() -def test__do_import_calls_update_album_cover_if_no_cover(factories, mocker): - path = os.path.join(DATA_DIR, "test.ogg") - album = factories["music.Album"](cover="") - track = factories["music.Track"](album=album) + assert tf.track == track + assert tf.import_status == "finished" + assert tf.import_date == now - mocker.patch( - "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", - return_value=track, - ) - mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover") +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() - job = factories["music.FileImportJob"](audio_file__path=path, track_file=None) + 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, + ) - tasks.import_job_run(import_job_id=job.pk) - mocked_update.assert_called_once_with(album, track.files.first()) +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="") + track = factories["music.Track"](album=album) + tf = factories["music.TrackFile"]( + track=None, import_metadata={"track": {"uuid": track.uuid}} + ) + tasks.import_track_file(track_file_id=tf.pk) + mocked_update.assert_called_once_with(album, tf) 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( 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" diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index aa04521cb571583ba8ce26511c12fe6bb5f2b518..ff49f24648f972fd957de73a7d6f257353ff10f2 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1,26 +1,17 @@ import io +import os import pytest from django.urls import reverse from django.utils import timezone -from funkwhale_api.federation import actors -from funkwhale_api.music import serializers, views +from funkwhale_api.music import serializers, tasks, views - -@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) +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) 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 request = api_request.get("/") 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): - track = factories["music.Track"]() + track = factories["music.TrackFile"](library__privacy_level="everyone").track album = track.album request = api_request.get("/") 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} ) 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") response = logged_in_api_client.get(url) 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): - track = factories["music.Track"]() + track = factories["music.TrackFile"](library__privacy_level="everyone").track request = api_request.get("/") qs = track.__class__.objects.all() serializer = serializers.TrackSerializer( qs, many=True, context={"request": request} ) expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} + expected["results"][0]["is_playable"] = True url = reverse("api:v1:tracks-list") 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")]) -def test_artist_view_filter_listenable(param, expected, factories, api_request): +def test_artist_view_filter_playable(param, expected, factories, api_request): artists = { "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.action_map = {"get": "list"} 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")]) -def test_album_view_filter_listenable(param, expected, factories, api_request): +def test_album_view_filter_playable(param, expected, factories, api_request): artists = { "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.action_map = {"get": "list"} 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( - factories, authenticated_actor, api_client, settings, preferences + factories, authenticated_actor, logged_in_api_client, settings, preferences ): 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"]( approved=True, actor=authenticated_actor, target=library_actor ) - track_file = factories["music.TrackFile"]() - response = api_client.get(track_file.path) + response = logged_in_api_client.get(track_file.track.listen_url) assert response.status_code == 200 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 ): preferences["common__api_authentication_required"] = True - track_file = factories["music.TrackFile"]() - response = api_client.get(track_file.path) + track_file = factories["music.TrackFile"](library__privacy_level="everyone") + response = api_client.get(track_file.track.listen_url) assert response.status_code == 403 @@ -147,9 +145,11 @@ def test_serve_file_in_place( settings.MUSIC_DIRECTORY_PATH = "/app/music" settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path 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[headers[proxy]] == expected @@ -198,9 +198,9 @@ def test_serve_file_media( settings.MUSIC_DIRECTORY_PATH = "/app/music" 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") - response = api_client.get(tf.path) + response = api_client.get(tf.track.listen_url) assert response.status_code == 200 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): 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")) - response = api_client.get(track_file.path) + r_mock.get(url, body=io.BytesIO(b"test")) + 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["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): preferences["common__api_authentication_required"] = False - track_file = factories["music.TrackFile"]() + track_file = factories["music.TrackFile"](library__privacy_level="everyone") now = timezone.now() 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() assert response.status_code == 200 assert track_file.accessed_date > now -def test_can_list_import_jobs(factories, superuser_api_client): - job = factories["music.ImportJob"]() - url = reverse("api:v1:import-jobs-list") - response = superuser_api_client.get(url) +def test_listen_no_track(factories, logged_in_api_client): + url = reverse("api:v1:listen-detail", kwargs={"uuid": "noop"}) + response = logged_in_api_client.get(url) - assert response.status_code == 200 - assert response.data["results"][0]["id"] == job.pk + assert response.status_code == 404 -def test_import_job_stats(factories, superuser_api_client): - factories["music.ImportJob"](status="pending") - factories["music.ImportJob"](status="errored") +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 - 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.data == expected +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) -def test_import_job_stats_filter(factories, superuser_api_client): - job1 = factories["music.ImportJob"](status="pending") - factories["music.ImportJob"](status="errored") + assert response.status_code == 404 - 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.data == expected +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) -def test_import_job_run_via_api(factories, superuser_api_client, mocker): - run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") - job1 = factories["music.ImportJob"](status="errored") - job2 = factories["music.ImportJob"](status="pending") + assert response.status_code == 200 - url = reverse("api:v1:import-jobs-run") - response = superuser_api_client.post(url, {"jobs": [job2.pk, job1.pk]}) - job1.refresh_from_db() - job2.refresh_from_db() +def test_listen_explicit_file(factories, logged_in_api_client, mocker): + mocked_serve = mocker.spy(views, "handle_serve") + 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}) + assert response.status_code == 200 - assert response.data == {"jobs": [job1.pk, job2.pk]} - assert job1.status == "pending" - assert job2.status == "pending" + mocked_serve.assert_called_once_with(tf2, user=logged_in_api_client.user) + - run.assert_any_call(import_job_id=job1.pk) - run.assert_any_call(import_job_id=job2.pk) +def test_user_can_create_library(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + 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 -def test_import_batch_run_via_api(factories, superuser_api_client, mocker): - run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") + 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" - batch = factories["music.ImportBatch"]() - job1 = factories["music.ImportJob"](batch=batch, status="errored") - job2 = factories["music.ImportJob"](batch=batch, status="pending") - url = reverse("api:v1:import-jobs-run") - response = superuser_api_client.post(url, {"batches": [batch.pk]}) +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) - job1.refresh_from_db() - job2.refresh_from_db() assert response.status_code == 200 - assert job1.status == "pending" - assert job2.status == "pending" + assert response.data["count"] == 1 + assert response.data["results"][0]["uuid"] == str(library.uuid) - run.assert_any_call(import_job_id=job1.pk) - run.assert_any_call(import_job_id=job2.pk) +def test_user_cannot_delete_other_actors_library(factories, logged_in_api_client): + logged_in_api_client.user.create_actor() + library = factories["music.Library"](privacy_level="everyone") -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") + url = reverse("api:v1:libraries-detail", kwargs={"uuid": library.uuid}) + response = logged_in_api_client.delete(url) - batch = factories["music.ImportBatch"]() - job1 = factories["music.ImportJob"](batch=batch, status="errored") - job2 = factories["music.ImportJob"](status="pending") + assert response.status_code == 404 - url = reverse("api:v1:import-jobs-run") - response = superuser_api_client.post( - url, {"batches": [batch.pk], "jobs": [job2.pk]} - ) - job1.refresh_from_db() - job2.refresh_from_db() - assert response.status_code == 200 - assert job1.status == "pending" - assert job2.status == "pending" +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"]() - run.assert_any_call(import_job_id=job1.pk) - run.assert_any_call(import_job_id=job2.pk) + 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_import_job_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.ImportJob"]() - url = reverse("api:v1:import-jobs-list") +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) + assert response.status_code == 200 assert response.data["count"] == 0 -def test_import_batch_viewset_get_queryset_upload_filters_user( - factories, logged_in_api_client +def test_user_can_create_track_file( + logged_in_api_client, factories, mocker, audio_file ): - logged_in_api_client.user.permission_upload = True - logged_in_api_client.user.save() + 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") + + response = logged_in_api_client.post( + url, + { + "audio_file": audio_file, + "source": "upload://test", + "import_reference": "test", + "library": library.uuid, + }, + ) - factories["music.ImportBatch"]() - url = reverse("api:v1:import-batches-list") - response = logged_in_api_client.get(url) + assert response.status_code == 201 - assert response.data["count"] == 0 + tf = library.files.latest("id") + + audio_file.seek(0) + assert tf.audio_file.read() == audio_file.read() + assert tf.source == "upload://test" + assert tf.import_reference == "test" + assert tf.track is None + m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk) diff --git a/api/tests/requests/test_models.py b/api/tests/requests/test_models.py index 3ac8a534207acc5152b18ed0eedc8c429463f345..9b9100b5cdfce0eb8370a71e9ff7496eb9aa2774 100644 --- a/api/tests/requests/test_models.py +++ b/api/tests/requests/test_models.py @@ -1,3 +1,7 @@ +import pytest + + +@pytest.mark.skip(reason="Refactoring in progress") def test_can_bind_import_batch_to_request(factories): request = factories["requests.ImportRequest"]() diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 1c7c528ccb8f00afbbe15719b1c06dc9bfde22ac..e2bdcfca468216cf8e350207d304aa78b805c3bd 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -165,7 +165,7 @@ def test_stream(f, db, logged_in_api_client, factories, mocker): tf = factories["music.TrackFile"](track=track) 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 diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index f63e69b635cad1b4534a8f7c1a3aabc6b636fd15..28197731603b6b0965c2515ecef819e8d7647a9e 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -1,91 +1,14 @@ -import datetime import os import pytest from django.core.management import call_command from django.core.management.base import CommandError -from funkwhale_api.providers.audiofile import tasks from funkwhale_api.music.models import ImportJob 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): path = os.path.join(DATA_DIR, "dummy_file.ogg") 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]) +@pytest.mark.skip("Refactoring in progress") def test_import_with_replace_flag(factories, mocker): factories["users.User"](username="me") 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): m = mocker.patch("funkwhale_api.music.tasks.import_job_run") 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 +@pytest.mark.skip("Refactoring in progress") def test_import_files_works_with_utf8_file_name(factories, mocker): m = mocker.patch("funkwhale_api.music.tasks.import_job_run") 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) +@pytest.mark.skip("Refactoring in progress") def test_import_files_in_place(factories, mocker, settings): settings.MUSIC_DIRECTORY_PATH = DATA_DIR m = mocker.patch("funkwhale_api.music.tasks.import_job_run") diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py deleted file mode 100644 index cb5559ce1c2a24fc16e5bdbc326fa3e6c717110d..0000000000000000000000000000000000000000 --- a/api/tests/test_youtube.py +++ /dev/null @@ -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] diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 0d03c0fc2bd06942c17d43a2ee7ccce40779cb7c..5ec0cc0d2c24c47835546c22d95e9cc43d6300c8 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -141,7 +141,7 @@ def test_creating_actor_from_user(factories, settings): assert actor.type == "Person" assert actor.name == user.username assert actor.manually_approves_followers is False - assert actor.url == federation_utils.full_url( + assert actor.fid == federation_utils.full_url( reverse( "federation:actors-detail", kwargs={"preferred_username": actor.preferred_username}, @@ -165,3 +165,46 @@ def test_creating_actor_from_user(factories, settings): 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, + } diff --git a/dev.yml b/dev.yml index 1d9cbba203a2c898cdb83977256760d48153ff5a..e96672393ed11eca3530d4050b6ac8053e2827b4 100644 --- a/dev.yml +++ b/dev.yml @@ -8,9 +8,6 @@ services: - .env environment: - "HOST=0.0.0.0" - - "VUE_PORT=${VUE_PORT-8080}" - ports: - - "${VUE_PORT_BINDING-8080:}${VUE_PORT-8080}" volumes: - "./front:/app" - "/app/node_modules" diff --git a/front/src/App.vue b/front/src/App.vue index 0bfdeb8b2acacb447cbbe53a0c4f8a59bc5c02da..514f52d1c944d3b36d88a911659c04d808d7d8ff 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -318,4 +318,8 @@ html, body { margin: 0.5em; } +a { + cursor: pointer; +} + </style> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 803518528841c08fa7297e84e6c8c5a0b017b82d..a8a3205cf4d9852da79d6ffb8ab55f68d705bd3b 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -59,6 +59,9 @@ <router-link v-if="$store.state.auth.authenticated" class="item" :to="{path: '/activity'}"><i class="bell icon"></i><translate>Activity</translate></router-link> + <router-link + v-if="$store.state.auth.authenticated" + class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate>Add content</translate></router-link> </div> </div> <div class="item" v-if="showAdmin"> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 58a58e7b1dbe30681364ddee7c94658042cd7eb8..3510852b24607b652c901e2bf5743d003d0833bc 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -37,7 +37,8 @@ export default { dropdownOnly: {type: Boolean, default: false}, iconOnly: {type: Boolean, default: false}, artist: {type: Number, required: false}, - album: {type: Number, required: false} + album: {type: Number, required: false}, + isPlayable: {type: Boolean, required: false, default: null} }, data () { return { @@ -60,21 +61,20 @@ export default { return this.$gettext('Play immediatly') } else { if (this.track) { - return this.$gettext('This track is not imported and cannot be played') + return this.$gettext('This track is not available in any library you have access to') } } }, playable () { + if (this.isPlayable) { + return true + } if (this.track) { - return this.track.files.length > 0 + return this.track.is_playable } else if (this.tracks) { - return this.tracks.length > 0 - } else if (this.playlist) { - return true - } else if (this.artist) { - return true - } else if (this.album) { - return true + return this.tracks.filter((t) => { + return t.is_playable + }).length > 0 } return false } @@ -130,7 +130,7 @@ export default { self.isLoading = false }, 250) return tracks.filter(e => { - return e.files.length > 0 + return e.is_playable === true }) }) }, diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index e22cb62c79995a799c2d02b85f2b1a7fe029173e..5626dd4d89bf41b64563aa93faa799d323f4051f 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -29,6 +29,7 @@ export default { let self = this this.sound = new Howl({ src: this.srcs.map((s) => { return s.url }), + format: this.srcs.map((s) => { return s.type }), autoplay: false, loop: false, html5: true, @@ -66,13 +67,13 @@ export default { looping: state => state.player.looping }), srcs: function () { - let file = this.track.files[0] - if (!file) { - this.$store.dispatch('player/trackErrored') - return [] - } + // let file = this.track.files[0] + // if (!file) { + // this.$store.dispatch('player/trackErrored') + // return [] + // } let sources = [ - {type: file.mimetype, url: this.$store.getters['instance/absoluteUrl'](file.path)} + {type: 'mp3', url: this.$store.getters['instance/absoluteUrl'](this.track.listen_url)} ] if (this.$store.state.auth.authenticated) { // we need to send the token directly in url diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index fa71808b3756b450d7d383c0e922b9410af245a3..a52a0dc874375db2c2409d0bfadf7285ca4c5593 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -1,5 +1,5 @@ <template> - <div> + <div class="wrapper"> <h3 class="ui header"> <slot name="title"></slot> </h3> @@ -14,7 +14,7 @@ </div> <div class="card" v-for="album in albums" :key="album.id"> <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover.original}]" :style="getImageStyle(album)"> - <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button> + <play-button class="play-overlay" :icon-only="true" :is-playable="album.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button> </div> <div class="content"> <router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}"> @@ -116,6 +116,9 @@ export default { background-image: url('../../../assets/audio/default-cover.png') !important; } +.wrapper { + width: 100%; +} .ui.cards { justify-content: center; } diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index dd32b4735310d46de665da5aa0da6cf75af7a113..5f8cfe10a5c9954fd909d2082a4f767f26ea3195 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -15,13 +15,13 @@ <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> </td> <td colspan="4"> - <router-link class="discrete link":to="{name: 'library.albums.detail', params: {id: album.id }}"> + <router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}"> <strong>{{ album.title }}</strong> </router-link><br /> {{ album.tracks_count }} tracks </td> <td> - <play-button class="right floated basic icon" :discrete="true" :album="album.id"></play-button> + <play-button class="right floated basic icon" :is-playable="album.is_playable" :discrete="true" :album="album.id"></play-button> </td> </tr> </tbody> @@ -41,7 +41,7 @@ <i class="sound icon"></i> <translate :translate-params="{count: artist.albums.length}" :translate-n="artist.albums.length" translate-plural="%{ count } albums">1 album</translate> </span> - <play-button class="mini basic orange right floated" :artist="artist.id"> + <play-button :is-playable="isPlayable" class="mini basic orange right floated" :artist="artist.id"> <translate>Play all</translate> </play-button> </div> @@ -70,6 +70,11 @@ export default { return this.artist.albums } return this.artist.albums.slice(0, this.initialAlbums) + }, + isPlayable () { + return this.artist.albums.filter((a) => { + return a.is_playable + }).length > 0 } } } diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index 2b49284c8551ed85f0e40f60091f97c19d78a44a..476a3f0bdbebf131b19158c5f0dd9ed4b4bb2276 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -19,40 +19,6 @@ :key="index + '-' + track.id" v-for="(track, index) in tracks"></track-row> </tbody> - <tfoot class="full-width"> - <tr> - <th colspan="3"> - <button @click="showDownloadModal = !showDownloadModal" class="ui basic button"> - <translate>Download</translate> - </button> - <modal :show.sync="showDownloadModal"> - <div class="header"><translate>Download tracks</translate></div> - <div class="content"> - <div class="description"> - <p><translate>There is currently no way to download directly multiple tracks from funkwhale as a ZIP archive. However, you can use a command line tools such as cURL to easily download a list of tracks.</translate></p> - <translate>Simply copy paste the snippet below into a terminal to launch the download.</translate> - <div class="ui warning message"> - <translate>Keep your PRIVATE_TOKEN secret as it gives access to your account.</translate> - </div> - <pre> -export PRIVATE_TOKEN="{{ $store.state.auth.token }}" -<template v-for="track in tracks"><template v-if="track.files.length > 0"> -curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ $store.getters['instance/absoluteUrl'](track.files[0].path) }}"</template></template> -</pre> - </div> - </div> - <div class="actions"> - <div class="ui black deny button"><translate>Cancel</translate></div> - </div> - </modal> - </th> - <th></th> - <th colspan="4"></th> - <th colspan="6"></th> - <th colspan="6"></th> - <th></th> - </tr> - </tfoot> </table> </template> @@ -74,8 +40,7 @@ export default { }, data () { return { - backend: backend, - showDownloadModal: false + backend: backend } } } diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index d74b0dfb749274c7a3dcccd43a7480fca89bed84..e8dec339aaa85d85172af1da44c7c11d8b4dc7b0 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -21,13 +21,14 @@ :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"> <translate>Go</translate></div> <dangerous-button - v-else-if="!currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" + v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" confirm-color="green" color="" @confirm="launchAction"> <translate>Go</translate> <p slot="modal-header"> <translate + key="1" :translate-n="objectsData.count" :translate-params="{count: objectsData.count, action: currentActionName}" translate-plural="Do you want to launch %{ action } on %{ count } elements?"> @@ -44,6 +45,7 @@ <translate tag="span" v-if="selectAll" + key="1" :translate-n="objectsData.count" :translate-params="{count: objectsData.count, total: objectsData.count}" translate-plural="%{ count } on %{ total } selected"> @@ -52,14 +54,16 @@ <translate tag="span" v-else + key="2" :translate-n="checked.length" :translate-params="{count: checked.length, total: objectsData.count}" translate-plural="%{ count } on %{ total } selected"> %{ count } on %{ total } selected </translate> - <template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length"> + <template v-if="currentAction.allowAll && checkable.length > 0 && checkable.length === checked.length"> <a @click="selectAll = true" v-if="!selectAll"> <translate + key="3" :translate-n="objectsData.count" :translate-params="{total: objectsData.count}" translate-plural="Select all %{ total } elements"> @@ -67,7 +71,7 @@ </translate> </a> <a @click="selectAll = false" v-else> - <translate>Select only current page</translate> + <translate key="4">Select only current page</translate> </a> </template> </div> @@ -108,13 +112,13 @@ </tr> </thead> <tbody v-if="objectsData.count > 0"> - <tr v-for="(obj, index) in objectsData.results"> + <tr v-for="(obj, index) in objects"> <td v-if="actions.length > 0" class="collapsing"> <input type="checkbox" - :disabled="checkable.indexOf(obj.id) === -1" - @click="toggleCheck($event, obj.id, index)" - :checked="checked.indexOf(obj.id) > -1"><label> </label> + :disabled="checkable.indexOf(getId(obj)) === -1" + @click="toggleCheck($event, getId(obj), index)" + :checked="checked.indexOf(getId(obj)) > -1"><label> </label> </td> <slot name="row-cells" :obj="obj"></slot> </tr> @@ -127,9 +131,11 @@ import axios from 'axios' export default { props: { actionUrl: {type: String, required: true}, + idField: {type: String, required: true, default: 'id'}, objectsData: {type: Object, required: true}, actions: {type: Array, required: true, default: () => { return [] }}, - filters: {type: Object, required: false, default: () => { return {} }} + filters: {type: Object, required: false, default: () => { return {} }}, + customObjects: {type: Array, required: false, default: () => { return [] }}, }, components: {}, data () { @@ -208,6 +214,9 @@ export default { self.actionLoading = false self.actionErrors = error.backendErrors }) + }, + getId (obj) { + return obj[this.idField] } }, computed: { @@ -218,6 +227,7 @@ export default { })[0] }, checkable () { + let self = this if (!this.currentAction) { return [] } @@ -228,7 +238,19 @@ export default { return filter(o) }) } - return objs.map((o) => { return o.id }) + return objs.map((o) => { return self.getId(o) }) + }, + objects () { + let self = this + return this.objectsData.results.map((o) => { + let custom = self.customObjects.filter((co) => { + return self.getId(co) == self.getId(o) + })[0] + if (custom) { + return custom + } + return o + }) } }, watch: { @@ -238,6 +260,14 @@ export default { this.selectAll = false }, deep: true + }, + currentActionName () { + // we update checked status as some actions have specific filters + // on what is checkable or not + let self = this + this.checked = this.checked.filter(r => { + return self.checkable.indexOf(r) > -1 + }) } } } diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue new file mode 100644 index 0000000000000000000000000000000000000000..e03c199242c5efb4dd8b2663e13786bae4986995 --- /dev/null +++ b/front/src/components/common/ActorLink.vue @@ -0,0 +1,30 @@ +<template> + <span :title="actor.full_username"> + <span :style="defaultAvatarStyle" class="ui circular label">{{ actor.preferred_username[0]}}</span> + {{ actor.full_username | truncate(30) }} + </span> +</template> + +<script> +import {hashCode, intToRGB} from '@/utils/color' + +export default { + props: ['actor'], + computed: { + actorColor () { + return intToRGB(hashCode(this.actor.full_username)) + }, + defaultAvatarStyle () { + return { + 'background-color': `#${this.actorColor}` + } + } + } +} +</script> +<style scoped> +.tiny.circular.avatar { + width: 1.7em; + height: 1.7em; +} +</style> diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..c2db315bd1e11e5b0c0b1910f08d637c4596f481 --- /dev/null +++ b/front/src/components/common/CopyInput.vue @@ -0,0 +1,44 @@ +<template> + <div class="ui fluid action input"> + <p class="message" v-if="copied"> + <translate>Text copied to clipboard!</translate> + </p> + <input ref="input" :value="value" type="text"> + <button @click="copy" class="ui teal right labeled icon button"> + <i class="copy icon"></i> + <translate>Copy</translate> + </button> + </div> +</template> +<script> +export default { + props: ['value'], + data () { + return { + copied: false, + timeout: null + } + }, + methods: { + copy () { + if (this.timeout) { + clearTimeout(this.timeout) + } + this.$refs.input.select() + document.execCommand("Copy") + let self = this + self.copied = true + this.timeout = setTimeout(() => { + self.copied = false + }, 5000) + } + } +} +</script> +<style scoped> +.message { + position: absolute; + right: 0; + bottom: -3em; +} +</style> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 6865ac1bc55f74c20914169d48560b96759ec852..ee2deaacb33b23f790dee63e642e7013b1270972 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -12,6 +12,10 @@ import UserLink from '@/components/common/UserLink' Vue.component('user-link', UserLink) +import ActorLink from '@/components/common/ActorLink' + +Vue.component('actor-link', ActorLink) + import Duration from '@/components/common/Duration' Vue.component('duration', Duration) @@ -24,4 +28,8 @@ import Message from '@/components/common/Message' Vue.component('message', Message) +import CopyInput from '@/components/common/CopyInput' + +Vue.component('copy-input', CopyInput) + export default {} diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 1c4849cc31f946c1069a8a48907ed297bec3b164..2ced8c64345840a90c38b0388732bddcd3743366 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -143,7 +143,7 @@ export default { page_size: this.paginateBy, name__icontains: this.query, ordering: this.getOrderingAsString(), - listenable: 'true' + playable: 'true' } logger.default.debug('Fetching artists') axios.get(url, {params: params}).then((response) => { diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue new file mode 100644 index 0000000000000000000000000000000000000000..e703f671cdf5e52e67e71ee57b6396ce8c9bcb2d --- /dev/null +++ b/front/src/components/library/FileUpload.vue @@ -0,0 +1,304 @@ + <template> + <div> + <div class="ui hidden clearing divider"></div> + <!-- <div v-if="files.length > 0" class="ui indicating progress"> + <div class="bar"></div> + <div class="label"> + {{ uploadedFilesCount }}/{{ files.length }} files uploaded, + {{ processedFilesCount }}/{{ processableFiles }} files processed + </div> + </div> --> + <div class="ui form"> + <div class="fields"> + <div class="ui four wide field"> + <label><translate>Import reference</translate></label> + <input type="text" v-model="importReference" /> + </div> + </div> + + </div> + <p><translate>This reference will be used to group imported files together.</translate></p> + <div class="ui top attached tabular menu"> + <a :class="['item', {active: currentTab === 'uploads'}]" @click="currentTab = 'uploads'"> + <translate>Uploading</translate> + <div v-if="files.length === 0" class="ui label"> + 0 + </div> + <div v-else-if="files.length > uploadedFilesCount + erroredFilesCount" class="ui yellow label"> + {{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }} + </div> + <div v-else :class="['ui', {'green': erroredFilesCount === 0}, {'red': erroredFilesCount > 0}, 'label']"> + {{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }} + </div> + </a> + <a :class="['item', {active: currentTab === 'processing'}]" @click="currentTab = 'processing'"> + <translate>Processing</translate> + <div v-if="processableFiles === 0" class="ui label"> + 0 + </div> + <div v-else-if="processableFiles > processedFilesCount" class="ui yellow label"> + {{ processedFilesCount }}/{{ processableFiles }} + </div> + <div v-else :class="['ui', {'green': trackFiles.errored === 0}, {'red': trackFiles.errored > 0}, 'label']"> + {{ processedFilesCount }}/{{ processableFiles }} + </div> + </a> + </div> + <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]"> + <div class="ui container"> + <file-upload-widget + :class="['ui', 'icon', 'basic', 'button']" + :post-action="uploadUrl" + :multiple="true" + :data="uploadData" + :drop="true" + accept="audio/*" + v-model="files" + name="audio_file" + :thread="1" + @input-filter="inputFilter" + @input-file="inputFile" + ref="upload"> + <i class="upload icon"></i> + <translate>Click to select files to upload or drag and drop files or directories</translate> + </file-upload-widget> + </div> + + <table v-if="files.length > 0" class="ui single line table"> + <thead> + <tr> + <th><translate>File name</translate></th> + <th><translate>Size</translate></th> + <th><translate>Status</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="(file, index) in sortedFiles" :key="file.id"> + <td :title="file.name">{{ file.name | truncate(60) }}</td> + <td>{{ file.size | humanSize }}</td> + <td> + <span v-if="file.error" class="ui tooltip" :data-tooltip="labels.tooltips[file.error]"> + <span class="ui red icon label"> + <i class="question circle outline icon" /> {{ file.error }} + </span> + </span> + <span v-else-if="file.success" class="ui green label"> + <translate key="1">Uploaded</translate> + </span> + <span v-else-if="file.active" class="ui yellow label"> + <translate key="2">Uploading...</translate> + </span> + <template v-else> + <span class="ui label"><translate key="3">Pending</translate></span> + <button class="ui tiny basic red icon button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button> + </template> + </td> + </tr> + </tbody> + </table> + + </div> + <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]"> + <library-files-table + :key="String(processTimestamp)" + :filters="{import_reference: importReference}" + :custom-objects="Object.values(trackFiles.objects)"></library-files-table> + </div> + </div> +</template> + +<script> +import $ from 'jquery' +import axios from 'axios' +import logger from '@/logging' +import FileUploadWidget from './FileUploadWidget' +import LibraryFilesTable from '@/views/content/libraries/FilesTable' +import moment from 'moment' +import { WebSocketBridge } from 'django-channels' + +export default { + props: ['library', 'defaultImportReference'], + components: { + FileUploadWidget, + LibraryFilesTable + }, + data () { + let importReference = this.defaultImportReference || moment().format() + this.$router.replace({query: {import: importReference}}) + return { + files: [], + currentTab: 'uploads', + uploadUrl: '/api/v1/track-files/', + importReference, + trackFiles: { + pending: 0, + finished: 0, + skipped: 0, + errored: 0, + objects: {}, + }, + bridge: null, + processTimestamp: new Date() + } + }, + created () { + this.openWebsocket() + this.fetchStatus() + }, + destroyed () { + this.disconnect() + }, + + methods: { + inputFilter (newFile, oldFile, prevent) { + if (newFile && !oldFile) { + let extension = newFile.name.split('.').pop() + if (['ogg', 'mp3', 'flac'].indexOf(extension) < 0) { + prevent() + } + } + }, + inputFile (newFile, oldFile) { + this.$refs.upload.active = true + }, + fetchStatus () { + let self = this + let statuses = ['pending', 'errored', 'skipped', 'finished'] + statuses.forEach((status) => { + axios.get('track-files/', {params: {import_reference: self.importReference, import_status: status, page_size: 1}}).then((response) => { + self.trackFiles[status] = response.data.count + }) + }) + }, + updateProgressBar () { + $(this.$el).find('.progress').progress({ + total: this.files.length * 2, + value: this.uploadedFilesCount + this.finishedJobs + }) + }, + disconnect () { + if (!this.bridge) { + return + } + this.bridge.socket.close(1000, 'goodbye', {keepClosed: true}) + }, + openWebsocket () { + this.disconnect() + let self = this + let token = this.$store.state.auth.token + const bridge = new WebSocketBridge() + this.bridge = bridge + let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`) + url = url.replace('http://', 'ws://') + url = url.replace('https://', 'wss://') + bridge.connect(url) + bridge.listen(function (event) { + self.handleEvent(event) + }) + bridge.socket.addEventListener('open', function () { + console.log('Connected to WebSocket') + }) + }, + handleEvent (event) { + console.log('Received event', event.type, event) + let self = this + if (event.type === 'import.status_updated') { + if (event.track_file.import_reference != self.importReference) { + return + } + this.$nextTick(() => { + self.trackFiles[event.old_status] -= 1 + self.trackFiles[event.new_status] += 1 + self.trackFiles.objects[event.track_file.uuid] = event.track_file + self.triggerReload() + }) + } + }, + triggerReload: _.throttle(function () { + this.processTimestamp = new Date() + }, 10000, {'leading': true}) + }, + computed: { + labels () { + let denied = this.$gettext('Upload refused, ensure the file is not too big and you have not reached your quota') + let server = this.$gettext('Impossible to upload this file, ensure it is not too big') + let network = this.$gettext('A network error occured while uploading this file') + let timeout = this.$gettext('Upload timeout, please try again') + return { + tooltips: { + denied, + server, + network, + timeout + } + } + }, + uploadedFilesCount () { + return this.files.filter((f) => { + return f.success + }).length + }, + uploadingFilesCount () { + return this.files.filter((f) => { + return !f.success && !f.error + }).length + }, + erroredFilesCount () { + return this.files.filter((f) => { + return f.error + }).length + }, + processableFiles () { + return this.trackFiles.pending + this.trackFiles.skipped + this.trackFiles.errored + this.trackFiles.finished + this.uploadedFilesCount + }, + processedFilesCount () { + return this.trackFiles.skipped + this.trackFiles.errored + this.trackFiles.finished + }, + uploadData: function () { + return { + 'library': this.library.uuid, + 'import_reference': this.importReference, + } + }, + sortedFiles () { + // return errored files on top + return this.files.sort((f) => { + if (f.errored) { + return -5 + } + if (f.success) { + return 5 + } + return 0 + }) + } + }, + watch: { + uploadedFilesCount () { + this.updateProgressBar() + }, + finishedJobs () { + this.updateProgressBar() + }, + importReference: _.debounce(function () { + this.$router.replace({query: {import: this.importReference}}) + }, 500) + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +.file-uploads.ui.button { + display: block; + padding: 2em 1em; + width: 100%; + box-shadow: none; + border-style: dashed !important; + border: 3px solid rgba(50, 50, 50, 0.5); + font-size: 1.5em; +} +.segment.hidden { + display: none; +} +</style> diff --git a/front/src/components/library/import/FileUploadWidget.vue b/front/src/components/library/FileUploadWidget.vue similarity index 85% rename from front/src/components/library/import/FileUploadWidget.vue rename to front/src/components/library/FileUploadWidget.vue index 1de8090c9c91939776818aa22d4e0dd20db9188d..93bead3e7a8bb6b7a263df698761e8d4853a1b72 100644 --- a/front/src/components/library/import/FileUploadWidget.vue +++ b/front/src/components/library/FileUploadWidget.vue @@ -19,7 +19,9 @@ export default { form.append(key, value) } } - form.append(this.name, file.file, file.file.filename || file.name) + let filename = file.file.filename || file.name + form.append('source', `upload://${filename}`) + form.append(this.name, file.file, filename) let xhr = new XMLHttpRequest() xhr.open('POST', file.postAction) xhr.setRequestHeader('Authorization', this.$store.getters['auth/header']) diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index 0bb16e1dd9e01d316169c45151a45da5a1114cb6..e11127608818e9024ba38f3c85ecef5860a7f7f2 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -13,7 +13,7 @@ </track-widget> </div> <div class="column"> - <playlist-widget :url="'playlists/'" :filters="{scope: 'user', listenable: true, ordering: '-creation_date'}"> + <playlist-widget :url="'playlists/'" :filters="{scope: 'user', playable: true, ordering: '-creation_date'}"> <template slot="title"><translate>Playlists</translate></template> </playlist-widget> </div> @@ -21,7 +21,7 @@ <div class="ui section hidden divider"></div> <div class="ui grid"> <div class="ui row"> - <album-widget :filters="{ordering: '-creation_date'}"> + <album-widget :filters="{playable: true, ordering: '-creation_date'}"> <template slot="title"><translate>Recently added</translate></template> </album-widget> </div> @@ -72,7 +72,7 @@ export default { this.isLoadingArtists = true let params = { ordering: '-creation_date', - listenable: true + playable: true } let url = ARTISTS_URL logger.default.time('Loading latest artists') diff --git a/front/src/components/library/import/ArtistImport.vue b/front/src/components/library/import/ArtistImport.vue deleted file mode 100644 index f86f71cce1000fb2c998bdbca1969add1426f7f2..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/ArtistImport.vue +++ /dev/null @@ -1,170 +0,0 @@ -<template> - <div> - <h3 class="ui dividing block header"> - <a :href="getMusicbrainzUrl('artist', metadata.id)" target="_blank" :title="labels.viewOnMusicbrainz">{{ metadata.name }}</a> - </h3> - <form class="ui form" @submit.prevent=""> - <h6 class="ui header"> - <translate>Filter album types</translate> - </h6> - <div class="inline fields"> - <div class="field" v-for="t in availableReleaseTypes"> - <div class="ui checkbox"> - <input type="checkbox" :value="t" v-model="releaseTypes" /> - <label>{{ t }}</label> - </div> - </div> - <div class="field"> - <label><translate>Query template</translate></label> - <input v-model="customQueryTemplate" /> - </div> - </div> - </form> - <template - v-for="release in releases"> - <release-import - :key="release.id" - :metadata="release" - :backends="backends" - :defaultEnabled="false" - :default-backend-id="defaultBackendId" - :query-template="customQueryTemplate" - @import-data-changed="recordReleaseData" - @enabled="recordReleaseEnabled" - ></release-import> - <div class="ui divider"></div> - </template> - </div> -</template> - -<script> -import Vue from 'vue' -import axios from 'axios' -import logger from '@/logging' - -import ImportMixin from './ImportMixin' -import ReleaseImport from './ReleaseImport' - -export default Vue.extend({ - mixins: [ImportMixin], - components: { - ReleaseImport - }, - data () { - return { - releaseImportData: [], - releaseGroupsData: {}, - releases: [], - releaseTypes: ['Album'], - availableReleaseTypes: [ - 'Album', - 'Live', - 'Compilation', - 'EP', - 'Single', - 'Other'] - } - }, - created () { - this.fetchReleaseGroupsData() - }, - methods: { - recordReleaseData (release) { - let existing = this.releaseImportData.filter(r => { - return r.releaseId === release.releaseId - })[0] - if (existing) { - existing.tracks = release.tracks - } else { - this.releaseImportData.push({ - releaseId: release.releaseId, - enabled: true, - tracks: release.tracks - }) - } - }, - recordReleaseEnabled (release, enabled) { - let existing = this.releaseImportData.filter(r => { - return r.releaseId === release.releaseId - })[0] - if (existing) { - existing.enabled = enabled - } else { - this.releaseImportData.push({ - releaseId: release.releaseId, - enabled: enabled, - tracks: release.tracks - }) - } - }, - fetchReleaseGroupsData () { - let self = this - this.releaseGroups.forEach(group => { - let url = 'providers/musicbrainz/releases/browse/' + group.id + '/' - return axios.get(url).then((response) => { - logger.default.info('successfully fetched release group', group.id) - let release = response.data['release-list'].filter(r => { - return r.status === 'Official' - })[0] - self.releaseGroupsData[group.id] = release - self.releases = self.computeReleaseData() - }, (response) => { - logger.default.error('error while fetching release group', group.id) - }) - }) - }, - computeReleaseData () { - let self = this - let releases = [] - this.releaseGroups.forEach(group => { - let data = self.releaseGroupsData[group.id] - if (data) { - releases.push(data) - } - }) - return releases - } - }, - computed: { - labels () { - return { - viewOnMusicbrainz: this.$gettext('View on MusicBrainz') - } - }, - type () { - return 'artist' - }, - releaseGroups () { - let self = this - return this.metadata['release-group-list'].filter(r => { - return self.releaseTypes.indexOf(r.type) !== -1 - }).sort(function (a, b) { - if (a['first-release-date'] < b['first-release-date']) { - return -1 - } - if (a['first-release-date'] > b['first-release-date']) { - return 1 - } - return 0 - }) - }, - importData () { - let releases = this.releaseImportData.filter(r => { - return r.enabled - }) - return { - artistId: this.metadata.id, - count: releases.reduce(function (a, b) { - return a + b.tracks.length - }, 0), - albums: releases - } - } - }, - watch: { - releaseTypes (newValue) { - this.fetchReleaseGroupsData() - } - } -}) -</script> diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue deleted file mode 100644 index fc5801ed18143be8dc911b00f66c7c828e02a18a..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/BatchDetail.vue +++ /dev/null @@ -1,275 +0,0 @@ -<template> - <div v-title="labels.title"> - <div v-if="isLoading && !batch" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - <div v-if="batch" class="ui vertical stripe segment"> - <table class="ui very basic table"> - <tbody> - <tr> - <td> - <strong><translate>Import batch</translate></strong> - </td> - <td> - #{{ batch.id }} - </td> - </tr> - <tr> - <td> - <strong><translate>Launch date</translate></strong> - </td> - <td> - <human-date :date="batch.creation_date"></human-date> - </td> - </tr> - <tr v-if="batch.user"> - <td> - <strong><translate>Submitted by</translate></strong> - </td> - <td> - <username :username="batch.user.username" /> - </td> - </tr> - <tr v-if="stats"> - <td><strong><translate>Pending</translate></strong></td> - <td>{{ stats.pending }}</td> - </tr> - <tr v-if="stats"> - <td><strong><translate>Skipped</translate></strong></td> - <td>{{ stats.skipped }}</td> - </tr> - <tr v-if="stats"> - <td><strong><translate>Errored</translate></strong></td> - <td> - {{ stats.errored }} - <button - @click="rerun({batches: [batch.id], jobs: []})" - v-if="stats.errored > 0 || stats.pending > 0" - class="ui tiny basic icon button"> - <i class="redo icon" /> - <translate>Rerun errored jobs</translate> - </button> - </td> - </tr> - <tr v-if="stats"> - <td><strong><translate>Finished</translate></strong></td> - <td>{{ stats.finished }}/{{ stats.count}}</td> - </tr> - </tbody> - </table> - <div class="ui inline form"> - <div class="fields"> - <div class="ui field"> - <label><translate>Search</translate></label> - <input type="text" v-model="jobFilters.search" :placeholder="labels.searchPlaceholder" /> - </div> - <div class="ui field"> - <label><translate>Status</translate></label> - <select class="ui dropdown" v-model="jobFilters.status"> - <option :value="null"><translate>Any</translate></option> - <option :value="'pending'"><translate>Pending</translate></option> - <option :value="'errored'"><translate>Errored</translate></option> - <option :value="'finished'"><translate>Success</translate></option> - <option :value="'skipped'"><translate>Skipped</translate></option> - </select> - </div> - </div> - </div> - <table v-if="jobResult" class="ui unstackable table"> - <thead> - <tr> - <th><translate>Job ID</translate></th> - <th><translate>Recording MusicBrainz ID</translate></th> - <th><translate>Source</translate></th> - <th><translate>Status</translate></th> - <th><translate>Track</translate></th> - </tr> - </thead> - <tbody> - <tr v-for="job in jobResult.results"> - <td>{{ job.id }}</th> - <td> - <a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a> - </td> - <td> - <a :title="job.source" :href="job.source" target="_blank"> - {{ job.source|truncate(50) }} - </a> - </td> - <td> - <span - :class="['ui', {'yellow': job.status === 'pending'}, {'red': job.status === 'errored'}, {'green': job.status === 'finished'}, 'label']"> - {{ job.status }}</span> - <button - @click="rerun({batches: [], jobs: [job.id]})" - v-if="['errored', 'pending'].indexOf(job.status) > -1" - :title="labels.rerun" - class="ui tiny basic icon button"> - <i class="redo icon" /> - </button> - </td> - <td> - <router-link v-if="job.track_file" :to="{name: 'library.tracks.detail', params: {id: job.track_file.track }}">{{ job.track_file.track }}</router-link> - </td> - </tr> - </tbody> - <tfoot class="full-width"> - <tr> - <th> - <pagination - v-if="jobResult && jobResult.count > jobFilters.paginateBy" - @page-changed="selectPage" - :compact="true" - :current="jobFilters.page" - :paginate-by="jobFilters.paginateBy" - :total="jobResult.count" - ></pagination> - </th> - <th v-if="jobResult && jobResult.results.length > 0"> - <translate - :translate-params="{start: ((jobFilters.page-1) * jobFilters.paginateBy) + 1, end: ((jobFilters.page-1) * jobFilters.paginateBy) + jobResult.results.length, total: jobResult.count}"> - Showing results %{ start }-%{ end } on %{ total } - </translate> - <th> - <th></th> - <th></th> - <th></th> - </tr> - </tfoot> - </table> - </div> - </div> -</template> - -<script> -import _ from 'lodash' -import axios from 'axios' -import logger from '@/logging' -import Pagination from '@/components/Pagination' - -export default { - props: ['id'], - components: { - Pagination - }, - data () { - return { - isLoading: true, - batch: null, - stats: null, - jobResult: null, - timeout: null, - jobFilters: { - status: null, - source: null, - search: '', - paginateBy: 25, - page: 1 - } - } - }, - created () { - let self = this - this.fetchData().then(() => { - self.fetchJobs() - self.fetchStats() - }) - }, - destroyed () { - if (this.timeout) { - clearTimeout(this.timeout) - } - }, - computed: { - labels () { - let msg = this.$gettext('Import Batch #%{ id }') - let title = this.$gettextInterpolate(msg, {id: this.id}) - let rerun = this.$gettext('Rerun job') - let searchPlaceholder = this.$gettext('Search by source...') - return { - title, - searchPlaceholder, - rerun - } - } - }, - methods: { - fetchData () { - var self = this - this.isLoading = true - let url = 'import-batches/' + this.id + '/' - logger.default.debug('Fetching batch "' + this.id + '"') - return axios.get(url).then((response) => { - self.batch = response.data - self.isLoading = false - }) - }, - fetchStats () { - var self = this - let url = 'import-jobs/stats/' - axios.get(url, {params: {batch: self.id}}).then((response) => { - let old = self.stats - self.stats = response.data - self.isLoading = false - if (!_.isEqual(old, self.stats)) { - self.fetchJobs() - self.fetchData() - } - if (self.stats.pending > 0) { - self.timeout = setTimeout( - self.fetchStats, - 5000 - ) - } - }) - }, - rerun ({jobs, batches}) { - let payload = { - jobs, batches - } - let self = this - axios.post('import-jobs/run/', payload).then((response) => { - self.fetchStats() - }) - }, - fetchJobs () { - let params = { - batch: this.id, - page_size: this.jobFilters.paginateBy, - page: this.jobFilters.page, - q: this.jobFilters.search - } - if (this.jobFilters.status) { - params.status = this.jobFilters.status - } - if (this.jobFilters.source) { - params.source = this.jobFilters.source - } - let self = this - axios.get('import-jobs/', {params}).then((response) => { - self.jobResult = response.data - }) - }, - selectPage: function (page) { - this.jobFilters.page = page - } - - }, - watch: { - id () { - this.fetchData() - }, - jobFilters: { - handler () { - this.fetchJobs() - }, - deep: true - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> - -</style> diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue deleted file mode 100644 index 9ef6bd9cd3a6338c2324d2944da14272ecf6882a..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/BatchList.vue +++ /dev/null @@ -1,163 +0,0 @@ -<template> - <div v-title="labels.title"> - <div class="ui vertical stripe segment"> - <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - <div class="ui inline form"> - <div class="fields"> - <div class="ui field"> - <label><translate>Search</translate></label> - <input type="text" v-model="filters.search" :placeholder="labels.searchPlaceholder" /> - </div> - <div class="ui field"> - <label><translate>Status</translate></label> - <select class="ui dropdown" v-model="filters.status"> - <option :value="null"><translate>Any</translate></option> - <option :value="'pending'"><translate>Pending</translate></option> - <option :value="'errored'"><translate>Errored</translate></option> - <option :value="'finished'"><translate>Success</translate></option> - </select> - </div> - <div class="ui field"> - <label><translate>Import source</translate></label> - <select class="ui dropdown" v-model="filters.source"> - <option :value="null"><translate>Any</translate></option> - <option :value="'shell'"><translate>CLI</translate></option> - <option :value="'api'"><translate>API</translate></option> - <option :value="'federation'"><translate>Federation</translate></option> - </select> - </div> - </div> - </div> - <div class="ui hidden clearing divider"></div> - <table v-if="result && result.results.length > 0" class="ui unstackable table"> - <thead> - <tr> - <th><translate>ID</translate></th> - <th><translate>Launch date</translate></th> - <th><translate>Jobs</translate></th> - <th><translate>Status</translate></th> - <th><translate>Source</translate></th> - <th><translate>Submitted by</translate></th> - </tr> - </thead> - <tbody> - <tr v-for="obj in result.results"> - <td>{{ obj.id }}</th> - <td> - <router-link :to="{name: 'library.import.batches.detail', params: {id: obj.id }}"> - <human-date :date="obj.creation_date"></human-date> - </router-link> - </td> - <td>{{ obj.job_count }}</td> - <td> - <span - :class="['ui', {'yellow': obj.status === 'pending'}, {'red': obj.status === 'errored'}, {'green': obj.status === 'finished'}, 'label']">{{ obj.status }} - </span> - </td> - <td>{{ obj.source }}</td> - <td><template v-if="obj.submitted_by">{{ obj.submitted_by.username }}</template></td> - </tr> - </tbody> - <tfoot class="full-width"> - <tr> - <th> - <pagination - v-if="result && result.count > filters.paginateBy" - @page-changed="selectPage" - :compact="true" - :current="filters.page" - :paginate-by="filters.paginateBy" - :total="result.count" - ></pagination> - </th> - <th v-if="result && result.results.length > 0"> - <translate - :translate-params="{start: ((filters.page-1) * filters.paginateBy) + 1, end: ((filters.page-1) * filters.paginateBy) + result.results.length, total: result.count}"> - Showing results %{ start }-%{ end } on %{ total } - </translate> - <th> - <th></th> - <th></th> - <th></th> - </tr> - </tfoot> - </table> - </div> - </div> -</template> - -<script> -import axios from 'axios' -import logger from '@/logging' -import Pagination from '@/components/Pagination' - -export default { - components: { - Pagination - }, - data () { - return { - result: null, - isLoading: false, - filters: { - status: null, - source: null, - search: '', - paginateBy: 25, - page: 1 - } - } - }, - created () { - this.fetchData() - }, - computed: { - labels () { - let searchPlaceholder = this.$gettext('Search by submitter, source...') - let title = this.$gettext('Import Batches') - return { - searchPlaceholder, - title - } - } - }, - methods: { - fetchData () { - let params = { - page_size: this.filters.paginateBy, - page: this.filters.page, - q: this.filters.search - } - if (this.filters.status) { - params.status = this.filters.status - } - if (this.filters.source) { - params.source = this.filters.source - } - var self = this - this.isLoading = true - logger.default.time('Loading import batches') - axios.get('import-batches/', {params}).then((response) => { - self.result = response.data - logger.default.timeEnd('Loading import batches') - self.isLoading = false - }) - }, - selectPage: function (page) { - this.filters.page = page - } - }, - watch: { - filters: { - handler () { - this.fetchData() - }, - deep: true - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/components/library/import/FileUpload.vue b/front/src/components/library/import/FileUpload.vue deleted file mode 100644 index a9b685523ecac9181ba0260402c0942d739fa80c..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/FileUpload.vue +++ /dev/null @@ -1,144 +0,0 @@ -<template> - <div> - <div v-if="batch" class="ui container"> - <div class="ui message"> - <translate>Ensure your music files are properly tagged before uploading them.</translate> - <a href="http://picard.musicbrainz.org/" target='_blank'><translate>We recommend using Picard for that purpose.</translate></a> - </div> - <file-upload-widget - :class="['ui', 'icon', 'left', 'floated', 'button']" - :post-action="uploadUrl" - :multiple="true" - :data="uploadData" - :drop="true" - extensions="ogg,mp3,flac" - accept="audio/*" - v-model="files" - name="audio_file" - :thread="1" - @input-filter="inputFilter" - @input-file="inputFile" - ref="upload"> - <i class="upload icon"></i> - <translate>Select files to upload...</translate> - </file-upload-widget> - <button - :class="['ui', 'right', 'floated', 'icon', {disabled: files.length === 0}, 'button']" - v-if="!$refs.upload || !$refs.upload.active" @click.prevent="startUpload()"> - <i class="play icon" aria-hidden="true"></i> - <translate>Start Upload</translate> - </button> - <button type="button" class="ui right floated icon yellow button" v-else @click.prevent="$refs.upload.active = false"> - <i class="pause icon" aria-hidden="true"></i> - <translate>Stop Upload</translate> - </button> - </div> - <div class="ui hidden clearing divider"></div> - <template v-if="batch"><translate>Once all your files are uploaded, simply click the following button to check the import status.</translate></template> - <router-link class="ui basic button" v-if="batch" :to="{name: 'library.import.batches.detail', params: {id: batch.id }}"> - <translate>Import detail page</translate> - </router-link> - <table class="ui single line table"> - <thead> - <tr> - <th><translate>File name</translate></th> - <th><translate>Size</translate></th> - <th><translate>Status</translate></th> - </tr> - </thead> - <tbody> - <tr v-for="(file, index) in files" :key="file.id"> - <td>{{ file.name }}</td> - <td>{{ file.size | humanSize }}</td> - <td> - <span v-if="file.error" class="ui red label"> - {{ file.error }} - </span> - <span v-else-if="file.success" class="ui green label"><translate>Success</translate></span> - <span v-else-if="file.active" class="ui yellow label"><translate>Uploading...</translate></span> - <template v-else> - <span class="ui label"><translate>Pending</translate></span> - <button class="ui tiny basic red icon button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button> - </template> - </td> - </tr> - </tbody> - </table> - </div> -</template> - -<script> -import axios from 'axios' -import logger from '@/logging' -import FileUploadWidget from './FileUploadWidget' - -export default { - components: { - FileUploadWidget - }, - data () { - return { - files: [], - uploadUrl: '/api/v1/import-jobs/', - batch: null - } - }, - mounted: function () { - this.createBatch() - }, - methods: { - inputFilter (newFile, oldFile, prevent) { - if (newFile && !oldFile) { - let extension = newFile.name.split('.').pop() - if (['ogg', 'mp3', 'flac'].indexOf(extension) < 0) { - prevent() - } - } - }, - inputFile (newFile, oldFile) { - if (newFile && !oldFile) { - // add - if (!this.batch) { - this.createBatch() - } - } - if (newFile && oldFile) { - // update - } - if (!newFile && oldFile) { - // remove - } - }, - createBatch () { - let self = this - return axios.post('import-batches/', {}).then((response) => { - self.batch = response.data - }, (response) => { - logger.default.error('error while launching creating batch') - }) - }, - startUpload () { - this.$emit('batch-created', this.batch) - this.$refs.upload.active = true - } - }, - computed: { - batchId: function () { - if (this.batch) { - return this.batch.id - } - return null - }, - uploadData: function () { - return { - 'batch': this.batchId, - 'source': 'file://' - } - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue deleted file mode 100644 index 8b0757dcc9256afa7d09fc4e61eb091668ed7582..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/ImportMixin.vue +++ /dev/null @@ -1,96 +0,0 @@ -<template> - -</template> - -<script> -import axios from 'axios' -import logger from '@/logging' -import router from '@/router' - -export default { - props: { - metadata: {type: Object, required: true}, - defaultEnabled: {type: Boolean, default: true}, - backends: {type: Array}, - defaultBackendId: {type: String}, - queryTemplate: {type: String, default: '$artist $title'}, - request: {type: Object, required: false} - }, - data () { - return { - customQueryTemplate: this.queryTemplate, - currentBackendId: this.defaultBackendId, - isImporting: false, - enabled: this.defaultEnabled - } - }, - methods: { - getMusicbrainzUrl (type, id) { - return 'https://musicbrainz.org/' + type + '/' + id - }, - launchImport () { - let self = this - this.isImporting = true - let url = 'submit/' + self.importType + '/' - let payload = self.importData - if (this.request) { - payload.importRequest = this.request.id - } - axios.post(url, payload).then((response) => { - logger.default.info('launched import for', self.type, self.metadata.id) - self.isImporting = false - router.push({ - name: 'library.import.batches.detail', - params: { - id: response.data.id - } - }) - }, (response) => { - logger.default.error('error while launching import for', self.type, self.metadata.id) - self.isImporting = false - }) - } - }, - computed: { - importType () { - return this.type - }, - currentBackend () { - let self = this - return this.backends.filter(b => { - return b.id === self.currentBackendId - })[0] - }, - realQueryTemplate () { - - } - }, - watch: { - isImporting (newValue) { - this.$emit('import-state-changed', newValue) - }, - importData: { - handler (newValue) { - this.$emit('import-data-changed', newValue) - }, - deep: true - }, - enabled (newValue) { - this.$emit('enabled', this.importData, newValue) - }, - queryTemplate (newValue, oldValue) { - // we inherit from the prop template unless the component changed - // the value - if (oldValue === this.customQueryTemplate) { - // no changed from prop, we keep the sync - this.customQueryTemplate = newValue - } - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> - -</style> diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue deleted file mode 100644 index 08047fd2fc1b09f020c6b8680f24548a5f0ce964..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/Main.vue +++ /dev/null @@ -1,306 +0,0 @@ -<template> - <div v-title="labels.title"> - <div class="ui vertical stripe segment"> - <div class="ui top three attached ordered steps"> - <a @click="currentStep = 0" :class="['step', {'active': currentStep === 0}, {'completed': currentStep > 0}]"> - <div class="content"> - <div class="title"><translate>Import source</translate></div> - <div class="description"><translate>Uploaded files or external source</translate></div> - </div> - </a> - <a @click="currentStep = 1" :class="['step', {'active': currentStep === 1}, {'completed': currentStep > 1}]"> - <div class="content"> - <div class="title"><translate>Metadata</translate></div> - <div class="description"><translate>Grab corresponding metadata</translate></div> - </div> - </a> - <a @click="currentStep = 2" :class="['step', {'active': currentStep === 2}, {'completed': currentStep > 2}]"> - <div class="content"> - <div class="title"><translate>Music</translate></div> - <div class="description"><translate>Select relevant sources or files for import</translate></div> - </div> - </a> - </div> - <div class="ui hidden divider"></div> - <div class="ui centered buttons"> - <button @click="currentStep -= 1" :disabled="currentStep === 0" class="ui icon button"><i class="left arrow icon"></i> - <translate>Previous step</translate> - </button> - <button @click="nextStep()" v-if="currentStep < 2" class="ui icon button"> - <translate>Next step</translate> - <i class="right arrow icon"></i> - </button> - <button - @click="$refs.import.launchImport()" - v-if="currentStep === 2 && currentSource != 'upload'" - :class="['ui', 'positive', 'icon', {'loading': isImporting}, 'button']" - :disabled="isImporting || importData.count === 0" - > - <translate - :translate-params="{count: importData.count || 0}" - :translate-n="importData.count || 0" - translate-plural="Import %{ count } tracks"> - Import %{ count } track - </translate> - <i class="check icon"></i> - </button> - <button - v-else-if="currentStep === 2 && currentSource === 'upload'" - @click="$router.push({name: 'library.import.batches.detail', params: {id: importBatch.id}})" - :class="['ui', 'positive', 'icon', {'disabled': !importBatch}, 'button']" - :disabled="!importBatch" - > - <translate>Finish import</translate> - <i class="check icon"></i> - </button> - </div> - <div class="ui hidden divider"></div> - <div class="ui attached segment"> - <template v-if="currentStep === 0"> - <p><translate>First, choose where you want to import the music from</translate></p> - <form class="ui form"> - <div class="field"> - <div class="ui radio checkbox"> - <input type="radio" id="external" value="external" v-model="currentSource"> - <label for="external"> - <translate>External source. Supported backends</translate> - <div v-for="backend in backends" class="ui basic label"> - <i v-if="backend.icon" :class="[backend.icon, 'icon']"></i> - {{ backend.label }} - </div> - </label> - </div> - </div> - <div class="field"> - <div class="ui radio checkbox"> - <input type="radio" id="upload" value="upload" v-model="currentSource"> - <label for="upload"><translate>File upload</translate></label> - </div> - </div> - </form> - </template> - <div v-if="currentStep === 1" class="ui stackable two column grid"> - <div class="column"> - <form class="ui form" @submit.prevent=""> - <div class="field"> - <label><translate>Search an entity you want to import:</translate></label> - <metadata-search - :mb-type="mbType" - :mb-id="mbId" - @id-changed="updateId" - @type-changed="updateType"></metadata-search> - </div> - </form> - <div class="ui horizontal divider"><translate>Or</translate></div> - <form class="ui form" @submit.prevent=""> - <div class="field"> - <label><translate>Input a MusicBrainz ID manually:</translate></label> - <input type="text" v-model="currentId" /> - </div> - </form> - <div class="ui hidden divider"></div> - <template v-if="currentType && currentId"> - <h4 class="ui header"> - <translate>You will import:</translate> - </h4> - <component - :mbId="currentId" - :is="metadataComponent" - @metadata-changed="this.updateMetadata" - ></component> - </template> - <p><translate>You can also skip this step and enter metadata manually.</translate></p> - </div> - <div class="column"> - <h5 class="ui header"><translate>What is metadata?</translate></h5> - <template v-translate> - Metadata is the data related to the music you want to import. This includes all the information about the artists, albums and tracks. In order to have a high quality library, it is recommended to grab data from the - <a href="https://musicbrainz.org" target="_blank"> - MusicBrainz - </a> - project, which you can think about as the Wikipedia of music. - </template> - </div> - </div> - <div v-if="currentStep === 2"> - <file-upload - ref="import" - @batch-created="updateBatch" - v-if="currentSource == 'upload'" - ></file-upload> - - <component - ref="import" - v-if="currentSource == 'external'" - :request="currentRequest" - :metadata="metadata" - :is="importComponent" - :backends="backends" - :default-backend-id="backends[0].id" - @import-data-changed="updateImportData" - @import-state-changed="updateImportState" - ></component> - </div> - </div> - </div> - <div class="ui vertical stripe segment" v-if="currentRequest"> - <h3 class="ui header"> - <translate>Music request</translate> - </h3> - <p><translate>This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.</translate></p> - <request-card :request="currentRequest" :import-action="false"></request-card> - - </div> - </div> -</template> - -<script> - -import RequestCard from '@/components/requests/Card' -import MetadataSearch from '@/components/metadata/Search' -import ReleaseCard from '@/components/metadata/ReleaseCard' -import ArtistCard from '@/components/metadata/ArtistCard' -import ReleaseImport from './ReleaseImport' -import FileUpload from './FileUpload' -import ArtistImport from './ArtistImport' - -import axios from 'axios' -import router from '@/router' -import $ from 'jquery' - -export default { - components: { - MetadataSearch, - ArtistCard, - ReleaseCard, - ArtistImport, - ReleaseImport, - FileUpload, - RequestCard - }, - props: { - mbType: {type: String, required: false}, - request: {type: String, required: false}, - source: {type: String, required: false}, - mbId: {type: String, required: false} - }, - data: function () { - return { - currentRequest: null, - currentType: this.mbType || 'artist', - currentId: this.mbId, - currentStep: 0, - currentSource: this.source, - metadata: {}, - isImporting: false, - importBatch: null, - importData: { - tracks: [] - }, - backends: [ - { - id: 'youtube', - label: 'YouTube', - icon: 'youtube' - } - ] - } - }, - created () { - if (this.request) { - this.fetchRequest(this.request) - } - if (this.currentSource) { - this.currentStep = 1 - } - }, - mounted: function () { - $(this.$el).find('.ui.checkbox').checkbox() - }, - methods: { - updateRoute () { - router.replace({ - query: { - source: this.currentSource, - type: this.currentType, - id: this.currentId, - request: this.request - } - }) - }, - updateImportData (newValue) { - this.importData = newValue - }, - updateImportState (newValue) { - this.isImporting = newValue - }, - updateMetadata (newValue) { - this.metadata = newValue - }, - updateType (newValue) { - this.currentType = newValue - }, - updateId (newValue) { - this.currentId = newValue - }, - updateBatch (batch) { - this.importBatch = batch - }, - fetchRequest (id) { - let self = this - axios.get(`requests/import-requests/${id}`).then((response) => { - self.currentRequest = response.data - }) - }, - nextStep () { - if (this.currentStep === 0 && this.currentSource === 'upload') { - // we skip metadata directly - this.currentStep += 2 - } else { - this.currentStep += 1 - } - } - }, - computed: { - labels () { - return { - title: this.$gettext('Import Music') - } - }, - metadataComponent () { - if (this.currentType === 'artist') { - return 'ArtistCard' - } - if (this.currentType === 'release') { - return 'ReleaseCard' - } - if (this.currentType === 'recording') { - return 'RecordingCard' - } - }, - importComponent () { - if (this.currentType === 'artist') { - return 'ArtistImport' - } - if (this.currentType === 'release') { - return 'ReleaseImport' - } - if (this.currentType === 'recording') { - return 'RecordingImport' - } - } - }, - watch: { - currentType (newValue) { - this.currentId = '' - this.updateRoute() - }, - currentId (newValue) { - this.updateRoute() - } - } -} -</script> -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/components/library/import/ReleaseImport.vue b/front/src/components/library/import/ReleaseImport.vue deleted file mode 100644 index 0ec78903071224dbdbc613594228801a5c297a7f..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/ReleaseImport.vue +++ /dev/null @@ -1,119 +0,0 @@ -<template> - <div> - <h3 class="ui dividing block header"> - <translate - tag="div" - translate-plural="Album %{ title } (%{ count } tracks) by %{ artist }" - :translate-n="tracks.length" - :translate-params="{count: tracks.length, title: metadata.title, artist: metadata['artist-credit-phrase']}"> - Album %{ title } (%{ count } track) by %{ artist } - </translate> - <div class="ui divider"></div> - <div class="sub header"> - <div class="ui toggle checkbox"> - <input type="checkbox" v-model="enabled" /> - <label><translate>Import this release</translate></label> - </div> - </div> - </h3> - <template - v-if="enabled" - v-for="track in tracks"> - <track-import - :key="track.recording.id" - :metadata="track" - :release-metadata="metadata" - :backends="backends" - :default-backend-id="defaultBackendId" - :query-template="customQueryTemplate" - @import-data-changed="recordTrackData" - @enabled="recordTrackEnabled" - ></track-import> - <div class="ui divider"></div> - </template> - </div> -</template> - -<script> -import Vue from 'vue' -import ImportMixin from './ImportMixin' -import TrackImport from './TrackImport' - -export default Vue.extend({ - mixins: [ImportMixin], - components: { - TrackImport - }, - data () { - return { - trackImportData: [] - } - }, - methods: { - recordTrackData (track) { - let existing = this.trackImportData.filter(t => { - return t.mbid === track.mbid - })[0] - if (existing) { - existing.source = track.source - } else { - this.trackImportData.push({ - mbid: track.mbid, - enabled: true, - source: track.source - }) - } - }, - recordTrackEnabled (track, enabled) { - let existing = this.trackImportData.filter(t => { - return t.mbid === track.mbid - })[0] - if (existing) { - existing.enabled = enabled - } else { - this.trackImportData.push({ - mbid: track.mbid, - enabled: enabled, - source: null - }) - } - } - }, - computed: { - type () { - return 'release' - }, - importType () { - return 'album' - }, - tracks () { - return this.metadata['medium-list'][0]['track-list'] - }, - importData () { - let tracks = this.trackImportData.filter(t => { - return t.enabled - }) - return { - releaseId: this.metadata.id, - count: tracks.length, - tracks: tracks - } - } - }, - watch: { - importData: { - handler (newValue) { - this.$emit('import-data-changed', newValue) - }, - deep: true - } - } -}) -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> -.ui.card { - width: 100% !important; -} -</style> diff --git a/front/src/components/library/import/TrackImport.vue b/front/src/components/library/import/TrackImport.vue deleted file mode 100644 index 10a146344489bb54b6c73a30b7c24ac6d5d76434..0000000000000000000000000000000000000000 --- a/front/src/components/library/import/TrackImport.vue +++ /dev/null @@ -1,206 +0,0 @@ -<template> - <div class="ui stackable grid"> - <div class="three wide column"> - <h5 class="ui header"> - {{ metadata.position }}. {{ metadata.recording.title }} - <div class="sub header"> - {{ time.parse(parseInt(metadata.length) / 1000) }} - </div> - </h5> - <div class="ui toggle checkbox"> - <input type="checkbox" v-model="enabled" /> - <label><translate>Import this track</translate></label> - </div> - </div> - <div class="three wide column" v-if="enabled"> - <form class="ui mini form" @submit.prevent=""> - <div class="field"> - <label><translate>Source</translate></label> - <select v-model="currentBackendId"> - <option v-for="backend in backends" :value="backend.id"> - {{ backend.label }} - </option> - </select> - </div> - </form> - <div class="ui hidden divider"></div> - <template v-if="currentResult"> - <button @click="currentResultIndex -= 1" class="ui basic tiny icon button" :disabled="currentResultIndex === 0"> - <i class="left arrow icon"></i> - </button> - {{ results.total }} - <translate :translate-params="{current: currentResultIndex + 1, total: results.length}"> - Result %{ current }/%{ total } - </translate> - <button @click="currentResultIndex += 1" class="ui basic tiny icon button" :disabled="currentResultIndex + 1 === results.length"> - <i class="right arrow icon"></i> - </button> - </template> - </div> - <div class="four wide column" v-if="enabled"> - <form class="ui mini form" @submit.prevent=""> - <div class="field"> - <label><translate>Search query</translate></label> - <input type="text" v-model="query" /> - <label><translate>Imported URL</translate></label> - <input type="text" v-model="importedUrl" /> - </div> - </form> - </div> - <div class="six wide column" v-if="enabled"> - <div v-if="isLoading" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - <div v-if="!isLoading && currentResult" class="ui items"> - <div class="item"> - <div class="ui small image"> - <img :src="currentResult.cover" /> - </div> - <div class="content"> - <a - :href="currentResult.url" - target="_blank" - class="description" - v-html="$options.filters.highlight(currentResult.title, warnings)"></a> - <div v-if="currentResult.channelTitle" class="meta"> - {{ currentResult.channelTitle}} - </div> - </div> - </div> - </div> - </div> - </div> -</template> - -<script> -import axios from 'axios' -import Vue from 'vue' -import time from '@/utils/time' -import logger from '@/logging' -import ImportMixin from './ImportMixin' - -import $ from 'jquery' - -Vue.filter('highlight', function (words, query) { - query.forEach(w => { - let re = new RegExp('(' + w + ')', 'gi') - words = words.replace(re, '<span class=\'highlight\'>$1</span>') - }) - return words -}) - -export default Vue.extend({ - mixins: [ImportMixin], - props: { - releaseMetadata: {type: Object, required: true} - }, - data () { - return { - isLoading: false, - results: [], - currentResultIndex: 0, - importedUrl: '', - warnings: [ - 'live', - 'tv', - 'full', - 'cover', - 'mix' - ], - customQuery: '', - time - } - }, - created () { - if (this.enabled) { - this.search() - } - }, - mounted () { - $('.ui.checkbox').checkbox() - }, - methods: { - search: function () { - let self = this - this.isLoading = true - let url = 'providers/' + this.currentBackendId + '/search/' - axios.get(url, {params: {query: this.query}}).then((response) => { - logger.default.debug('searching', self.query, 'on', self.currentBackendId) - self.results = response.data - self.isLoading = false - }, (response) => { - logger.default.error('error while searching', self.query, 'on', self.currentBackendId) - self.isLoading = false - }) - } - }, - computed: { - type () { - return 'track' - }, - currentResult () { - if (this.results) { - return this.results[this.currentResultIndex] - } - }, - importData () { - return { - count: 1, - mbid: this.metadata.recording.id, - source: this.importedUrl - } - }, - query: { - get: function () { - if (this.customQuery.length > 0) { - return this.customQuery - } - let queryMapping = [ - ['artist', this.releaseMetadata['artist-credit'][0]['artist']['name']], - ['album', this.releaseMetadata['title']], - ['title', this.metadata['recording']['title']] - ] - let query = this.customQueryTemplate - queryMapping.forEach(e => { - query = query.split('$' + e[0]).join(e[1]) - }) - return query - }, - set: function (newValue) { - this.customQuery = newValue - } - } - }, - watch: { - query () { - this.search() - }, - currentResult (newValue) { - if (newValue) { - this.importedUrl = newValue.url - } - }, - importedUrl (newValue) { - this.$emit('url-changed', this.importData, this.importedUrl) - }, - enabled (newValue) { - if (newValue && this.results.length === 0) { - this.search() - } - } - } -}) -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> -.ui.card { - width: 100% !important; -} -</style> -<style lang="scss"> -.highlight { - font-weight: bold !important; - background-color: yellow !important; -} -</style> diff --git a/front/src/router/index.js b/front/src/router/index.js index 0d8a0b9367c126d8585133a78bd56466814b5544..ad87ef54e154962c279e8d900683dfcb6e68dda1 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -18,12 +18,9 @@ import LibraryArtist from '@/components/library/Artist' import LibraryArtists from '@/components/library/Artists' import LibraryAlbum from '@/components/library/Album' import LibraryTrack from '@/components/library/Track' -import LibraryImport from '@/components/library/import/Main' import LibraryRadios from '@/components/library/Radios' import RadioBuilder from '@/components/library/radios/Builder' import RadioDetail from '@/views/radios/Detail' -import BatchList from '@/components/library/import/BatchList' -import BatchDetail from '@/components/library/import/BatchDetail' import PlaylistDetail from '@/views/playlists/Detail' import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' @@ -42,6 +39,13 @@ import FederationLibraryDetail from '@/views/federation/LibraryDetail' import FederationLibraryList from '@/views/federation/LibraryList' import FederationTrackList from '@/views/federation/LibraryTrackList' import FederationFollowersList from '@/views/federation/LibraryFollowersList' +import ContentBase from '@/views/content/Base' +import ContentHome from '@/views/content/Home' +import LibrariesHome from '@/views/content/libraries/Home' +import LibrariesUpload from '@/views/content/libraries/Upload' +import LibrariesDetail from '@/views/content/libraries/Detail' +import LibrariesFiles from '@/views/content/libraries/Files' +import RemoteLibrariesHome from '@/views/content/remote/Home' Vue.use(Router) @@ -128,6 +132,68 @@ export default new Router({ defaultPaginateBy: route.query.paginateBy }) }, + { + path: '/content', + component: ContentBase, + children: [ + { + path: '', + name: 'content.index', + component: ContentHome + } + ] + }, + { + path: '/content/libraries/tracks', + component: ContentBase, + children: [ + { + path: '', + name: 'content.libraries.files', + component: LibrariesFiles, + props: (route) => ({ + query: route.query.q + }) + } + ] + }, + { + path: '/content/libraries', + component: ContentBase, + children: [ + { + path: '', + name: 'content.libraries.index', + component: LibrariesHome + }, + { + path: ':id/upload', + name: 'content.libraries.detail.upload', + component: LibrariesUpload, + props: (route) => ({ + id: route.params.id, + defaultImportReference: route.query.import + }) + }, + { + path: ':id', + name: 'content.libraries.detail', + component: LibrariesDetail, + props: true + } + ] + }, + { + path: '/content/remote', + component: ContentBase, + children: [ + { + path: '', + name: 'content.remote.index', + component: RemoteLibrariesHome + } + ] + }, { path: '/manage/settings', name: 'manage.settings', @@ -272,24 +338,6 @@ export default new Router({ { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true }, - { - path: 'import/launch', - name: 'library.import.launch', - component: LibraryImport, - props: (route) => ({ - source: route.query.source, - request: route.query.request, - mbType: route.query.type, - mbId: route.query.id }) - }, - { - path: 'import/batches', - name: 'library.import.batches', - component: BatchList, - children: [ - ] - }, - { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true } ] }, { path: '*', component: PageNotFound } diff --git a/front/src/search.js b/front/src/search.js new file mode 100644 index 0000000000000000000000000000000000000000..5fc2a6b94401d70b2736e4512c26c12c57246cca --- /dev/null +++ b/front/src/search.js @@ -0,0 +1,69 @@ +export function normalizeQuery (query) { + // given a string such as 'this is "my query" go', returns + // an array of tokens like this: ['this', 'is', 'my query', 'go'] + if (!query) { + return [] + } + return query.match(/\\?.|^$/g).reduce((p, c) => { + if (c === '"'){ + p.quote ^= 1 + } else if (!p.quote && c === ' '){ + p.a.push('') + } else { + p.a[p.a.length-1] += c.replace(/\\(.)/,"$1") + } + return p + }, {a: ['']}).a +} + +export function parseTokens (tokens) { + // given an array of tokens as returned by normalizeQuery, + // returns a list of objects such as [ + // { + // field: 'status', + // value: 'pending' + // }, + // { + // field: null, + // value: 'hello' + // } + // ] + return tokens.map(t => { + // we split the token on ":" + let parts = t.split(/:(.+)/) + if (parts.length === 1) { + // no field specified + return {field: null, value: t} + } + // first item is the field, second is the value, possibly quoted + let field = parts[0] + let rawValue = parts[1] + + // we remove surrounding quotes if any + if (rawValue[0] === '"') { + rawValue = rawValue.substring(1) + } + if (rawValue.slice(-1) === '"') { + rawValue = rawValue.substring(0, rawValue.length - 1); + } + return {field, value: rawValue} + }) +} + +export function compileTokens (tokens) { + // given a list of tokens as returned by parseTokens, + // returns a string query + let parts = tokens.map(t => { + let v = t.value + let k = t.field + if (v.indexOf(' ') > -1) { + v = `"${v}"` + } + if (k) { + return `${k}:${v}` + } else { + return v + } + }) + return parts.join(' ') +} diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 163c595e302a0997887862f913207b01be0bb3b2..a1a1530a9f31029acd526bb21f47519194e0d78a 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -31,6 +31,9 @@ export default { users: { registration_enabled: { value: true + }, + upload_quota: { + value: 0 } }, subsonic: { diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue index d21b4e277c307f911245a0a29937cb929f4f46f3..ac3c3408040665a2610eb9b92e1d65f8275585c7 100644 --- a/front/src/views/admin/Settings.vue +++ b/front/src/views/admin/Settings.vue @@ -101,7 +101,8 @@ export default { settings: [ 'users__registration_enabled', 'common__api_authentication_required', - 'users__default_permissions' + 'users__default_permissions', + 'users__upload_quota' ] }, { diff --git a/front/src/views/content/Base.vue b/front/src/views/content/Base.vue new file mode 100644 index 0000000000000000000000000000000000000000..cfdd204281bb2086700e1abf744fda1121a8ad51 --- /dev/null +++ b/front/src/views/content/Base.vue @@ -0,0 +1,26 @@ +<template> + <div class="main pusher" v-title="labels.title"> + <div class="ui secondary pointing menu"> + <router-link + class="ui item" + :to="{name: 'content.libraries.index'}"><translate>Libraries</translate></router-link> + <router-link + class="ui item" + :to="{name: 'content.libraries.files'}"><translate>Tracks</translate></router-link> + </div> + <router-view :key="$route.fullPath"></router-view> + </div> +</template> +<script> + +export default { + computed: { + labels () { + let title = this.$gettext('Add content') + return { + title + } + } + } +} +</script> diff --git a/front/src/views/content/Home.vue b/front/src/views/content/Home.vue new file mode 100644 index 0000000000000000000000000000000000000000..91fe11824d694ff613c99065213e8b4916e975a0 --- /dev/null +++ b/front/src/views/content/Home.vue @@ -0,0 +1,44 @@ +<template> + <div class="ui vertical aligned stripe segment" v-title="labels.title"> + <div class="ui text container"> + <h1>{{ labels.title }}</h1> + <p><translate>We offer various way to grab new content and make it available here.</translate></p> + <div class="ui segment"> + <h2><translate>Upload audio content</translate></h2> + <p><translate>Upload music files (mp3, ogg, flac, etc.) from your personal library directly from your browser to enjoy them here.</translate></p> + <p> + <strong><translate :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space to every user.</translate></strong> + </p> + <router-link :to="{name: 'content.libraries.index'}" class="ui green button"> + <translate>Get started</translate> + </router-link> + </div> + <div class="ui segment"> + <h2><translate>Follow remote libraries</translate></h2> + <p><translate>You can follow libraries from other users to get access to new music. Public libraries can be followed immediatly, while following a private library requires approval from its owner.</translate></p> + <router-link :to="{name: 'content.remote.index'}" class="ui green button"> + <translate>Get started</translate> + </router-link> + </div> + + </div> + </div> +</template> + +<script> +import {humanSize} from '@/filters' + +export default { + computed: { + labels () { + return { + title: this.$gettext('Add and manage content') + } + }, + defaultQuota () { + let quota = this.$store.state.instance.settings.users.upload_quota.value * 1000 * 1000 + return humanSize(quota) + } + } +} +</script> diff --git a/front/src/views/content/libraries/Card.vue b/front/src/views/content/libraries/Card.vue new file mode 100644 index 0000000000000000000000000000000000000000..bcbf77c17c20aca0c6ba3596bf2f3b603f4fd034 --- /dev/null +++ b/front/src/views/content/libraries/Card.vue @@ -0,0 +1,74 @@ +<template> + <div class="ui fluid card"> + <div class="content"> + <div class="header"> + {{ library.name }} + <span + v-if="library.privacy_level === 'me'" + class="right floated" + :data-tooltip="labels.tooltips.me"> + <i class="small lock icon"></i> + </span> + <span + v-else-if="library.privacy_level === 'instance'" + class="right floated" + :data-tooltip="labels.tooltips.instance"> + <i class="small circle outline icon"></i> + </span> + <span + v-else-if="library.privacy_level === 'everyone'" + class="right floated" + :data-tooltip="labels.tooltips.everyone"> + <i class="small globe icon"></i> + </span> + </div> + <div class="meta"> + <span> + <i class="small outline clock icon" /> + <human-date :date="library.creation_date" /> + </span> + </div> + <div class="description"> + <div class="ui hidden divider"></div> + </div> + <div class="content"> + <span v-if="library.size" class="right floated" :data-tooltip="labels.tooltips.size"> + <i class="database icon"></i> + {{ library.size | humanSize }} + </span> + <i class="music icon"></i> + <translate :translate-params="{count: library.files_count}" :translate-n="library.files_count" translate-plural="%{ count } tracks">1 tracks</translate> + </div> + </div> + <div class="ui bottom basic attached buttons"> + <router-link :to="{name: 'content.libraries.detail.upload', params: {id: library.uuid}}" class="ui button"> + <translate>Upload</translate> + </router-link> + <router-link :to="{name: 'content.libraries.detail', params: {id: library.uuid}}" exact class="ui button"> + <translate>Detail</translate> + </router-link> + </div> + </div> +</template> +<script> +export default { + props: ['library'], + computed: { + labels () { + let me = this.$gettext('Visibility: nobody except me') + let instance = this.$gettext('Visibility: everyone on this instance') + let everyone = this.$gettext('Visibility: everyone, including other instances') + let size = this.$gettext('Total size of the files in this library') + + return { + tooltips: { + me, + instance, + everyone, + size + } + } + } + } +} +</script> diff --git a/front/src/views/content/libraries/Detail.vue b/front/src/views/content/libraries/Detail.vue new file mode 100644 index 0000000000000000000000000000000000000000..661ac92e612788e4cec87c0789898187cc444d9e --- /dev/null +++ b/front/src/views/content/libraries/Detail.vue @@ -0,0 +1,59 @@ +<template> + <div class="ui vertical aligned stripe segment"> + <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate>Loading library data...</translate></div> + </div> + <detail-area v-else :library="library"> + <div slot="header"> + <h2 class="ui header"><translate>Manage</translate></h2> + <p><a @click="hiddenForm = !hiddenForm"> + <i class="pencil icon" /> + <translate>Edit library</translate> + </a></p> + <library-form v-if="!hiddenForm" :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" /> + <div class="ui hidden divider"></div> + <div class="ui form"> + <div class="field"> + <label><translate>Sharing link</translate></label> + <p><translate>Share this link with other users so they can request an access to your library.</translate></p> + <copy-input :value="library.fid" /> + </div> + </div> + </div> + <h2><translate>Tracks</translate></h2> + <library-files-table :filters="{library: library.uuid}"></library-files-table> + </detail-area> + </div> +</template> + +<script> +import DetailMixin from './DetailMixin' +import DetailArea from './DetailArea' +import LibraryForm from './Form' +import LibraryFilesTable from './FilesTable' + +export default { + mixins: [DetailMixin], + components: { + DetailArea, + LibraryForm, + LibraryFilesTable + }, + data () { + return { + hiddenForm: true + } + }, + methods: { + libraryUpdated () { + this.hiddenForm = true + this.fetch() + }, + libraryDeleted () { + this.$router.push({ + name: 'content.libraries.index' + }) + } + } +} +</script> diff --git a/front/src/views/content/libraries/DetailArea.vue b/front/src/views/content/libraries/DetailArea.vue new file mode 100644 index 0000000000000000000000000000000000000000..677984fbbd5ce07e63fa58eb6537f9968727e143 --- /dev/null +++ b/front/src/views/content/libraries/DetailArea.vue @@ -0,0 +1,37 @@ +<template> + <div> + <div class="ui stackable grid"> + <div class="eleven wide stretched column"> + <slot name="header"></slot> + </div> + <div class="five wide column"> + <h3 class="ui header"><translate>Current library</translate></h3> + <library-card :library="library" /> + </div> + </div> + <div class="ui divider"></div> + <slot></slot> + </div> +</template> + +<script> +import LibraryCard from './Card' + +export default { + props: ['library'], + components: { + LibraryCard + }, + computed: { + links () { + let upload = this.$gettext('Upload') + return [ + { + name: 'libraries.detail.upload', + label: upload + } + ] + } + } +} +</script> diff --git a/front/src/views/content/libraries/DetailMixin.vue b/front/src/views/content/libraries/DetailMixin.vue new file mode 100644 index 0000000000000000000000000000000000000000..92ff8452d88bbe026c413b92de9012c9a3401e8c --- /dev/null +++ b/front/src/views/content/libraries/DetailMixin.vue @@ -0,0 +1,26 @@ +<script> +import axios from 'axios' + +export default { + props: ['id'], + created () { + this.fetch() + }, + data () { + return { + isLoadingLibrary: false, + library: null + } + }, + methods: { + fetch () { + let self = this + self.isLoadingLibrary = true + axios.get(`libraries/${this.id}/`).then((response) => { + self.library = response.data + self.isLoadingLibrary = false + }) + } + } +} +</script> diff --git a/front/src/views/content/libraries/Files.vue b/front/src/views/content/libraries/Files.vue new file mode 100644 index 0000000000000000000000000000000000000000..752dcd77699056c6eac175d2faf571f37cb1a3e1 --- /dev/null +++ b/front/src/views/content/libraries/Files.vue @@ -0,0 +1,16 @@ +<template> + <div class="ui vertical aligned stripe segment"> + <library-files-table :default-query="query"></library-files-table> + </div> +</template> + +<script> +import LibraryFilesTable from './FilesTable' + +export default { + props: ['query'], + components: { + LibraryFilesTable + } +} +</script> diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..9ff33ecd185ecc30a11196f3b30db1a20850081b --- /dev/null +++ b/front/src/views/content/libraries/FilesTable.vue @@ -0,0 +1,303 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui six wide field"> + <label><translate>Search</translate></label> + <form @submit.prevent="search.query = $refs.search.value"> + <input ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> + </form> + </div> + <div class="field"> + <label><translate>Import status</translate></label> + <select class="ui dropdown" @change="addSearchToken('status', $event.target.value)" :value="getTokenValue('status', '')"> + <option value=""><translate>All</translate></option> + <option value="pending"><translate>Pending</translate></option> + <option value="skipped"><translate>Skipped</translate></option> + <option value="errored"><translate>Errored</translate></option> + <option value="finished"><translate>Finished</translate></option> + </select> + </div> + <div class="field"> + <label><translate>Ordering</translate></label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <label><translate>Ordering direction</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate>Ascending</translate></option> + <option value="-"><translate>Descending</translate></option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :id-field="'uuid'" + :objects-data="result" + :custom-objects="customObjects" + :actions="actions" + :action-url="'track-files/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate>Title</translate></th> + <th><translate>Artist</translate></th> + <th><translate>Album</translate></th> + <th><translate>Upload date</translate></th> + <th><translate>Import status</translate></th> + <th><translate>Duration</translate></th> + <th><translate>Size</translate></th> + </template> + <template slot="row-cells" slot-scope="scope"> + <template v-if="scope.obj.track"> + <td> + <span :title="scope.obj.track.title">{{ scope.obj.track.title|truncate(25) }}</span> + </td> + <td> + <span class="discrete link" @click="addSearchToken('artist', scope.obj.track.artist.name)" :title="scope.obj.track.artist.name">{{ scope.obj.track.artist.name|truncate(20) }}</span> + </td> + <td> + <span class="discrete link" @click="addSearchToken('album', scope.obj.track.album.title)" :title="scope.obj.track.album.title">{{ scope.obj.track.album.title|truncate(20) }}</span> + </td> + </template> + <template v-else> + <td>{{ scope.obj.source }}</td> + <td></td> + <td></td> + </template> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td :title="labels.importStatuses[scope.obj.import_status].help"> + <span class="discrete link" @click="addSearchToken('status', scope.obj.import_status)"> + {{ labels.importStatuses[scope.obj.import_status].label }} + <i class="question circle outline icon"></i> + </span> + </td> + <td v-if="scope.obj.duration"> + {{ time.parse(scope.obj.duration) }} + </td> + <td v-else> + <translate>N/A</translate> + </td> + <td v-if="scope.obj.size"> + {{ scope.obj.size | humanSize }} + </td> + <td v-else> + <translate>N/A</translate> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.count > paginateBy" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + <translate + :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}"> + Showing results %{ start }-%{ end } on %{ total } + </translate> + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' +import time from '@/utils/time' +import {normalizeQuery, parseTokens, compileTokens} from '@/search' + +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false}, + defaultQuery: {type: String, default: ''}, + customObjects: {type: Array, required: false, default: () => { return [] }} + }, + components: { + Pagination, + ActionTable + }, + data () { + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 25, + search: { + query: this.defaultQuery, + tokens: parseTokens(normalizeQuery(this.defaultQuery)) + }, + orderingDirection: '-', + ordering: 'creation_date', + orderingOptions: [ + ['creation_date', 'Creation date'], + ['title', 'Title'], + ['size', 'Size'], + ['duration', 'Duration'], + ['bitrate', 'Bitrate'], + ['album_title', 'Album title'], + ['artist_name', 'Artist name'] + ] + } + }, + created () { + this.fetchData() + }, + methods: { + getTokenValue (key, fallback) { + let matching = this.search.tokens.filter(t => { + return t.field === key + }) + if (matching.length > 0) { + return matching[0].value + } + return fallback + }, + addSearchToken (key, value) { + if (!value) { + // we remove existing matching tokens, if any + this.search.tokens = this.search.tokens.filter(t => { + return t.field != key + }) + } else { + let existing = this.search.tokens.filter(t => { + return t.field === key + }) + if (existing.length > 0) { + // we replace the value in existing tokens, if any + existing.forEach(t => { + t.value = value + }) + } else { + // we add a new token + this.search.tokens.push({field: key, value}) + } + } + }, + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'ordering': this.getOrderingAsString(), + 'q': this.search.query + }, {}) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/track-files/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + labels () { + return { + searchPlaceholder: this.$gettext('Search by title, artist, album...'), + importStatuses: { + skipped: { + label: this.$gettext('Skipped'), + help: this.$gettext('Track was already present in one of your libraries'), + }, + pending: { + label: this.$gettext('Pending'), + help: this.$gettext('Track is uploaded but not processed by the server yet'), + }, + errored: { + label: this.$gettext('Errored'), + help: this.$gettext('An error occured while processing this track, ensure the track is correctly tagged'), + }, + finished: { + label: this.$gettext('Finished'), + help: this.$gettext('Import went on successfully'), + }, + } + } + }, + actionFilters () { + var currentFilters = { + q: this.search.query + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + let deleteMsg = this.$gettext('Delete') + let relaunchMsg = this.$gettext('Relaunch import') + return [ + { + name: 'delete', + label: deleteMsg, + isDangerous: true, + allowAll: true + }, + { + name: 'relaunch_import', + label: relaunchMsg, + isDangerous: true, + allowAll: true, + filterCheckable: f => { + return f.import_status != 'finished' + } + } + ] + } + }, + watch: { + 'search.query' (newValue) { + this.search.tokens = parseTokens(normalizeQuery(newValue)) + }, + 'search.tokens': { + handler (newValue) { + this.search.query = compileTokens(newValue) + this.fetchData() + }, + deep: true + }, + orderingDirection: function () { + this.page = 1 + this.fetchData() + }, + ordering: function () { + this.page = 1 + this.fetchData() + }, + search (newValue) { + this.page = 1 + this.fetchData() + }, + } +} +</script> diff --git a/front/src/views/content/libraries/Form.vue b/front/src/views/content/libraries/Form.vue new file mode 100644 index 0000000000000000000000000000000000000000..8650496bbd0b5416c124fc29d3be8d652c75a9a7 --- /dev/null +++ b/front/src/views/content/libraries/Form.vue @@ -0,0 +1,145 @@ +<template> + <form class="ui form" @submit.prevent="submit"> + <p v-if="!library"><translate>Libraries help you organize and share your music collections. You can upload your own music collection to Funkwhale and share it with your friends and family.</translate></p> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate>Error</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="required field"> + <label><translate>Name</translate></label> + <input v-model="currentName" :placeholder="labels.namePlaceholder" required maxlength="100"> + </div> + <div class="field"> + <label><translate>Description</translate></label> + <textarea v-model="currentDescription" :placeholder="labels.descriptionPlaceholder" maxlength="2000"></textarea> + </div> + <div class="field"> + <label><translate>Visibility</translate></label> + <p><translate>You will be able to share your library with other people, regardless of it's visibility.</translate></p> + <select class="ui dropdown" v-model="currentVisibilityLevel"> + <option :value="c" v-for="c in ['me', 'instance', 'everyone']">{{ labels.visibility[c] }}</option> + </select> + </div> + <button class="ui submit button" type="submit"> + <translate v-if="library">Update library</translate> + <translate v-else>Create library</translate> + </button> + <dangerous-button v-if="library" class="right floated basic button" color='red' @confirm="remove()"> + <translate>Delete</translate> + <p slot="modal-header"> + <translate>Delete this library?</translate> + </p> + <p slot="modal-content"> + <translate> + The library and all its tracks will be deleted. This action is irreversible. + </translate> + </p> + <p slot="modal-confirm"> + <translate>Delete library</translate> + </p> + </dangerous-button> + </form> +</template> + +<script> +import axios from 'axios' + +export default { + props: ['library'], + data () { + let d = { + isLoading: false, + over: false, + errors: [] + } + if (this.library) { + d.currentVisibilityLevel = this.library.privacy_level + d.currentName = this.library.name + d.currentDescription = this.library.description + } else { + d.currentVisibilityLevel = 'me' + d.currentName = '' + d.currentDescription = '' + } + return d + }, + computed: { + labels () { + let namePlaceholder = this.$gettext('My awesome library') + let descriptionPlaceholder = this.$gettext('This library contains my personnal music, I hope you will like it!') + let me = this.$gettext('Nobody except me') + let instance = this.$gettext('Everyone on this instance') + let everyone = this.$gettext('Everyone, including other instances') + return { + namePlaceholder, + descriptionPlaceholder, + visibility: { + me, + instance, + everyone + } + } + } + }, + methods: { + submit () { + let self = this + this.isLoading = true + let payload = { + name: this.currentName, + description: this.currentDescription, + privacy_level: this.currentVisibilityLevel + } + let promise + if (this.library) { + promise = axios.patch(`libraries/${this.library.uuid}/`, payload) + } else { + promise = axios.post('libraries/', payload) + } + promise.then((response) => { + self.isLoading = false + let msg + if (self.library) { + self.$emit('updated', response.data) + msg = this.$gettext('Library updated') + } else { + self.$emit('created', response.data) + msg = this.$gettext('Library created') + } + self.$store.commit('ui/addMessage', { + content: msg, + date: new Date() + }) + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + reset () { + this.currentVisibilityLevel = 'me' + this.currentName = '' + this.currentDescription = '' + }, + remove () { + let self = this + axios.delete(`libraries/${this.library.uuid}/`).then((response) => { + self.isLoading = false + let msg = this.$gettext('Library deleted') + self.$emit('deleted', {}) + self.$store.commit('ui/addMessage', { + content: msg, + date: new Date() + }) + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + } +} +</script> + +<style scoped> +</style> diff --git a/front/src/views/content/libraries/Home.vue b/front/src/views/content/libraries/Home.vue new file mode 100644 index 0000000000000000000000000000000000000000..98dc2dde3e6fd6fc820427491d86533d1155c372 --- /dev/null +++ b/front/src/views/content/libraries/Home.vue @@ -0,0 +1,74 @@ +<template> + <div class="ui vertical aligned stripe segment"> + <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate>Loading Libraries...</translate></div> + </div> + <div v-else class="ui text container"> + <h1 class="ui header"><translate>My libraries</translate></h1> + + <p v-if="libraries.length == 0"> + <translate>It looks like you don't have any library yet, it's time to create one!</translate> + </p> + <a @click="hiddenForm = !hiddenForm"> + <i class="plus icon" v-if="hiddenForm" /> + <i class="minus icon" v-else /> + <translate>Create a new library</translate> + </a> + <library-form :library="null" v-if="!hiddenForm" @created="libraryCreated" /> + <div class="ui hidden divider"></div> + <quota /> + <div class="ui hidden divider"></div> + <div v-if="libraries.length > 0" class="ui two column grid"> + <div v-for="library in libraries" :key="library.uuid" class="column"> + <library-card :library="library" /> + </div> + </div> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import LibraryForm from './Form' +import LibraryCard from './Card' +import Quota from './Quota' + +export default { + data () { + return { + isLoading: false, + hiddenForm: true, + libraries: [] + } + }, + created () { + this.fetch() + }, + components: { + LibraryForm, + LibraryCard, + Quota + }, + methods: { + fetch () { + this.isLoading = true + let self = this + axios.get('libraries/').then((response) => { + self.isLoading = false + self.libraries = response.data.results + if (self.libraries.length === 0) { + self.hiddenForm = false + } + }) + }, + libraryCreated (library) { + this.hiddenForm = true + this.libraries.unshift(library) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/content/libraries/Quota.vue b/front/src/views/content/libraries/Quota.vue new file mode 100644 index 0000000000000000000000000000000000000000..edb0cbbeae66dcfa873da17867c9bb31f6996c44 --- /dev/null +++ b/front/src/views/content/libraries/Quota.vue @@ -0,0 +1,169 @@ +<template> + <div class="ui segment"> + <h3 class="ui header"><translate>Current usage</translate></h3> + <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate>Loading usage data...</translate></div> + </div> + <div :class="['ui', {'success': progress < 60}, {'yellow': progress >= 60 && progress < 96}, {'error': progress >= 95}, 'progress']"> + <div class="bar"> + <div class="progress">{{ progress }}%</div> + </div> + <div class="label" v-if="quotaStatus"> + <translate :translate-params="{max: humanSize(quotaStatus.max * 1000 * 1000), current: humanSize(quotaStatus.current * 1000 * 1000)}">%{ current } used on %{ max } allowed</translate> + </div> + </div> + <div class="ui hidden divider"></div> + <div v-if="quotaStatus" class="ui stackable three column grid"> + <div v-if="quotaStatus.pending > 0" class="column"> + <div class="ui tiny yellow statistic"> + <div class="value"> + {{ humanSize(quotaStatus.pending * 1000 * 1000) }} + </div> + <div class="label"> + <translate>Pending files</translate> + </div> + </div> + <div> + <router-link + class="ui basic blue tiny button" + :to="{name: 'content.libraries.files', query: {q: compileTokens([{field: 'status', value: 'pending'}])}}"> + <translate>View files</translate> + </router-link> + + <dangerous-button + color="grey" + class="basic tiny" + :action="purgePendingFiles"> + <translate>Purge</translate> + <p slot="modal-header"><translate>Purge pending files?</translate></p> + <p slot="modal-content"><translate>This will remove tracks that were uploaded but not processed yet. This will remove those files completely and you will regain the corresponding quota.</translate></p> + <p slot="modal-confirm"><translate>Purge</translate></p> + </dangerous-button> + </div> + </div> + <div v-if="quotaStatus.skipped > 0" class="column"> + <div class="ui tiny grey statistic"> + <div class="value"> + {{ humanSize(quotaStatus.skipped * 1000 * 1000) }} + </div> + <div class="label"> + <translate>Skipped files</translate> + </div> + </div> + <div> + <router-link + class="ui basic blue tiny button" + :to="{name: 'content.libraries.files', query: {q: compileTokens([{field: 'status', value: 'skipped'}])}}"> + <translate>View files</translate> + </router-link> + <dangerous-button + color="grey" + class="basic tiny" + :action="purgeSkippedFiles"> + <translate>Purge</translate> + <p slot="modal-header"><translate>Purge skipped files?</translate></p> + <p slot="modal-content"><translate>This will remove tracks that were uploaded but skipped during import processes for various reasons. This will remove those files completely and you will regain the corresponding quota.</translate></p> + <p slot="modal-confirm"><translate>Purge</translate></p> + </dangerous-button> + </div> + </div> + <div v-if="quotaStatus.errored > 0" class="column"> + <div class="ui tiny red statistic"> + <div class="value"> + {{ humanSize(quotaStatus.errored * 1000 * 1000) }} + </div> + <div class="label"> + <translate>Errored files</translate> + </div> + </div> + <div> + <router-link + class="ui basic blue tiny button" + :to="{name: 'content.libraries.files', query: {q: compileTokens([{field: 'status', value: 'errored'}])}}"> + <translate>View files</translate> + </router-link> + <dangerous-button + color="grey" + class="basic tiny" + :action="purgeErroredFiles"> + <translate>Purge</translate> + <p slot="modal-header"><translate>Purge errored files?</translate></p> + <p slot="modal-content"><translate>This will remove tracks that were uploaded but failed to be process by the server. This will remove those files completely and you will regain the corresponding quota.</translate></p> + <p slot="modal-confirm"><translate>Purge</translate></p> + </dangerous-button> + </div> + </div> + </div> + </div> +</template> +<script> +import axios from 'axios' +import $ from 'jquery' +import {humanSize} from '@/filters' +import {compileTokens} from '@/search' + +export default { + data () { + return { + quotaStatus: null, + isLoading: false, + humanSize, + compileTokens + } + }, + created () { + this.fetch() + }, + methods: { + fetch () { + let self = this + self.isLoading = true + axios.get('users/users/me/').then((response) => { + self.quotaStatus = response.data.quota_status + self.isLoading = false + }) + }, + purge (status) { + let self = this + let payload = { + action: 'delete', + objects: 'all', + filters: { + import_status: status + } + } + axios.post('track-files/action/', payload).then((response) => { + self.fetch() + }) + }, + purgeSkippedFiles () { + this.purge('skipped') + }, + purgePendingFiles () { + this.purge('pending') + }, + purgeErroredFiles () { + this.purge('errored') + }, + updateProgressBar () { + $(this.$el).find('.ui.progress').progress({ + percent: this.progress, + showActivity: false + }) + } + }, + computed: { + progress () { + if (!this.quotaStatus) { + return 0 + } + return Math.min(parseInt(this.quotaStatus.current * 100 / this.quotaStatus.max), 100) + } + }, + watch: { + progress () { + this.updateProgressBar() + } + } +} +</script> diff --git a/front/src/views/content/libraries/Upload.vue b/front/src/views/content/libraries/Upload.vue new file mode 100644 index 0000000000000000000000000000000000000000..68ee3b9338558d4347778eaa11740d5698436ac8 --- /dev/null +++ b/front/src/views/content/libraries/Upload.vue @@ -0,0 +1,43 @@ +<template> + <div class="ui vertical aligned stripe segment"> + <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate>Loading library data...</translate></div> + </div> + <detail-area v-else :library="library"> + <div slot="header"> + <h2 class="ui header"><translate>Upload new tracks</translate></h2> + <div class="ui message"> + <p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p> + <ul> + <li v-if="library.privacy_level != 'me'"> + You are not uploading copyrighted content in a public library, otherwise you may be infringing the law + </li> + <li> + <translate>The music files you are uploading are tagged properly:</translate> + <a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a> + </li> + <li> + <translate>The uploaded music files are in OGG, Flac or MP3 format</translate> + </li> + </ul> + </div> + </div> + <file-upload :default-import-reference="defaultImportReference" :library="library" /> + </detail-area> + </div> +</template> + +<script> +import DetailMixin from './DetailMixin' +import DetailArea from './DetailArea' + +import FileUpload from '@/components/library/FileUpload' +export default { + mixins: [DetailMixin], + props: ['defaultImportReference'], + components: { + DetailArea, + FileUpload + } +} +</script> diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue new file mode 100644 index 0000000000000000000000000000000000000000..b9903d94b7197117dc69af5db4ae8c73d4d1fc7a --- /dev/null +++ b/front/src/views/content/remote/Card.vue @@ -0,0 +1,89 @@ +<template> + <div class="ui fluid card"> + <div class="content"> + <div class="header"> + {{ library.name }} + <span + v-if="library.privacy_level === 'me'" + class="right floated" + :data-tooltip="labels.tooltips.me"> + <i class="small lock icon"></i> + </span> + <span + v-else-if="library.privacy_level === 'everyone'" + class="right floated" + :data-tooltip="labels.tooltips.everyone"> + <i class="small globe icon"></i> + </span> + </div> + <div class="meta"> + <span> + <i class="small outline clock icon" /> + <human-date :date="library.creation_date" /> + </span> + </div> + <div class="content"> + <i class="music icon"></i> + <translate :translate-params="{count: library.files_count}" :translate-n="library.files_count" translate-plural="%{ count } tracks">1 tracks</translate> + </div> + </div> + <div class="extra content"> + <actor-link :actor="library.actor" /> + </div> + <div class="ui bottom attached buttons"> + <button + v-if="!library.follow" + @click="follow()" + :class="['ui', 'green', {'loading': isLoadingFollow}, 'button']"> + <translate>Follow</translate> + </button> + <button + v-else-if="!library.follow.approved" + class="ui disabled button"><i class="hourglass icon"></i> + <translate>Follow pending approval</translate> + </button> + <button + v-else-if="!library.follow.approved" + class="ui disabled button"><i class="check icon"></i> + <translate>Following</translate> + </button> + </div> + </div> +</template> +<script> +import axios from 'axios' + +export default { + props: ['library'], + data () { + return { + isLoadingFollow: false + } + }, + computed: { + labels () { + let me = this.$gettext('This library is private and you will need approval from its owner to access its content') + let everyone = this.$gettext('This library is public and you can access its content without any authorization') + + return { + tooltips: { + me, + everyone + } + } + } + }, + methods: { + follow () { + let self = this + this.isLoadingFollow = true + axios.post('federation/follows/library/', {target: this.library.uuid}).then((response) => { + self.library.follow = response.data + self.isLoadingFollow = false + }, error => { + self.isLoadingFollow = false + }) + } + } +} +</script> diff --git a/front/src/views/content/remote/Home.vue b/front/src/views/content/remote/Home.vue new file mode 100644 index 0000000000000000000000000000000000000000..753e5fa2b04eabbb024c5f692d12ab2ec135aa93 --- /dev/null +++ b/front/src/views/content/remote/Home.vue @@ -0,0 +1,55 @@ +<template> + <div class="ui vertical aligned stripe segment"> + <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate>Loading remote libraries...</translate></div> + </div> + <div v-else class="ui text container"> + <h1 class="ui header"><translate>Remote libraries</translate></h1> + <p><translate>Remote libraries are owned by other users on the network. You can access them as long as they are public or you are granted access.</translate></p> + <scan-form @scanned="scanResult = $event"></scan-form> + <div class="ui hidden divider"></div> + <div v-if="scanResult && scanResult.results.length > 0" class="ui two cards"> + <library-card :library="library" v-for="library in scanResult.results" :key="library.fid" /> + </div> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import ScanForm from './ScanForm' +import LibraryCard from './Card' + +export default { + data () { + return { + isLoading: false, + scanResult: null + } + }, + created () { + // this.fetch() + }, + components: { + ScanForm, + LibraryCard + }, + methods: { + fetch () { + this.isLoading = true + let self = this + axios.get('libraries/').then((response) => { + self.isLoading = false + self.libraries = response.data.results + if (self.libraries.length === 0) { + self.hiddenForm = false + } + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/content/remote/ScanForm.vue b/front/src/views/content/remote/ScanForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..9ff614efb45b6f4fe44b921a645b8c80a80a6889 --- /dev/null +++ b/front/src/views/content/remote/ScanForm.vue @@ -0,0 +1,55 @@ +<template> + <form class="ui form" @submit.prevent="scan"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate>Error while fetching remote library</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="ui field"> + <label><translate>Search a remote library</translate></label> + <div :class="['ui', 'action', {loading: isLoading}, 'input']"> + <input v-model="query" :placeholder="labels.placeholder" type="url"> + <button type="submit" class="ui icon button"> + <i class="search icon"></i> + </button> + </div> + </div> + </form> +</template> +<script> +import axios from 'axios' + +export default { + data () { + return { + query: '', + isLoading: false, + errors: [] + } + }, + methods: { + scan () { + if (!this.query) { + return + } + let self = this + axios.post('federation/libraries/scan/', {fid: this.query}).then((response) => { + self.$emit('scanned', response.data) + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + }, + computed: { + labels () { + let placeholder = this.$gettext('Enter a library url') + return { + placeholder + } + } + } +} +</script> diff --git a/front/tests/unit/specs/search.spec.js b/front/tests/unit/specs/search.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5cc551b20f4aca9cd8cd1dc99e227f1ab925d338 --- /dev/null +++ b/front/tests/unit/specs/search.spec.js @@ -0,0 +1,65 @@ +import {expect} from 'chai' + +import {normalizeQuery, parseTokens, compileTokens} from '@/search' + +describe('search', () => { + it('normalizeQuery returns correct tokens', () => { + const input = 'this is a "search query" yeah' + let output = normalizeQuery(input) + expect(output).to.deep.equal(['this', 'is', 'a', 'search query', 'yeah']) + }) + it('parseTokens can extract fields and values from tokens', () => { + const input = ['unhandled', 'key:value', 'status:pending', 'title:"some title"', 'anotherunhandled'] + let output = parseTokens(input) + let expected = [ + { + 'field': null, + 'value': 'unhandled' + }, + { + 'field': 'key', + 'value': 'value' + }, + { + 'field': 'status', + 'value': 'pending', + }, + { + 'field': 'title', + 'value': 'some title' + }, + { + 'field': null, + 'value': 'anotherunhandled' + } + ] + expect(output).to.deep.equal(expected) + }) + it('compileTokens returns proper query string', () => { + let input = [ + { + 'field': null, + 'value': 'unhandled' + }, + { + 'field': 'key', + 'value': 'value' + }, + { + 'field': 'status', + 'value': 'pending', + }, + { + 'field': 'title', + 'value': 'some title' + }, + { + 'field': null, + 'value': 'anotherunhandled' + } + ] + const expected = 'unhandled key:value status:pending title:"some title" anotherunhandled' + let output = compileTokens(input) + expect(output).to.deep.equal(expected) + }) +}) diff --git a/per-user-libraries b/per-user-libraries new file mode 100644 index 0000000000000000000000000000000000000000..d2905d88acee544a3689d1f640316ef69910279e --- /dev/null +++ b/per-user-libraries @@ -0,0 +1,51 @@ +Todo: + +- upload utilisateur +- gestion des doublons ? Si piste uploadée deux fois, on fait quoi: + - On rajoute un lien entre la piste existante et la bibliothèque de l'utilisateur + - L'utilisateur peut forcer l'upload de SA piste +- Comment on gère l'affichage : une piste peut ne pas être jouable +- more tests about: + - replacing + - deletion + - permissions + +- un utilisateur envoie une piste: + - pas de problème: au pire les métadonnées ne sont pas bonnes mais ce n'est pas très grave + - on incite à tagguer avec picard / musicbrainz + - en cas de conflit (piste sans tag, mais le nom de l'artiste existe avec un ID musicbrainz, par exemple): + on fournit à l'utilisateur le choix (binder à l'artiste musicbrainz), ou créer un artiste séparé + on peut aussi tenter des trucs plus intelligents à base de matching sur les noms de pistes, mais dans un second temps + +- Un créateur envoie une piste: + - il crée un profil avec son nom d'artiste, des liens vers ses différents profils (youtube, etc). + - on peut lui fournir un snippet à inclure sur ses profils "Funkwhale: http://creator.url" pour servir à valider + - les instances fédérées pourront donc faire la vérification elles-mêmes + - à l'upload, il a un formulaire spécial ou il déclare bien être le créateur des pistes et avoir les droits + - on ne tente pas d'être smart : il faut que les données soient fiables et éditables par le créateur avant publication ! + + +Jour 2: + +- on bind les fichiers aux bibliothèques +- on ne dédoublonne pas, trop compliqué +- en fédé, quand on scanne, on crée les track files, du coup plus besoin d'import manuel +- dans le script de migration, gérer le cas dess trucs importés via la fédé + + +Todo: + +- tester le remplacement +- skipper les tracks qui sont déjà dans une autre bibliothèque +- gestion d'erreur plus poussée +- gérer les radios +- tester permission sur la fédé +- tester qu'on ne sert que les bibliothèques locales +- virer au maximum la logique custom pour les acteurs systèmes +- utiliser le vrai champs durée d'activitystream pour l'audio +- shared inbox url +- vue pour servir les bibliothèques: + +- logique de scan: + - pouvoir lancer, mettre en pause, interrompre un scan + - avoir des infos sur le déroulement du scan