From 4db52025b9094228f7fa860194e443b6c301b67e Mon Sep 17 00:00:00 2001 From: Petitminion Date: Wed, 17 Sep 2025 01:48:01 +0200 Subject: [PATCH] feat(back):trigger public library follow on user follow --- api/funkwhale_api/federation/api_views.py | 10 +++++ api/funkwhale_api/federation/utils.py | 45 +++++++++++++++++++++++ api/funkwhale_api/federation/views.py | 8 +++- api/funkwhale_api/music/filters.py | 11 ++++++ api/tests/federation/test_api_views.py | 40 +++++++++++++++++++- docs/specs/user-follow/index.md | 16 ++++++++ 6 files changed, 128 insertions(+), 2 deletions(-) diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index a076f5a1d..bd95be461 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -9,6 +9,7 @@ from rest_framework.exceptions import NotFound as RestNotFound from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.federation import actors from funkwhale_api.music import models as music_models from funkwhale_api.music import serializers as music_serializers from funkwhale_api.music import views as music_views @@ -359,6 +360,15 @@ class UserFollowViewSet( def perform_create(self, serializer): follow = serializer.save(actor=self.request.user.actor) routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow}) + if not follow.target.is_local: + public_lib = utils.get_or_create_buildin_actor_library( + follow.target, privacy_level="everyone" + ) + lib_follow = models.LibraryFollow.objects.create( + actor=actors.get_service_actor(), + target=public_lib, + ) + routes.outbox.dispatch({"type": "Follow"}, context={"follow": lib_follow}) @transaction.atomic def perform_destroy(self, instance): diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index c3cb9501f..6abe50c1c 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -290,7 +290,52 @@ def can_manage(obj_owner, actor): return False +<<<<<<< HEAD def update_actor_privacy(actor, privacy_level): actor.track_favorites.update(privacy_level=privacy_level) actor.listenings.update(privacy_level=privacy_level) # to do : trigger federation privacy_level downgrade #2336 +======= +class BuildInLibException(Exception): + pass + + +def get_or_create_builtin_actor_library(actor, privacy_level): + from funkwhale_api.music import models as music_models + + from . import actors + + service_actor = actors.get_service_actor() + auth = signing.get_auth(service_actor.private_key, service_actor.private_key_id) + response = session.get_session().get( + f"https://{actor.domain}/api/v2/federation/music/libraries", + auth=auth, + params={ + "actor": actor.preferred_username, + "privacy_level": privacy_level, + "name": privacy_level, + }, + headers={ + "Accept": "application/activity+json", + "Content-Type": "application/activity+json", + }, + ) + response.raise_for_status() + data = response.json() + if len(data["results"]) == 0: + raise BuildInLibException( + f"Could not find built-in lib {privacy_level} for actor {actor}" + ) + elif not len(data["results"]) == 1: + raise BuildInLibException( + f"Too many built-in lib {privacy_level} for actor {actor}" + ) + else: + lib, created = music_models.Library.objects.get_or_create( + actor=actor, + playlist__isnull=True, + privacy_level="everyone", + name="everyone", + ) + return lib +>>>>>>> afc77ed5e (feat(back):trigger public library follow on user follow) diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 8b6617132..f0a241eae 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -14,6 +14,7 @@ from funkwhale_api.favorites import models as favorites_models from funkwhale_api.federation import utils as federation_utils from funkwhale_api.history import models as history_models from funkwhale_api.moderation import models as moderation_models +from funkwhale_api.music import filters as music_filters from funkwhale_api.music import models as music_models from funkwhale_api.music import utils as music_utils from funkwhale_api.playlists import models as playlists_models @@ -380,7 +381,10 @@ def has_playlist_access(request, playlist): class MusicLibraryViewSet( - FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet + FederationMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, + mixins.ListModelMixin, ): authentication_classes = [authentication.SignatureAuthentication] renderer_classes = renderers.get_ap_renderers() @@ -391,6 +395,8 @@ class MusicLibraryViewSet( .select_related("actor") .filter(channel=None) ) + + filterset_class = music_filters.LibraryFilter lookup_field = "uuid" def retrieve(self, request, *args, **kwargs): diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 982cfe72f..d91be131e 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -387,7 +387,18 @@ class LibraryFilter(filters.FilterSet): distinct=True, library_field="pk", ) + actor = filters.CharFilter(method="filter_actor") class Meta: model = models.Library fields = ["privacy_level"] + + def filter_actor(self, queryset, name, value): + # supports username or username@domain + if "@" in value: + username, domain = value.split("@", 1) + return queryset.filter( + actor__preferred_username=username, + actor__domain_id=domain, + ) + return queryset.filter(actor__preferred_username=value) diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index f1e581143..acf60b20c 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -1,9 +1,17 @@ import datetime +from unittest.mock import Mock import pytest from django.urls import reverse -from funkwhale_api.federation import api_serializers, serializers, tasks, views +from funkwhale_api.federation import ( + actors, + api_serializers, + models, + serializers, + tasks, + views, +) def test_user_can_list_their_library_follows(factories, logged_in_api_client): @@ -457,3 +465,33 @@ def test_user_can_accept_or_reject_own_received_follows( mocked_dispatch.assert_called_once_with( {"type": action.title()}, context={"follow": follow} ) + + +def test_following_using_trigger_service_actor_lib_follow( + factories, logged_in_api_client, mocker +): + target_actor = factories["federation.Actor"]() + lib = factories["music.Library"](actor=target_actor, privacy_level="everyone") + mocked_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" + ) + mock_session = Mock() + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.json.side_effect = [ + {"results": [serializers.LibrarySerializer(lib).data]} + ] + mock_session.get.return_value = mock_response + mocker.patch( + "funkwhale_api.federation.utils.session.get_session", + return_value=mock_session, + ) + lib.delete() + url = reverse("api:v1:federation:user-follows-list") + logged_in_api_client.user.create_actor() + logged_in_api_client.post(url, {"target": target_actor.fid}) + + service_follow = models.LibraryFollow.objects.get(actor=actors.get_service_actor()) + mocked_dispatch.assert_called_with( + {"type": "Follow"}, context={"follow": service_follow} + ) diff --git a/docs/specs/user-follow/index.md b/docs/specs/user-follow/index.md index 95b324972..c99cf5d74 100644 --- a/docs/specs/user-follow/index.md +++ b/docs/specs/user-follow/index.md @@ -204,6 +204,22 @@ sequenceDiagram When a **requesting user** unfollows a **target user**, the UI must update to visually indicate that the action has succeeded. All activities relating to the **target user** must be visually hidden. +### Get user upload to remote pod + +When you follow a user you expect to have access to its public and followers content. + +#### Public content (#2422) + +- When create a `UserFollow` we also send a `LibraryFollow` for the public user Library, using the service actor of the local pod. This will allow the remote pod to get access to the content. +- When deleting a `UserFollow` we keep the service actor follow, in case other users in the pod use the remote metadata. + +#### Followers content (#2536) + +As a user I want to share uploads with all my followers. + +- Create a new built-in library with `followers` privacy_level +- Add the `followers` privacy_level in `ManageUploads` vue component + ## Availability - [x] App frontend