diff --git a/api/funkwhale_api/cli/users.py b/api/funkwhale_api/cli/users.py index 115e0dc36..c9c7aaa47 100644 --- a/api/funkwhale_api/cli/users.py +++ b/api/funkwhale_api/cli/users.py @@ -49,7 +49,6 @@ def handler_create_user( utils.logger.warn("Unknown permission %s", permission) utils.logger.debug("Creating actor…") user.actor = models.create_actor(user) - models.create_user_libraries(user) user.save() return user diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index a56c4c41c..dc4bd9052 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -627,6 +627,12 @@ def get_actors_from_audience(urls): final_query, Q(pk__in=actor_follows.values_list("actor", flat=True)) ) + library_follows = models.LibraryFollow.objects.filter( + queries["followed"], approved=True + ) + final_query = funkwhale_utils.join_queries_or( + final_query, Q(pk__in=library_follows.values_list("actor", flat=True)) + ) if not final_query: return models.Actor.objects.none() return models.Actor.objects.filter(final_query) diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index 2c1d5ae49..8a3fd1a76 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -56,6 +56,7 @@ class LibrarySerializer(serializers.ModelSerializer): "uuid", "actor", "name", + "description", "creation_date", "uploads_count", "privacy_level", diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 3cc9f3fcb..06445df76 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -188,9 +188,13 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): uuid = factory.Faker("uuid4") actor = factory.SubFactory(ActorFactory) privacy_level = "me" - name = privacy_level + name = factory.Faker("sentence") + description = factory.Faker("sentence") uploads_count = 0 fid = factory.Faker("federation_url") + followers_url = factory.LazyAttribute( + lambda o: o.fid + "/followers" if o.fid else None + ) class Meta: model = "music.Library" diff --git a/api/funkwhale_api/federation/management/commands/fix_federation_ids.py b/api/funkwhale_api/federation/management/commands/fix_federation_ids.py index bcbed31a3..2ee2eb8a1 100644 --- a/api/funkwhale_api/federation/management/commands/fix_federation_ids.py +++ b/api/funkwhale_api/federation/management/commands/fix_federation_ids.py @@ -9,7 +9,7 @@ MODELS = [ (music_models.Album, ["fid"]), (music_models.Track, ["fid"]), (music_models.Upload, ["fid"]), - (music_models.Library, ["fid"]), + (music_models.Library, ["fid", "followers_url"]), ( federation_models.Actor, [ diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 441688fad..2a8b22751 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -166,7 +166,7 @@ def outbox_follow(context): def outbox_create_audio(context): upload = context["upload"] channel = upload.library.get_channel() - followers_target = channel.actor if channel else upload.library.actor + followers_target = channel.actor if channel else upload.library actor = channel.actor if channel else upload.library.actor if channel: serializer = serializers.ChannelCreateUploadSerializer(upload) @@ -310,8 +310,8 @@ def outbox_delete_audio(context): uploads = context["uploads"] library = uploads[0].library channel = library.get_channel() + followers_target = channel.actor if channel else library actor = channel.actor if channel else library.actor - followers_target = channel.actor if channel else actor serializer = serializers.ActivitySerializer( { "type": "Delete", @@ -679,9 +679,6 @@ def inbox_delete_favorite(payload, context): favorite.delete() -# to do : test listening routes and broadcast - - @outbox.register({"type": "Listen", "object.type": "Track"}) def outbox_create_listening(context): track = context["track"] diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 99e47df5d..d82a413c7 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -996,6 +996,8 @@ class LibrarySerializer(PaginatedCollectionSerializer): actor = serializers.URLField(max_length=500, required=False) attributedTo = serializers.URLField(max_length=500, required=False) name = serializers.CharField() + summary = serializers.CharField(allow_blank=True, allow_null=True, required=False) + followers = serializers.URLField(max_length=500) audience = serializers.ChoiceField( choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"], required=False, @@ -1012,7 +1014,9 @@ class LibrarySerializer(PaginatedCollectionSerializer): PAGINATED_COLLECTION_JSONLD_MAPPING, { "name": jsonld.first_val(contexts.AS.name), + "summary": jsonld.first_val(contexts.AS.summary), "audience": jsonld.first_id(contexts.AS.audience), + "followers": jsonld.first_id(contexts.AS.followers), "actor": jsonld.first_id(contexts.AS.actor), "attributedTo": jsonld.first_id(contexts.AS.attributedTo), }, @@ -1034,6 +1038,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): conf = { "id": library.fid, "name": library.name, + "summary": library.description, "page_size": 100, "attributedTo": library.actor, "actor": library.actor, @@ -1044,6 +1049,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): r["audience"] = ( contexts.AS.Public if library.privacy_level == "everyone" else "" ) + r["followers"] = library.followers_url return r def create(self, validated_data): @@ -1063,6 +1069,8 @@ class LibrarySerializer(PaginatedCollectionSerializer): defaults={ "uploads_count": validated_data["totalItems"], "name": validated_data["name"], + "description": validated_data.get("summary"), + "followers_url": validated_data["followers"], "privacy_level": privacy[validated_data["audience"]], }, ) diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index cb6f26ebb..d02995e76 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -387,6 +387,7 @@ class MusicLibraryViewSet( "id": lb.get_federation_id(), "actor": lb.actor, "name": lb.name, + "summary": lb.description, "items": lb.uploads.for_federation() .order_by("-creation_date") .prefetch_related( diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 0ccc86783..f048ebb64 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -572,6 +572,7 @@ class ManageLibrarySerializer(serializers.ModelSerializer): domain = serializers.CharField(source="domain_name") actor = ManageBaseActorSerializer() uploads_count = serializers.SerializerMethodField() + followers_count = serializers.SerializerMethodField() class Meta: model = music_models.Library @@ -581,11 +582,14 @@ class ManageLibrarySerializer(serializers.ModelSerializer): "fid", "url", "name", + "description", "domain", "is_local", "creation_date", "privacy_level", "uploads_count", + "followers_count", + "followers_url", "actor", ] read_only_fields = [ @@ -601,6 +605,10 @@ class ManageLibrarySerializer(serializers.ModelSerializer): def get_uploads_count(self, obj) -> int: return getattr(obj, "_uploads_count", int(obj.uploads_count)) + @extend_schema_field(OpenApiTypes.INT) + def get_followers_count(self, obj): + return getattr(obj, "followers_count", None) + class ManageNestedLibrarySerializer(serializers.ModelSerializer): domain = serializers.CharField(source="domain_name") @@ -614,10 +622,12 @@ class ManageNestedLibrarySerializer(serializers.ModelSerializer): "fid", "url", "name", + "description", "domain", "is_local", "creation_date", "privacy_level", + "followers_url", "actor", ] diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py index ca81f2d8b..9154d102a 100644 --- a/api/funkwhale_api/moderation/serializers.py +++ b/api/funkwhale_api/moderation/serializers.py @@ -164,6 +164,7 @@ class LibraryStateSerializer(serializers.ModelSerializer): "uuid", "fid", "name", + "description", "creation_date", "privacy_level", ] diff --git a/api/funkwhale_api/music/fake_data.py b/api/funkwhale_api/music/fake_data.py index 9f74c1f72..9fa923d10 100644 --- a/api/funkwhale_api/music/fake_data.py +++ b/api/funkwhale_api/music/fake_data.py @@ -12,7 +12,7 @@ from funkwhale_api.federation import factories as federation_factories from funkwhale_api.history import factories as history_factories from funkwhale_api.music import factories as music_factories from funkwhale_api.playlists import factories as playlist_factories -from funkwhale_api.users import models, serializers +from funkwhale_api.users import serializers logger = logging.getLogger(__name__) @@ -39,7 +39,6 @@ def create_data(super_user_name=None): print( f"Superuser {super_user_name} already in db. Skipping superuser creation" ) - super_user = models.User.objects.get(username=super_user_name) continue else: raise e diff --git a/api/funkwhale_api/music/migrations/0059_remove_album_artist_remove_track_artist_artistcredit_and_more.py b/api/funkwhale_api/music/migrations/0059_remove_album_artist_remove_track_artist_artistcredit_and_more.py index b434d730d..7caaf58b0 100644 --- a/api/funkwhale_api/music/migrations/0059_remove_album_artist_remove_track_artist_artistcredit_and_more.py +++ b/api/funkwhale_api/music/migrations/0059_remove_album_artist_remove_track_artist_artistcredit_and_more.py @@ -36,7 +36,6 @@ def set_all_artists_credit(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ ("music", "0058_upload_quality"), - ("playlists", "0008_playlist_library_drop"), ] operations = [ diff --git a/api/funkwhale_api/music/migrations/0060_remove_library_description_and_more.py b/api/funkwhale_api/music/migrations/0060_remove_library_description_and_more.py deleted file mode 100644 index 6d14e152f..000000000 --- a/api/funkwhale_api/music/migrations/0060_remove_library_description_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.9 on 2025-01-03 20:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("music", "0059_remove_album_artist_remove_track_artist_artistcredit_and_more"), - ("playlists", "0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="library", - name="description", - ), - migrations.RemoveField( - model_name="library", - name="followers_url", - ), - ] diff --git a/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py b/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py deleted file mode 100644 index 52be63931..000000000 --- a/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py +++ /dev/null @@ -1,108 +0,0 @@ -# Generated by Django 4.2.9 on 2025-01-03 16:12 - -from django.db import migrations, models -from django.db import IntegrityError - -from funkwhale_api.federation import utils as federation_utils -from django.urls import reverse -import uuid - - -def insert_tracks_to_playlist(apps, playlist, uploads): - PlaylistTrack = apps.get_model("playlists", "PlaylistTrack") - plts = [ - PlaylistTrack( - creation_date=playlist.creation_date, - playlist=playlist, - track=upload.track, - index=0 + i, - uuid=(new_uuid := uuid.uuid4()), - fid=federation_utils.full_url( - reverse( - f"federation:music:playlists-detail", - kwargs={"uuid": new_uuid}, - ) - ), - ) - for i, upload in enumerate(uploads) - if upload.track - ] - - return PlaylistTrack.objects.bulk_create(plts) - - -def migrate_libraries_to_playlist(apps, schema_editor): - Playlist = apps.get_model("playlists", "Playlist") - Library = apps.get_model("music", "Library") - LibraryFollow = apps.get_model("federation", "LibraryFollow") - Follow = apps.get_model("federation", "Follow") - User = apps.get_model("users", "User") - Actor = apps.get_model("federation", "Actor") - - # library to playlist - for library in Library.objects.all(): - playlist = Playlist.objects.create( - name=library.name, - actor=library.actor, - creation_date=library.creation_date, - privacy_level=library.privacy_level, - uuid=(new_uuid := uuid.uuid4()), - fid=federation_utils.full_url( - reverse( - f"federation:music:playlists-detail", - kwargs={"uuid": new_uuid}, - ) - ), - ) - playlist.save() - - if library.uploads.all().exists(): - insert_tracks_to_playlist(apps, playlist, library.uploads.all()) - - # library follows to user follow - for lib_follow in LibraryFollow.objects.filter(target=library): - try: - Follow.objects.create( - uuid=lib_follow.uuid, - target=library.actor, - actor=lib_follow.actor, - approved=lib_follow.approved, - creation_date=lib_follow.creation_date, - modification_date=lib_follow.modification_date, - ) - except IntegrityError: - pass - - LibraryFollow.objects.all().delete() - - # migrate uploads to new library - for actor in Actor.objects.all(): - privacy_levels = ["me", "instance", "everyone"] - for privacy_level in privacy_levels: - build_in_lib = Library.objects.create( - actor=actor, - privacy_level=privacy_level, - name=privacy_level, - uuid=(new_uuid := uuid.uuid4()), - fid=federation_utils.full_url( - reverse( - f"federation:music:playlists-detail", - kwargs={"uuid": new_uuid}, - ) - ), - ) - for library in actor.libraries.filter(privacy_level=privacy_level): - library.uploads.all().update(library=build_in_lib) - if library.pk is not build_in_lib.pk: - library.delete() - - -class Migration(migrations.Migration): - dependencies = [ - ("music", "0060_remove_library_description_and_more"), - ] - operations = [ - migrations.RunPython( - migrate_libraries_to_playlist, reverse_code=migrations.RunPython.noop - ), - ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index fe1e2b0c9..ff140e4fc 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1335,8 +1335,10 @@ class Library(federation_models.FederationMixin): 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 ) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index c01585759..e6b270426 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -338,6 +338,7 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer): "uuid", "fid", "name", + "description", "privacy_level", "uploads_count", "size", @@ -536,26 +537,6 @@ class UploadForOwnerSerializer(UploadSerializer): return f -class UploadBulkUpdateSerializer(serializers.Serializer): - uuid = serializers.UUIDField() - privacy_level = serializers.ChoiceField( - choices=models.LIBRARY_PRIVACY_LEVEL_CHOICES - ) - - def validate(self, data): - try: - upload = models.Upload.objects.get(uuid=data["uuid"]) - except models.Upload.DoesNotExist: - raise serializers.ValidationError( - f"Upload with uuid {data['uuid']} does not exist" - ) - - upload.library = upload.library.actor.libraries.get( - privacy_level=data["privacy_level"] - ) - return upload - - class UploadActionSerializer(common_serializers.ActionSerializer): actions = [ common_serializers.Action("delete", allow_all=True), diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py index 6a9ca87c0..2518bfe9c 100644 --- a/api/funkwhale_api/music/spa_views.py +++ b/api/funkwhale_api/music/spa_views.py @@ -340,6 +340,7 @@ def library_library(request, uuid, redirect_to_ap): {"tag": "meta", "property": "og:url", "content": library_url}, {"tag": "meta", "property": "og:type", "content": "website"}, {"tag": "meta", "property": "og:title", "content": obj.name}, + {"tag": "meta", "property": "og:description", "content": obj.description}, ] if preferences.get("federation__enabled"): diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 0e9420f9c..60e0e1c99 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -293,8 +293,11 @@ class AlbumViewSet( class LibraryViewSet( + mixins.CreateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, viewsets.GenericViewSet, ): lookup_field = "uuid" @@ -329,6 +332,42 @@ class LibraryViewSet( return qs + def perform_create(self, serializer): + serializer.save(actor=self.request.user.actor) + + @transaction.atomic + def perform_destroy(self, instance): + routes.outbox.dispatch( + {"type": "Delete", "object": {"type": "Library"}}, + context={"library": instance}, + ) + instance.delete() + + @extend_schema( + responses=federation_api_serializers.LibraryFollowSerializer(many=True) + ) + @action( + methods=["get"], + detail=True, + ) + @transaction.non_atomic_requests + def follows(self, request, *args, **kwargs): + library = self.get_object() + queryset = ( + library.received_follows.filter(target__actor=self.request.user.actor) + .prefetch_related("actor", "target__actor") + .order_by("-creation_date") + ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = federation_api_serializers.LibraryFollowSerializer( + page, many=True, required=False + ) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True, required=False) + return Response(serializer.data) + # TODO quickfix, basically specifying the response would be None @extend_schema(responses=None) @action( diff --git a/api/funkwhale_api/playlists/migrations/0008_playlist_library_drop.py b/api/funkwhale_api/playlists/migrations/0008_playlist_library_drop.py deleted file mode 100644 index f141dac35..000000000 --- a/api/funkwhale_api/playlists/migrations/0008_playlist_library_drop.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.9 on 2025-01-03 16:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("playlists", "0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="playlist", - name="name", - field=models.CharField(max_length=100), - ), - migrations.AddField( - model_name="playlist", - name="description", - field=models.TextField(blank=True, max_length=5000, null=True), - ), - ] diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index d70d0b2a3..9e5812df5 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -78,14 +78,14 @@ class PlaylistQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet): class Playlist(federation_models.FederationMixin): uuid = models.UUIDField(default=uuid.uuid4, unique=True) - name = models.CharField(max_length=100) + name = models.CharField(max_length=50) actor = models.ForeignKey( "federation.Actor", related_name="playlists", on_delete=models.CASCADE ) creation_date = models.DateTimeField(default=timezone.now) modification_date = models.DateTimeField(auto_now=True) privacy_level = fields.get_privacy_field() - description = models.TextField(max_length=5000, null=True, blank=True) + objects = PlaylistQuerySet.as_manager() federation_namespace = "playlists" diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index d4338a147..6872b61d0 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -24,7 +24,6 @@ from funkwhale_api.common import validators as common_validators from funkwhale_api.federation import keys from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import utils as federation_utils -from funkwhale_api.music import models as music_models def get_token(length=5): @@ -462,22 +461,6 @@ def create_actor(user, **kwargs): return actor -def create_user_libraries(user): - for privacy_level, l in music_models.LIBRARY_PRIVACY_LEVEL_CHOICES: - music_models.Library.objects.create( - actor=user.actor, - privacy_level=privacy_level, - name=privacy_level, - uuid=(new_uuid := uuid.uuid4()), - fid=federation_utils.full_url( - reverse( - "federation:music:playlists-detail", - kwargs={"uuid": new_uuid}, - ) - ), - ) - - @receiver(ldap_populate_user) def init_ldap_user(sender, user, ldap_user, **kwargs): if not user.actor: diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index f1a7ca73e..3b72c5b51 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -114,7 +114,7 @@ class RegisterSerializer(RS): user_request_id=user_request.pk, new_status=user_request.status, ) - models.create_user_libraries(user) + return user diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index 90e2a74f3..2a54a1494 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -1,3 +1,5 @@ +import uuid + import pytest from django.db.models import Q from django.urls import reverse @@ -244,6 +246,9 @@ def test_should_reject(factories, params, policy_kwargs, expected): def test_get_actors_from_audience_urls(settings, db): settings.FEDERATION_HOSTNAME = "federation.hostname" + library_uuid1 = uuid.uuid4() + library_uuid2 = uuid.uuid4() + urls = [ "https://wrong.url", "https://federation.hostname" @@ -252,15 +257,21 @@ def test_get_actors_from_audience_urls(settings, db): + reverse("federation:actors-detail", kwargs={"preferred_username": "alice"}), "https://federation.hostname" + reverse("federation:actors-detail", kwargs={"preferred_username": "bob"}), - "https://federation.hostname", + "https://federation.hostname" + + reverse("federation:music:libraries-detail", kwargs={"uuid": library_uuid1}), + "https://federation.hostname" + + reverse("federation:music:libraries-detail", kwargs={"uuid": library_uuid2}), activity.PUBLIC_ADDRESS, ] followed_query = Q(target__followers_url=urls[0]) for url in urls[1:-1]: followed_query |= Q(target__followers_url=url) actor_follows = models.Follow.objects.filter(followed_query, approved=True) + library_follows = models.LibraryFollow.objects.filter(followed_query, approved=True) expected = models.Actor.objects.filter( - Q(fid__in=urls[0:-1]) | Q(pk__in=actor_follows.values_list("actor", flat=True)) + Q(fid__in=urls[0:-1]) + | Q(pk__in=actor_follows.values_list("actor", flat=True)) + | Q(pk__in=library_follows.values_list("actor", flat=True)) ) assert str(activity.get_actors_from_audience(urls).query) == str(expected.query) @@ -467,9 +478,17 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): ) remote_actor3 = factories["federation.Actor"](shared_inbox_url=None) remote_actor4 = factories["federation.Actor"]() + + library = factories["music.Library"]() + library_follower_local = factories["federation.LibraryFollow"]( + target=library, actor__local=True, approved=True + ).actor + library_follower_remote = factories["federation.LibraryFollow"]( + target=library, actor__local=False, approved=True + ).actor # follow not approved - factories["federation.Follow"]( - target=remote_actor3, actor__local=False, approved=False + factories["federation.LibraryFollow"]( + target=library, actor__local=False, approved=False ) followed_actor = factories["federation.Actor"]() @@ -492,6 +511,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): remote_actor2, remote_actor3, activity.PUBLIC_ADDRESS, + {"type": "followers", "target": library}, {"type": "followers", "target": followed_actor}, {"type": "actor_inbox", "actor": remote_actor4}, ] @@ -504,6 +524,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): models.InboxItem(actor=local_actor1, type="to"), models.InboxItem(actor=local_actor2, type="to"), models.InboxItem(actor=local_actor3, type="to"), + models.InboxItem(actor=library_follower_local, type="to"), models.InboxItem(actor=actor_follower_local, type="to"), ], key=lambda v: v.actor.pk, @@ -514,6 +535,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): models.Delivery(inbox_url=remote_actor1.shared_inbox_url), models.Delivery(inbox_url=remote_actor3.inbox_url), models.Delivery(inbox_url=remote_actor4.inbox_url), + models.Delivery(inbox_url=library_follower_remote.inbox_url), models.Delivery(inbox_url=actor_follower_remote.inbox_url), ], key=lambda v: v.inbox_url, @@ -527,6 +549,7 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences): remote_actor2.fid, remote_actor3.fid, activity.PUBLIC_ADDRESS, + library.followers_url, followed_actor.followers_url, remote_actor4.fid, ] diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index f60799508..d9d171513 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -12,6 +12,7 @@ def test_library_serializer(factories, to_api_date): "uuid": str(library.uuid), "actor": serializers.APIActorSerializer(library.actor).data, "name": library.name, + "description": library.description, "creation_date": to_api_date(library.creation_date), "uploads_count": library.uploads_count, "privacy_level": library.privacy_level, diff --git a/api/tests/federation/test_commands.py b/api/tests/federation/test_commands.py index b409f8833..621b8cf64 100644 --- a/api/tests/federation/test_commands.py +++ b/api/tests/federation/test_commands.py @@ -29,7 +29,7 @@ def test_fix_fids_no_dry_run(factories, mocker, queryset_equal_queries): (music_models.Album, ["fid"]), (music_models.Track, ["fid"]), (music_models.Upload, ["fid"]), - (music_models.Library, ["fid"]), + (music_models.Library, ["fid", "followers_url"]), ( federation_models.Actor, [ diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index a455fed18..493727c21 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -370,7 +370,7 @@ def test_outbox_create_audio(factories, mocker): } ) expected = serializer.data - expected["to"] = [{"type": "followers", "target": upload.library.actor}] + expected["to"] = [{"type": "followers", "target": upload.library}] assert dict(activity["payload"]) == dict(expected) assert activity["actor"] == upload.library.actor @@ -685,7 +685,7 @@ def test_outbox_delete_audio(factories): {"type": "Delete", "object": {"type": "Audio", "id": [upload.fid]}} ).data - expected["to"] = [{"type": "followers", "target": upload.library.actor}] + expected["to"] = [{"type": "followers", "target": upload.library}] assert dict(activity["payload"]) == dict(expected) assert activity["actor"] == upload.library.actor diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 37883ca5c..d79bd4db2 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -548,11 +548,13 @@ def test_music_library_serializer_to_ap(factories): "type": "Library", "id": library.fid, "name": library.name, + "summary": library.description, "attributedTo": library.actor.fid, "totalItems": 0, "current": library.fid + "?page=1", "last": library.fid + "?page=1", "first": library.fid + "?page=1", + "followers": library.followers_url, } assert serializer.data == expected @@ -567,8 +569,10 @@ def test_music_library_serializer_from_public(factories, mocker): "@context": jsonld.get_default_context(), "audience": "https://www.w3.org/ns/activitystreams#Public", "name": "Hello", + "summary": "World", "type": "Library", "id": "https://library.id", + "followers": "https://library.id/followers", "attributedTo": actor.fid, "totalItems": 12, "first": "https://library.id?page=1", @@ -585,6 +589,8 @@ def test_music_library_serializer_from_public(factories, mocker): assert library.uploads_count == data["totalItems"] assert library.privacy_level == "everyone" assert library.name == "Hello" + assert library.description == "World" + assert library.followers_url == data["followers"] retrieve.assert_called_once_with( actor.fid, @@ -603,8 +609,10 @@ def test_music_library_serializer_from_private(factories, mocker): "@context": jsonld.get_default_context(), "audience": "", "name": "Hello", + "summary": "World", "type": "Library", "id": "https://library.id", + "followers": "https://library.id/followers", "attributedTo": actor.fid, "totalItems": 12, "first": "https://library.id?page=1", @@ -621,6 +629,8 @@ def test_music_library_serializer_from_private(factories, mocker): assert library.uploads_count == data["totalItems"] assert library.privacy_level == "me" assert library.name == "Hello" + assert library.description == "World" + assert library.followers_url == data["followers"] retrieve.assert_called_once_with( actor.fid, actor=None, @@ -637,8 +647,10 @@ def test_music_library_serializer_from_ap_update(factories, mocker): "@context": jsonld.get_default_context(), "audience": "https://www.w3.org/ns/activitystreams#Public", "name": "Hello", + "summary": "World", "type": "Library", "id": library.fid, + "followers": "https://library.id/followers", "attributedTo": actor.fid, "totalItems": 12, "first": "https://library.id?page=1", @@ -654,6 +666,8 @@ def test_music_library_serializer_from_ap_update(factories, mocker): assert library.uploads_count == data["totalItems"] assert library.privacy_level == "everyone" assert library.name == "Hello" + assert library.description == "World" + assert library.followers_url == data["followers"] def test_activity_pub_artist_serializer_to_ap(factories): diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index c5d5e34dd..eafd149d0 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -219,6 +219,7 @@ def test_music_library_retrieve_page_public(factories, api_client): "actor": library.actor, "page": Paginator([upload], 1).page(1), "name": library.name, + "summary": library.description, } ).data diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 57698c885..004174a38 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -447,13 +447,16 @@ def test_manage_library_serializer(factories, now, to_api_date): "fid": library.fid, "url": library.url, "uuid": str(library.uuid), + "followers_url": library.followers_url, "domain": library.domain_name, "is_local": library.is_local, "name": library.name, + "description": library.description, "privacy_level": library.privacy_level, "creation_date": to_api_date(library.creation_date), "actor": serializers.ManageBaseActorSerializer(library.actor).data, "uploads_count": 44, + "followers_count": 42, } s = serializers.ManageLibrarySerializer(library) diff --git a/api/tests/music/test_migrations.py b/api/tests/music/test_migrations.py index 125e619cc..69826636e 100644 --- a/api/tests/music/test_migrations.py +++ b/api/tests/music/test_migrations.py @@ -1,8 +1,3 @@ -from uuid import uuid4 - -import pytest -from django.utils.timezone import now - # this test is commented since it's very slow, but it can be useful for future development # def test_pytest_plugin_initial(migrator): # mapping_list = [ @@ -77,107 +72,3 @@ def test_artist_credit_migration(migrator): assert album_obj.artist_credit.all()[0].artist.pk == old_album.artist.pk assert album_obj.artist_credit.all()[0].joinphrase == "" assert album_obj.artist_credit.all()[0].credit == old_album.artist.name - - -@pytest.mark.django_db -def test_migrate_libraries_to_playlist(migrator): - music_initial_migration = ( - "music", - "0059_remove_album_artist_remove_track_artist_artistcredit_and_more", - ) - music_final_migration = ("music", "0061_migrate_libraries_to_playlist") - - # Apply migrations - migrator.migrate( - [ - music_initial_migration, - ] - ) - music_apps = migrator.loader.project_state([music_initial_migration]).apps - - Playlist = music_apps.get_model("playlists", "Playlist") - LibraryFollow = music_apps.get_model("federation", "LibraryFollow") - Actor = music_apps.get_model("federation", "Actor") - Domain = music_apps.get_model("federation", "Domain") - Track = music_apps.get_model("music", "Track") - Library = music_apps.get_model("music", "Library") - Upload = music_apps.get_model("music", "Upload") - - # Create data - domain = Domain.objects.create() - domain2 = Domain.objects.create(pk=2) - actor = Actor.objects.create(name="Test Actor", domain=domain) - existing_urls = Actor.objects.values_list("fid", flat=True) - print(existing_urls) - target_actor = Actor.objects.create( - name="Test Actor 2", domain=domain2, fid="http://test2.com/superduniquemanonmam" - ) - - library = Library.objects.create( - name="This should becane playlist name", - actor=target_actor, - creation_date=now(), - privacy_level="everyone", - uuid=uuid4(), - ) - - Track.objects.create() - Track.objects.create() - track = Track.objects.create() - track2 = Track.objects.create() - track3 = Track.objects.create() - - uploads = [ - Upload.objects.create(library=library, track=track), - Upload.objects.create(library=library, track=track2), - Upload.objects.create(library=library, track=track3), - ] - - library_follow = LibraryFollow.objects.create( - uuid=uuid4(), - target=library, - actor=actor, - approved=True, - creation_date=now(), - modification_date=now(), - ) - - # Perform migration - migrator.loader.build_graph() - migrator.migrate([music_final_migration]) - - new_apps = migrator.loader.project_state([music_final_migration]).apps - Playlist = new_apps.get_model("playlists", "Playlist") - PlaylistTrack = new_apps.get_model("playlists", "PlaylistTrack") - Follow = new_apps.get_model("federation", "Follow") - LibraryFollow = new_apps.get_model("federation", "LibraryFollow") - Follow = new_apps.get_model("federation", "Follow") - - # Assertions - - # Verify Playlist creation - playlist = Playlist.objects.get(name="This should becane playlist name") - - assert playlist.actor.pk == library.actor.pk - assert playlist.creation_date == library.creation_date - assert playlist.privacy_level == library.privacy_level - assert playlist.description == library.description - - # Verify PlaylistTrack creation - playlist_tracks = PlaylistTrack.objects.filter(playlist=playlist).order_by("index") - assert playlist_tracks.count() == 3 - for i, playlist_track in enumerate(playlist_tracks): - assert playlist_track.track.pk == uploads[i].track.pk - - # Verify User Follow creation - follow = Follow.objects.get(target__pk=target_actor.pk) - assert follow.actor.pk == actor.pk - assert follow.approved == library_follow.approved - - # Verify LibraryFollow deletion and library creation - assert LibraryFollow.objects.count() == 0 - - # Test fail but works on real db I don't get why - # no library are found in the new app - # NewAppLibrary = new_apps.get_model("music", "Library") - # assert NewAppLibrary.objects.count() == 3 diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 384035309..72250110c 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -263,7 +263,7 @@ def test_library(factories): now = timezone.now() actor = factories["federation.Actor"]() library = factories["music.Library"]( - name="Hello world", actor=actor, privacy_level="instance" + name="Hello world", description="hello", actor=actor, privacy_level="instance" ) assert library.creation_date >= now diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index c0c7fc100..78f55b9e7 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -415,6 +415,31 @@ def test_track_upload_serializer(factories): assert data == expected +@pytest.mark.parametrize( + "field,before,after", + [ + ("privacy_level", "me", "everyone"), + ("name", "Before", "After"), + ("description", "Before", "After"), + ], +) +def test_update_library_privacy_level_broadcasts_to_followers( + factories, field, before, after, mocker +): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + library = factories["music.Library"](**{field: before}) + + serializer = serializers.LibraryForOwnerSerializer( + library, data={field: after}, partial=True + ) + assert serializer.is_valid(raise_exception=True) + serializer.save() + + dispatch.assert_called_once_with( + {"type": "Update", "object": {"type": "Library"}}, context={"library": library} + ) + + def test_upload_with_channel(factories, uploaded_audio_file): channel = factories["audio.Channel"](attributed_to__local=True) user = channel.attributed_to.user diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py index 2663595d5..d388f64ce 100644 --- a/api/tests/music/test_spa_views.py +++ b/api/tests/music/test_spa_views.py @@ -311,6 +311,7 @@ def test_library_library(spa_html, no_api_auth, client, factories, settings): }, {"tag": "meta", "property": "og:type", "content": "website"}, {"tag": "meta", "property": "og:title", "content": library.name}, + {"tag": "meta", "property": "og:description", "content": library.description}, { "tag": "link", "rel": "alternate", diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index dfef58a54..2f8c4e2cf 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -638,6 +638,25 @@ def test_listen_transcode_in_place( ) +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 + + assert library.actor == actor + assert library.name == "hello" + assert library.description == "world" + assert library.privacy_level == "me" + assert library.fid == library.get_federation_id() + assert library.followers_url == library.fid + "/followers" + + def test_user_can_list_their_library(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() library = factories["music.Library"](actor=actor) @@ -691,7 +710,17 @@ def test_user_cannot_delete_other_actors_library(factories, logged_in_api_client url = reverse("api:v1:libraries-detail", kwargs={"uuid": library.uuid}) response = logged_in_api_client.delete(url) - assert response.status_code == 405 + assert response.status_code == 404 + + +def test_library_delete_via_api_triggers_outbox(factories, mocker): + dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + library = factories["music.Library"]() + view = views.LibraryViewSet() + view.perform_destroy(library) + dispatch.assert_called_once_with( + {"type": "Delete", "object": {"type": "Library"}}, context={"library": library} + ) def test_user_cannot_get_other_not_playable_uploads(factories, logged_in_api_client): @@ -906,6 +935,25 @@ def test_user_can_patch_draft_upload_status_triggers_processing( m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk) +def test_user_can_list_own_library_follows(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + library = factories["music.Library"](actor=actor) + another_library = factories["music.Library"](actor=actor) + follow = factories["federation.LibraryFollow"](target=library) + factories["federation.LibraryFollow"](target=another_library) + + url = reverse("api:v1:libraries-follows", kwargs={"uuid": library.uuid}) + + response = logged_in_api_client.get(url) + + assert response.data == { + "count": 1, + "next": None, + "previous": None, + "results": [federation_api_serializers.LibraryFollowSerializer(follow).data], + } + + @pytest.mark.parametrize("entity", ["artist", "album", "track"]) def test_can_get_libraries_for_music_entities( factories, api_client, entity, preferences @@ -1565,25 +1613,3 @@ def test_album_create_artist_credit(factories, logged_in_api_client): url, {"artist": artist.pk, "title": "super album"}, format="json" ) assert response.status_code == 204 - - -def test_can_patch_upload_list(factories, logged_in_api_client): - url = reverse("api:v1:uploads-bulk-update") - actor = logged_in_api_client.user.create_actor() - upload = factories["music.Upload"](library__actor=actor) - upload2 = factories["music.Upload"](library__actor=actor) - factories["music.Library"](actor=actor, privacy_level="everyone") - - response = logged_in_api_client.patch( - url, - [ - {"uuid": upload.uuid, "privacy_level": "everyone"}, - {"uuid": upload2.uuid, "privacy_level": "everyone"}, - ], - format="json", - ) - upload.refresh_from_db() - upload2.refresh_from_db() - - assert response.status_code == 200 - assert upload.library.privacy_level == "everyone" diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index a79690c6c..631a5ac49 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -89,30 +89,3 @@ def test_playlist_serializer(factories, to_api_date): serializer = serializers.PlaylistSerializer(playlist) assert serializer.data == expected - - -# to do : - -# @pytest.mark.parametrize( -# "field,before,after", -# [ -# ("privacy_level", "me", "everyone"), -# ("name", "Before", "After"), -# ("description", "Before", "After"), -# ], -# ) -# def test_update_playlist_privacy_level_broadcasts_to_followers( -# factories, field, before, after, mocker -# ): -# dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") -# playlist = factories["playlists.Playlist"](**{field: before}) - -# serializer = serializers.PlaylistSerializer( -# playlist, data={field: after}, partial=True -# ) -# assert serializer.is_valid(raise_exception=True) -# serializer.save() - -# dispatch.assert_called_once_with( -# {"type": "Update", "object": {"type": "Library"}}, context={"library": library} -# ) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index d6871f03d..5a3f9dc55 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -4,7 +4,6 @@ import pytest from django.urls import reverse from funkwhale_api.federation import utils as federation_utils -from funkwhale_api.music import models as music_models from funkwhale_api.users import models @@ -183,18 +182,6 @@ def test_creating_actor_from_user(factories, settings): ) -def test_creating_libraries_from_user(factories, settings): - user = factories["users.User"](username="Hello M. world", with_actor=True) - models.create_user_libraries(user) - for privacy_level, desc in music_models.LIBRARY_PRIVACY_LEVEL_CHOICES: - assert ( - user.actor.libraries.filter( - name=privacy_level, privacy_level=privacy_level, actor=user.actor - ).count() - == 1 - ) - - def test_get_channels_groups(factories): user = factories["users.User"](permission_library=True) diff --git a/changes/changelog.d/2366.feature b/changes/changelog.d/2366.feature deleted file mode 100644 index 72cce3afd..000000000 --- a/changes/changelog.d/2366.feature +++ /dev/null @@ -1 +0,0 @@ -Drop libraries in favor of playlist (#2366) diff --git a/compose/app.django.yml b/compose/app.django.yml index 2b2b80f0e..975bca2f9 100644 --- a/compose/app.django.yml +++ b/compose/app.django.yml @@ -33,6 +33,7 @@ services: retries: 3 command: > sh -c " + funkwhale-manage migrate && funkwhale-manage collectstatic --no-input && uvicorn --reload config.asgi:application --host 0.0.0.0 --port 5000 --reload-dir config/ --reload-dir funkwhale_api/ " diff --git a/docs/specs/library-drop/index.md b/docs/specs/library-drop/index.md deleted file mode 100644 index c897f87b4..000000000 --- a/docs/specs/library-drop/index.md +++ /dev/null @@ -1,20 +0,0 @@ -# Library drop in favor of playlist - -## Issue - -We now have playlist, use complained about library not being clearly defined. - -## Proposed solution - -## Backend - -A new endpoint to move upload from one library to another : `PATCH` on `api/v2/uploads/bulk-update` with `[{"uuid": "uuid1", "privacy_level": "public"}]` - -### Migration from Libraries to Playlist - -New `description` field on playlist, to inherit from the `description` field of Library - -Library Follows will be transformed to user follow. -The schedule_scan function of the library still exist and allow federation of audio content - -During user creation, built-in libraries are generated automatically by `create_user_libraries`