diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index d0db33138d38f30796608a91b2122c6208c7e589..9041ed28a40b52a49c63c44b07d845077bc309d7 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -14,9 +14,23 @@ class NestedLibraryFollowSerializer(serializers.ModelSerializer): fields = ["creation_date", "uuid", "fid", "approved", "modification_date"] +class LibraryScanSerializer(serializers.ModelSerializer): + class Meta: + model = music_models.LibraryScan + fields = [ + "total_files", + "processed_files", + "errored_files", + "status", + "creation_date", + "modification_date", + ] + + class LibrarySerializer(serializers.ModelSerializer): actor = federation_serializers.APIActorSerializer() uploads_count = serializers.SerializerMethodField() + latest_scan = serializers.SerializerMethodField() follow = serializers.SerializerMethodField() class Meta: @@ -31,6 +45,7 @@ class LibrarySerializer(serializers.ModelSerializer): "uploads_count", "privacy_level", "follow", + "latest_scan", ] def get_uploads_count(self, o): @@ -42,6 +57,11 @@ class LibrarySerializer(serializers.ModelSerializer): except (AttributeError, IndexError): return None + def get_latest_scan(self, o): + scan = o.scans.order_by("-creation_date").first() + if scan: + return LibraryScanSerializer(scan).data + class LibraryFollowSerializer(serializers.ModelSerializer): target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True) @@ -54,6 +74,9 @@ class LibraryFollowSerializer(serializers.ModelSerializer): def validate_target(self, v): actor = self.context["actor"] + if v.actor == actor: + raise serializers.ValidationError("You cannot follow your own library") + 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_views.py b/api/funkwhale_api/federation/api_views.py index 88092d70c886b05b67534f752bb917f5fee5160a..1f33ce8592185006d2f44c09e396b01a8a250358 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -31,13 +31,14 @@ class LibraryFollowViewSet( mixins.CreateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, viewsets.GenericViewSet, ): lookup_field = "uuid" queryset = ( models.LibraryFollow.objects.all() .order_by("-creation_date") - .select_related("target__actor", "actor") + .select_related("actor", "target__actor") ) serializer_class = api_serializers.LibraryFollowSerializer permission_classes = [permissions.IsAuthenticated] @@ -52,6 +53,13 @@ class LibraryFollowViewSet( follow = serializer.save(actor=self.request.user.actor) routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow}) + @transaction.atomic + def perform_destroy(self, instance): + routes.outbox.dispatch( + {"type": "Undo", "object": {"type": "Follow"}}, context={"follow": instance} + ) + instance.delete() + def get_serializer_context(self): context = super().get_serializer_context() context["actor"] = self.request.user.actor @@ -96,8 +104,25 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): qs = super().get_queryset() return qs.viewable_by(actor=self.request.user.actor) - @decorators.list_route(methods=["post"]) + @decorators.detail_route(methods=["post"]) def scan(self, request, *args, **kwargs): + library = self.get_object() + if library.actor.is_local: + return response.Response({"status": "skipped"}, 200) + + scan = library.schedule_scan(actor=request.user.actor) + if scan: + return response.Response( + { + "status": "scheduled", + "scan": api_serializers.LibraryScanSerializer(scan).data, + }, + 200, + ) + return response.Response({"status": "skipped"}, 200) + + @decorators.list_route(methods=["post"]) + def fetch(self, request, *args, **kwargs): try: fid = request.data["fid"] except KeyError: @@ -110,7 +135,7 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): ) except requests.exceptions.RequestException as e: return response.Response( - {"detail": "Error while scanning the library: {}".format(str(e))}, + {"detail": "Error while fetching the library: {}".format(str(e))}, status=400, ) except serializers.serializers.ValidationError as e: diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py index 4b1e392006a10d33d953d5249ec68c485b522960..46722079980d0c80c5e2eda867e58ec188eba79e 100644 --- a/api/funkwhale_api/federation/library.py +++ b/api/funkwhale_api/federation/library.py @@ -90,7 +90,7 @@ def get_library_data(library_url, actor): return {"errors": ["Permission denied while scanning library"]} elif scode >= 400: return {"errors": ["Error {} while fetching the library".format(scode)]} - serializer = serializers.PaginatedCollectionSerializer(data=response.json()) + serializer = serializers.LibrarySerializer(data=response.json()) if not serializer.is_valid(): return {"errors": ["Invalid ActivityPub response from remote library"]} diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 0ee05c7e19a221dc6e4e0ed0134cd9e43850cb44..b757d4f9279b8968cc649f4cbd4c5058e4836f99 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -77,6 +77,38 @@ def outbox_accept(context): } +@inbox.register({"type": "Undo", "object.type": "Follow"}) +def inbox_undo_follow(payload, context): + serializer = serializers.UndoFollowSerializer(data=payload, context=context) + if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): + logger.debug( + "Discarding invalid follow undo from {}: %s", + context["actor"].fid, + serializer.errors, + ) + return + + serializer.save() + + +@outbox.register({"type": "Undo", "object.type": "Follow"}) +def outbox_undo_follow(context): + follow = context["follow"] + actor = follow.actor + if follow._meta.label == "federation.LibraryFollow": + recipient = follow.target.actor + else: + recipient = follow.target + payload = serializers.UndoFollowSerializer(follow, context={"actor": actor}).data + yield { + "actor": actor, + "type": "Undo", + "payload": with_recipients(payload, to=[recipient]), + "object": follow, + "related_object": follow.target, + } + + @outbox.register({"type": "Follow"}) def outbox_follow(context): follow = context["follow"] diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 71cd7a83142d636d737fd3a7cdb8fe548e89a35c..61574a57e539e61128ddfb7a1adde7c6b41afbee 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -343,7 +343,7 @@ class AcceptFollowSerializer(serializers.Serializer): follow.approved = True follow.save() if follow.target._meta.label == "music.Library": - follow.target.schedule_scan() + follow.target.schedule_scan(actor=follow.actor) return follow @@ -354,7 +354,8 @@ class UndoFollowSerializer(serializers.Serializer): type = serializers.ChoiceField(choices=["Undo"]) def validate_actor(self, v): - expected = self.context.get("follow_target") + expected = self.context.get("actor") + if expected and expected.fid != v: raise serializers.ValidationError("Invalid actor") try: @@ -366,11 +367,19 @@ class UndoFollowSerializer(serializers.Serializer): # we ensure the accept actor actually match the follow actor if validated_data["actor"] != validated_data["object"]["actor"]: raise serializers.ValidationError("Actor mismatch") + + target = validated_data["object"]["object"] + + if target._meta.label == "music.Library": + follow_class = models.LibraryFollow + else: + follow_class = models.Follow + try: - validated_data["follow"] = models.Follow.objects.filter( - actor=validated_data["actor"], target=validated_data["object"]["object"] + validated_data["follow"] = follow_class.objects.filter( + actor=validated_data["actor"], target=target ).get() - except models.Follow.DoesNotExist: + except follow_class.DoesNotExist: raise serializers.ValidationError("No follow to remove") return validated_data @@ -545,7 +554,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): "summary": library.description, "page_size": 100, "actor": library.actor, - "items": library.uploads.filter(import_status="finished"), + "items": library.uploads.for_federation(), "type": "Library", } r = super().to_representation(conf) @@ -599,9 +608,10 @@ class CollectionPageSerializer(serializers.Serializer): raw_items = [item_serializer(data=i, context=self.context) for i in v] valid_items = [] for i in raw_items: - if i.is_valid(): + try: + i.is_valid(raise_exception=True) valid_items.append(i) - else: + except serializers.ValidationError: logger.debug("Invalid item %s: %s", i.data, i.errors) return valid_items diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 9780bd2583110bb0934d99011d9c875e4f9078e9..510c672fdc5d9abe5c5d5a9d8d940b41faa341ad 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -191,7 +191,7 @@ class MusicLibraryViewSet( authentication_classes = [authentication.SignatureAuthentication] permission_classes = [] renderer_classes = [renderers.ActivityPubRenderer] - serializer_class = serializers.PaginatedCollectionSerializer + serializer_class = serializers.LibrarySerializer queryset = music_models.Library.objects.all().select_related("actor") lookup_field = "uuid" @@ -203,7 +203,7 @@ class MusicLibraryViewSet( "actor": lb.actor, "name": lb.name, "summary": lb.description, - "items": lb.uploads.order_by("-creation_date"), + "items": lb.uploads.for_federation().order_by("-creation_date"), "item_serializer": serializers.UploadSerializer, } page = request.GET.get("page") diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py index 6029c55e0375ea98131997f193a46fe897962c89..0cb4641808b0bcb4a1fc1106b73118e302890684 100644 --- a/api/funkwhale_api/music/admin.py +++ b/api/funkwhale_api/music/admin.py @@ -78,9 +78,37 @@ class UploadAdmin(admin.ModelAdmin): list_filter = ["mimetype", "import_status", "library__privacy_level"] +def launch_scan(modeladmin, request, queryset): + for library in queryset: + library.schedule_scan(actor=request.user.actor, force=True) + + +launch_scan.short_description = "Launch scan" + + @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"] + actions = [launch_scan] + + +@admin.register(models.LibraryScan) +class LibraryScanAdmin(admin.ModelAdmin): + list_display = [ + "id", + "library", + "actor", + "status", + "creation_date", + "modification_date", + "status", + "total_files", + "processed_files", + "errored_files", + ] + list_select_related = True + search_fields = ["actor__username", "library__name"] + list_filter = ["status"] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 55f1c77b8a499fc517c38d78d32437315b393fe7..6f8517e4d14deb4b205d4a06931c80062be7f918 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1,5 +1,6 @@ import datetime import logging +import mimetypes import os import tempfile import uuid @@ -553,25 +554,8 @@ class Track(APIModelMixin): class UploadQuerySet(models.QuerySet): def playable_by(self, actor, include=True): - from funkwhale_api.federation.models import LibraryFollow - - if actor is None: - libraries = Library.objects.filter(privacy_level="everyone") + libraries = Library.objects.viewable_by(actor) - else: - me_query = models.Q(privacy_level="me", actor=actor) - instance_query = models.Q( - privacy_level="instance", actor__domain=actor.domain - ) - followed_libraries = LibraryFollow.objects.filter( - actor=actor, approved=True - ).values_list("target", flat=True) - libraries = Library.objects.filter( - me_query - | instance_query - | models.Q(privacy_level="everyone") - | models.Q(pk__in=followed_libraries) - ) if include: return self.filter(library__in=libraries) return self.exclude(library__in=libraries) @@ -579,6 +563,9 @@ class UploadQuerySet(models.QuerySet): def local(self, include=True): return self.exclude(library__actor__user__isnull=include) + def for_federation(self): + return self.filter(import_status="finished", mimetype__startswith="audio/") + TRACK_FILE_IMPORT_STATUS_CHOICES = ( ("pending", "Pending"), @@ -731,8 +718,11 @@ class Upload(models.Model): } def save(self, **kwargs): - if not self.mimetype and self.audio_file: - self.mimetype = utils.guess_mimetype(self.audio_file) + if not self.mimetype: + if self.audio_file: + self.mimetype = utils.guess_mimetype(self.audio_file) + elif self.source and self.source.startswith("file://"): + self.mimetype = mimetypes.guess_type(self.source)[0] if not self.size and self.audio_file: self.size = self.audio_file.size if not self.pk and not self.fid and self.library.actor.is_local: @@ -869,6 +859,24 @@ class LibraryQuerySet(models.QuerySet): ) ) + def viewable_by(self, actor): + from funkwhale_api.federation.models import LibraryFollow + + if actor is None: + return Library.objects.filter(privacy_level="everyone") + + me_query = models.Q(privacy_level="me", actor=actor) + instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain) + followed_libraries = LibraryFollow.objects.filter( + actor=actor, approved=True + ).values_list("target", flat=True) + return Library.objects.filter( + me_query + | instance_query + | models.Q(privacy_level="everyone") + | models.Q(pk__in=followed_libraries) + ) + class Library(federation_models.FederationMixin): uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) @@ -904,14 +912,20 @@ class Library(federation_models.FederationMixin): return True return False - def schedule_scan(self): - latest_scan = self.scans.order_by("-creation_date").first() + def schedule_scan(self, actor, force=False): + latest_scan = ( + self.scans.exclude(status="errored").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: + if ( + not force + and latest_scan + and latest_scan.creation_date + delay_between_scans > now + ): return - scan = self.scans.create(total_files=self.uploads_count) + scan = self.scans.create(total_files=self.uploads_count, actor=actor) from . import tasks common_utils.on_commit(tasks.start_library_scan.delay, library_scan_id=scan.pk) @@ -921,6 +935,7 @@ class Library(federation_models.FederationMixin): SCAN_STATUS = [ ("pending", "pending"), ("scanning", "scanning"), + ("errored", "errored"), ("finished", "finished"), ] diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 0a4c0422556f350e455f3591dc43ef793fac9e9b..f3a57a8398192022f2923ea6643a7fb5246affa6 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -29,7 +29,6 @@ logger = logging.getLogger(__name__) def update_album_cover(album, source=None, cover_data=None, replace=False): if album.cover and not replace: return - if cover_data: return album.get_image(data=cover_data) @@ -118,17 +117,17 @@ def import_batch_notify_followers(import_batch): 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.app.task(name="music.start_library_scan") @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) + try: + data = lb.get_library_data(library_scan.library.fid, actor=library_scan.actor) + except Exception: + library_scan.status = "errored" + library_scan.save(update_fields=["status", "modification_date"]) + raise library_scan.modification_date = timezone.now() library_scan.status = "scanning" library_scan.total_files = data["totalItems"] @@ -152,10 +151,6 @@ def scan_library_page(library_scan, page_url): for item_serializer in data["items"]: upload = item_serializer.save(library=library_scan.library) - if upload.import_status == "pending" and not upload.track: - # this track is not matched to any musicbrainz or other musical - # metadata - process_upload.delay(upload_id=upload.pk) uploads.append(upload) library_scan.processed_files = F("processed_files") + len(uploads) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index a1688127c442e20a8205fbf4f3f22f923af759b3..5736bfdee46f584eead18651831643fecdb237a5 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -271,6 +271,15 @@ def media_root(settings): shutil.rmtree(tmp_dir) +@pytest.fixture(autouse=True) +def disabled_musicbrainz(mocker): + # we ensure no music brainz requests gets out + yield mocker.patch( + "musicbrainzngs.musicbrainz._safe_read", + side_effect=Exception("Disabled network calls"), + ) + + @pytest.fixture(autouse=True) def r_mock(requests_mock): """ diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index b1d7af650a37a42a998b8617c01f190495230466..2eacda1a8d3926aaf2b0c275e1050dc3d47ee257 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -1,3 +1,5 @@ +import pytest + from funkwhale_api.federation import api_serializers from funkwhale_api.federation import serializers @@ -14,6 +16,7 @@ def test_library_serializer(factories): "uploads_count": library.uploads_count, "privacy_level": library.privacy_level, "follow": None, + "latest_scan": None, } serializer = api_serializers.LibrarySerializer(library) @@ -21,6 +24,16 @@ def test_library_serializer(factories): assert serializer.data == expected +def test_library_serializer_latest_scan(factories): + library = factories["music.Library"](uploads_count=5678) + scan = factories["music.LibraryScan"](library=library) + setattr(library, "latest_scans", [scan]) + expected = api_serializers.LibraryScanSerializer(scan).data + serializer = api_serializers.LibrarySerializer(library) + + assert serializer.data["latest_scan"] == expected + + def test_library_serializer_with_follow(factories): library = factories["music.Library"](uploads_count=5678) follow = factories["federation.LibraryFollow"](target=library) @@ -36,6 +49,7 @@ def test_library_serializer_with_follow(factories): "uploads_count": library.uploads_count, "privacy_level": library.privacy_level, "follow": api_serializers.NestedLibraryFollowSerializer(follow).data, + "latest_scan": None, } serializer = api_serializers.LibrarySerializer(library) @@ -43,7 +57,7 @@ def test_library_serializer_with_follow(factories): assert serializer.data == expected -def test_library_serializer_validates_existing_follow(factories): +def test_library_follow_serializer_validates_existing_follow(factories): follow = factories["federation.LibraryFollow"]() serializer = api_serializers.LibraryFollowSerializer( data={"target": follow.target.uuid}, context={"actor": follow.actor} @@ -53,6 +67,16 @@ def test_library_serializer_validates_existing_follow(factories): assert "target" in serializer.errors +def test_library_follow_serializer_do_not_allow_own_library(factories): + actor = factories["federation.Actor"]() + library = factories["music.Library"](actor=actor) + serializer = api_serializers.LibraryFollowSerializer(context={"actor": actor}) + + with pytest.raises(api_serializers.serializers.ValidationError) as e: + serializer.validate_target(library) + assert "own library" in str(e) + + def test_manage_upload_action_read(factories): ii = factories["federation.InboxItem"]() s = api_serializers.InboxItemActionSerializer(queryset=None) diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index d99b85003213fc139efde3cc0d6c4d8f732e440a..c2d695184572fcfcbcd5bfc9a24f62f445d9640c 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -20,12 +20,12 @@ def test_user_can_list_their_library_follows(factories, logged_in_api_client): assert response.data["results"][0]["uuid"] == str(follow.uuid) -def test_user_can_scan_library_using_url(mocker, factories, logged_in_api_client): +def test_user_can_fetch_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") + url = reverse("api:v1:federation:libraries-fetch") response = logged_in_api_client.post(url, {"fid": library.fid}) assert mocked_retrieve.call_count == 1 args = mocked_retrieve.call_args @@ -36,6 +36,22 @@ def test_user_can_scan_library_using_url(mocker, factories, logged_in_api_client assert response.data["results"] == [api_serializers.LibrarySerializer(library).data] +def test_user_can_schedule_library_scan(mocker, factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + library = factories["music.Library"](privacy_level="everyone") + + schedule_scan = mocker.patch( + "funkwhale_api.music.models.Library.schedule_scan", return_value=True + ) + url = reverse("api:v1:federation:libraries-scan", kwargs={"uuid": library.uuid}) + + response = logged_in_api_client.post(url) + + assert response.status_code == 200 + + schedule_scan.assert_called_once_with(actor=actor) + + 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() @@ -53,6 +69,24 @@ def test_can_follow_library(factories, logged_in_api_client, mocker): dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow}) +def test_can_undo_library_follow(factories, logged_in_api_client, mocker): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + actor = logged_in_api_client.user.create_actor() + follow = factories["federation.LibraryFollow"](actor=actor) + delete = mocker.patch.object(follow.__class__, "delete") + url = reverse( + "api:v1:federation:library-follows-detail", kwargs={"uuid": follow.uuid} + ) + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + + delete.assert_called_once_with() + dispatch.assert_called_once_with( + {"type": "Undo", "object": {"type": "Follow"}}, context={"follow": follow} + ) + + @pytest.mark.parametrize("action", ["accept", "reject"]) def test_user_cannot_edit_someone_else_library_follow( factories, logged_in_api_client, action diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 664fb44311d73438801bd99ffd2fb18090d0b6cb..79d194f5636c6ea396174946428d1953c7e8dbbf 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -11,6 +11,7 @@ from funkwhale_api.federation import routes, serializers ({"type": "Create", "object.type": "Audio"}, routes.inbox_create_audio), ({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library), ({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio), + ({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow), ], ) def test_inbox_routes(route, handler): @@ -30,6 +31,7 @@ def test_inbox_routes(route, handler): ({"type": "Create", "object.type": "Audio"}, routes.outbox_create_audio), ({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library), ({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio), + ({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow), ], ) def test_outbox_routes(route, handler): @@ -148,7 +150,7 @@ def test_inbox_accept(factories, mocker): follow.refresh_from_db() assert follow.approved is True - mocked_scan.assert_called_once_with() + mocked_scan.assert_called_once_with(actor=follow.actor) def test_outbox_follow_library(factories, mocker): @@ -311,3 +313,43 @@ def test_outbox_delete_audio(factories): assert dict(activity["payload"]) == dict(expected) assert activity["actor"] == upload.library.actor + + +def test_inbox_delete_follow_library(factories): + local_actor = factories["users.User"]().create_actor() + remote_actor = factories["federation.Actor"]() + follow = factories["federation.LibraryFollow"]( + actor=local_actor, target__actor=remote_actor, approved=True + ) + assert follow.approved is True + serializer = serializers.UndoFollowSerializer( + follow, context={"actor": local_actor} + ) + ii = factories["federation.InboxItem"](actor=local_actor) + routes.inbox_undo_follow( + serializer.data, + context={"actor": local_actor, "inbox_items": [ii], "raise_exception": True}, + ) + with pytest.raises(follow.__class__.DoesNotExist): + follow.refresh_from_db() + + +def test_outbox_delete_follow_library(factories): + remote_actor = factories["federation.Actor"]() + local_actor = factories["federation.Actor"](local=True) + follow = factories["federation.LibraryFollow"]( + actor=local_actor, target__actor=remote_actor + ) + + activity = list(routes.outbox_undo_follow({"follow": follow}))[0] + + serializer = serializers.UndoFollowSerializer( + follow, context={"actor": follow.actor} + ) + expected = serializer.data + expected["to"] = [follow.target.actor] + + assert activity["payload"] == expected + assert activity["actor"] == follow.actor + assert activity["object"] == follow + assert activity["related_object"] == follow.target diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 54e044c31284cccf31909c88f2a6a429cddc5088..c43647070116d9beec041b6543bd28e6033a1a2a 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -736,7 +736,7 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock): assert album_artist.creation_date == published -def test_activity_pub_upload_serializer_from_ap(factories, mocker): +def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock): activity = factories["federation.Activity"]() library = factories["music.Library"]() @@ -769,6 +769,11 @@ def test_activity_pub_upload_serializer_from_ap(factories, mocker): "musicbrainzId": str(uuid.uuid4()), "published": published.isoformat(), "released": released.isoformat(), + "cover": { + "type": "Link", + "href": "https://cover.image/test.png", + "mediaType": "image/png", + }, "artists": [ { "type": "Artist", @@ -790,6 +795,7 @@ def test_activity_pub_upload_serializer_from_ap(factories, mocker): ], }, } + r_mock.get(data["track"]["album"]["cover"]["href"], body=io.BytesIO(b"coucou")) serializer = serializers.UploadSerializer(data=data, context={"activity": activity}) assert serializer.is_valid(raise_exception=True) diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index a3bf7e2c28fb5b5f0a80ffbea78194c170c7c156..ac359eac6c106b149c0edd87f2c336a3170a4e76 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -149,7 +149,7 @@ def test_music_library_retrieve(factories, api_client, privacy_level): def test_music_library_retrieve_page_public(factories, api_client): library = factories["music.Library"](privacy_level="everyone") - upload = factories["music.Upload"](library=library) + upload = factories["music.Upload"](library=library, import_status="finished") id = library.get_federation_id() expected = serializers.CollectionPageSerializer( { diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 16ab5055d41d89bfa2942d979ec40b94d9a89f80..f3a1df3bf8a87f52a978256b2d0c72fd6a05d8d0 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -388,11 +388,12 @@ def test_library_schedule_scan(factories, now, mocker): on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") library = factories["music.Library"](uploads_count=5) - scan = library.schedule_scan() + scan = library.schedule_scan(library.actor) assert scan.creation_date >= now assert scan.status == "pending" assert scan.library == library + assert scan.actor == library.actor assert scan.total_files == 5 assert scan.processed_files == 0 assert scan.errored_files == 0 @@ -405,7 +406,7 @@ def test_library_schedule_scan(factories, now, mocker): def test_library_schedule_scan_too_recent(factories, now): scan = factories["music.LibraryScan"]() - result = scan.library.schedule_scan() + result = scan.library.schedule_scan(scan.library.actor) assert result is None assert scan.library.scans.count() == 1 diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index de5e0310f6fbf3060cae2f91d5195a9fb60d6798..efa0e801f42dfe6c785a35ca4ee70d63dfd8be82 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -50,6 +50,7 @@ def test_can_create_track_from_file_metadata_mbid(factories, mocker): "musicbrainz_recordingid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", "musicbrainz_artistid": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13", "musicbrainz_albumartistid": "9c6bddde-6478-4d9f-ad0d-03f6fcb19e13", + "cover_data": {"content": b"image_content", "mimetype": "image/png"}, } mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) @@ -235,6 +236,7 @@ def test_upload_import_in_place(factories, mocker): assert upload.size == 23 assert upload.duration == 42 assert upload.bitrate == 66 + assert upload.mimetype == "audio/ogg" def test_upload_import_skip_existing_track_in_own_library(factories, temp_signal): @@ -464,15 +466,18 @@ def test_scan_library_fetches_page_and_calls_scan_page(now, mocker, factories, r "id": scan.library.fid, "page_size": 10, "items": range(10), + "type": "Library", + "name": "hello", } collection = federation_serializers.PaginatedCollectionSerializer(collection_conf) + data = collection.data + data["followers"] = "https://followers.domain" + scan_page = mocker.patch("funkwhale_api.music.tasks.scan_library_page.delay") - r_mock.get(collection_conf["id"], json=collection.data) + r_mock.get(collection_conf["id"], json=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_page.assert_called_once_with(library_scan_id=scan.pk, page_url=data["first"]) scan.refresh_from_db() assert scan.status == "scanning" diff --git a/api/tests/test_downloader.py b/api/tests/test_downloader.py deleted file mode 100644 index 0a41343935590fe370ccf34286787f2314a58982..0000000000000000000000000000000000000000 --- a/api/tests/test_downloader.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from funkwhale_api import downloader - - -def test_can_download_audio_from_youtube_url_to_vorbis(tmpdir): - data = downloader.download( - "https://www.youtube.com/watch?v=tPEE9ZwTmy0", target_directory=tmpdir - ) - assert data["audio_file_path"] == os.path.join(tmpdir, "tPEE9ZwTmy0.ogg") - assert os.path.exists(data["audio_file_path"]) diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index c95d055e6efbceacc317d6aeee6b0bc2a541699a..48f5896ad25dbd6caa2812558f0ac69fc99670a4 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -22,10 +22,48 @@ <human-date :date="library.creation_date" /> </span> </div> - <div class="content"> + <div class="meta"> <i class="music icon"></i> <translate :translate-params="{count: library.uploads_count}" :translate-n="library.uploads_count" translate-plural="%{ count } tracks">1 tracks</translate> </div> + <div v-if="latestScan" class="meta"> + <template v-if="latestScan.status === 'pending'"> + <i class="hourglass icon"></i> + <translate>Scan pending</translate> + </template> + <template v-if="latestScan.status === 'scanning'"> + <i class="loading spinner icon"></i> + <translate :translate-params="{progress: scanProgress}">Scanning... (%{ progress }%)</translate> + </template> + <template v-else-if="latestScan.status === 'errored'"> + <i class="red download icon"></i> + <translate>Error during scan</translate> + </template> + <template v-else-if="latestScan.status === 'finished' && latestScan.errored_files === 0"> + <i class="green download icon"></i> + <translate>Scanned successfully</translate> + </template> + <template v-else-if="latestScan.status === 'finished' && latestScan.errored_files > 0"> + <i class="yellow download icon"></i> + <translate>Scanned with errors</translate> + </template> + <span class="link right floated" @click="showScan = !showScan"> + <translate>Details</translate> + <i v-if="showScan" class="angle down icon" /> + <i v-else class="angle right icon" /> + </span> + <div v-if="showScan"> + <template v-if="latestScan.modification_date"> + <translate>Last update:</translate><human-date :date="latestScan.modification_date" /><br /> + </template> + <translate>Errored tracks:</translate> {{ latestScan.errored_files }} + </div> + </div> + <div v-if="canLaunchScan" class="clearfix"> + <span class="right floated link" @click="launchScan"> + <translate>Launch scan</translate> <i class="paper plane icon" /> + </span> + </div> </div> <div class="extra content"> <actor-link :actor="library.actor" /> @@ -47,11 +85,18 @@ class="ui disabled button"><i class="check icon"></i> <translate>Following</translate> </button> - <button + <dangerous-button v-else-if="library.follow.approved" - class="ui button"><i class="x icon"></i> + color="" + :class="['ui', 'button']" + :action="unfollow"> <translate>Unfollow</translate> - </button> + <p slot="modal-header"><translate>Unfollow this library?</translate></p> + <div slot="modal-content"> + <p><translate>By unfollowing this library, you will loose access to its content.</translate></p> + </div> + <p slot="modal-confirm"><translate>Unfollow</translate></p> + </dangerous-button> </div> </div> </template> @@ -62,7 +107,10 @@ export default { props: ['library'], data () { return { - isLoadingFollow: false + isLoadingFollow: false, + showScan: false, + scanTimeout: null, + latestScan: this.library.latest_scan, } }, computed: { @@ -76,18 +124,92 @@ export default { everyone } } + }, + scanProgress () { + let scan = this.latestScan + let progress = scan.processed_files * 100 / scan.total_files + return Math.min(parseInt(progress), 100) + }, + scanStatus () { + if (this.latestScan) { + return this.latestScan.status + } + return 'unknown' + }, + canLaunchScan () { + if (this.scanStatus === 'pending') { + return false + } + if (this.scanStatus === 'scanning') { + return false + } + return true } }, methods: { + launchScan () { + let self = this + let successMsg = this.$gettext('Scan launched') + let skippedMsg = this.$gettext('Scan skipped (previous scan is too recent)') + axios.post(`federation/libraries/${this.library.uuid}/scan/`).then((response) => { + let msg + if (response.data.status == 'skipped') { + msg = skippedMsg + } else { + self.latestScan = response.data.scan + msg = successMsg + } + self.$store.commit('ui/addMessage', { + content: msg, + date: new Date() + }) + }) + }, 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 + self.$emit('followed') + + }, error => { + self.isLoadingFollow = false + }) + }, + unfollow () { + let self = this + this.isLoadingFollow = true + axios.delete(`federation/follows/library/${this.library.follow.uuid}/`).then((response) => { + self.$emit('deleted') + self.isLoadingFollow = false }, error => { self.isLoadingFollow = false }) + }, + fetchScanStatus () { + let self = this + axios.get(`federation/follows/library/${this.library.follow.uuid}/`).then((response) => { + self.latestScan = response.data.target.latest_scan + if (self.scanStatus === 'pending' || self.scanStatus === 'scanning') { + self.scanTimeout = setTimeout(self.fetchScanStatus(), 5000) + } else { + clearTimeout(self.scanTimeout) + } + }) + } + }, + watch: { + showScan (newValue, oldValue) { + if (newValue) { + if (this.scanStatus === 'pending' || this.scanStatus === 'scanning') { + this.fetchScanStatus() + } + } else { + if (this.scanTimeout) { + clearTimeout(this.scanTimeout) + } + } } } } diff --git a/front/src/views/content/remote/Home.vue b/front/src/views/content/remote/Home.vue index d781f696ef4392b031838ec75ee36b7b2b8cb2e8..c22dd677b2d63c4ca9b4ec1ce72cccfea20f5ac5 100644 --- a/front/src/views/content/remote/Home.vue +++ b/front/src/views/content/remote/Home.vue @@ -13,8 +13,15 @@ </div> <template v-if="existingFollows && existingFollows.count > 0"> <h2><translate>Known libraries</translate></h2> + <i @click="fetch()" :class="['ui', 'circular', 'medium', 'refresh', 'icon']" /> <translate>Refresh</translate> + <div class="ui hidden divider"></div> <div class="ui two cards"> - <library-card :library="getLibraryFromFollow(follow)" v-for="follow in existingFollows.results" :key="follow.fid" /> + <library-card + @deleted="fetch()" + @followed="fetch()" + :library="getLibraryFromFollow(follow)" + v-for="follow in existingFollows.results" + :key="follow.fid" /> </div> </template> </div> @@ -47,6 +54,9 @@ export default { let self = this axios.get('federation/follows/library/', {params: {'page_size': 100, 'ordering': '-creation_date'}}).then((response) => { self.existingFollows = response.data + self.existingFollows.results.forEach(f => { + f.target.follow = f + }) self.isLoading = false }, error => { self.isLoading = false diff --git a/front/src/views/content/remote/ScanForm.vue b/front/src/views/content/remote/ScanForm.vue index 9ff614efb45b6f4fe44b921a645b8c80a80a6889..ddd9eff8d2b04576add2265697f7816fe88c53bb 100644 --- a/front/src/views/content/remote/ScanForm.vue +++ b/front/src/views/content/remote/ScanForm.vue @@ -34,7 +34,7 @@ export default { return } let self = this - axios.post('federation/libraries/scan/', {fid: this.query}).then((response) => { + axios.post('federation/libraries/fetch/', {fid: this.query}).then((response) => { self.$emit('scanned', response.data) self.isLoading = false }, error => {