diff --git a/api/funkwhale_api/federation/contexts.py b/api/funkwhale_api/federation/contexts.py index 0873bcd46b04d715e861a9244a1327187d50167d..b3fc112f0e0c233595ba9a63d4af9bf6bee73d2a 100644 --- a/api/funkwhale_api/federation/contexts.py +++ b/api/funkwhale_api/federation/contexts.py @@ -214,6 +214,7 @@ CONTEXTS = [ "shares": {"@id": "as:shares", "@type": "@id"}, # Added manually "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "Hashtag": "as:Hashtag", } }, }, diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index f7210e324bc2092b99a4e692b4d63bdc43ad7978..d975dfaec0304485fbdf70ee45f4ee8ee6a6d5dc 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -11,6 +11,7 @@ from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.music import licenses from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks +from funkwhale_api.tags import models as tags_models from . import activity, actors, contexts, jsonld, models, tasks, utils @@ -781,6 +782,20 @@ MUSIC_ENTITY_JSONLD_MAPPING = { } +class TagSerializer(jsonld.JsonLdSerializer): + type = serializers.ChoiceField(choices=[contexts.AS.Hashtag]) + name = serializers.CharField(max_length=100) + + class Meta: + jsonld_mapping = {"name": jsonld.first_val(contexts.AS.name)} + + def validate_name(self, value): + if value.startswith("#"): + # remove trailing # + value = value[1:] + return value + + class MusicEntitySerializer(jsonld.JsonLdSerializer): id = serializers.URLField(max_length=500) published = serializers.DateTimeField() @@ -797,8 +812,10 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): self.updateable_fields, validated_data, instance ) if updated_fields: - return music_tasks.update_library_entity(instance, updated_fields) + music_tasks.update_library_entity(instance, updated_fields) + tags = [t["name"] for t in validated_data.get("tags", []) or []] + tags_models.set_tags(instance, *tags) return instance @@ -892,6 +909,9 @@ class TrackSerializer(MusicEntitySerializer): album = AlbumSerializer() license = serializers.URLField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False) + tags = serializers.ListField( + child=TagSerializer(), min_length=0, required=False, allow_null=True + ) updateable_fields = [ ("name", "title"), @@ -914,6 +934,7 @@ class TrackSerializer(MusicEntitySerializer): "disc": jsonld.first_val(contexts.FW.disc), "license": jsonld.first_id(contexts.FW.license), "position": jsonld.first_val(contexts.FW.position), + "tags": jsonld.raw(contexts.AS.tag), }, ) @@ -941,6 +962,12 @@ class TrackSerializer(MusicEntitySerializer): "attributedTo": instance.attributed_to.fid if instance.attributed_to else None, + "tag": [ + {"type": "Hashtag", "name": "#{}".format(tag)} + for tag in sorted( + instance.tagged_items.values_list("tag__name", flat=True) + ) + ], } if self.context.get("include_ap_context", self.parent is None): @@ -950,6 +977,7 @@ class TrackSerializer(MusicEntitySerializer): def create(self, validated_data): from funkwhale_api.music import tasks as music_tasks + tags = [t["name"] for t in validated_data.get("tags", []) or []] references = {} actors_to_fetch = set() actors_to_fetch.add( @@ -981,7 +1009,6 @@ class TrackSerializer(MusicEntitySerializer): if not url: continue references[url] = actors.get_actor(url) - metadata = music_tasks.federation_audio_track_to_metadata( validated_data, references ) @@ -990,6 +1017,7 @@ class TrackSerializer(MusicEntitySerializer): if from_activity: metadata["from_activity_id"] = from_activity.pk track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True) + tags_models.add_tags(track, *tags) return track def update(self, obj, validated_data): diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 43b465d5480fa0096ea8fcc99e889fdde3826a0e..140f31528760dd09f1308f69546260b4767ec8b9 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -604,7 +604,11 @@ def test_activity_pub_album_serializer_to_ap(factories): def test_activity_pub_track_serializer_to_ap(factories): track = factories["music.Track"]( - license="cc-by-4.0", copyright="test", disc_number=3, attributed=True + license="cc-by-4.0", + copyright="test", + disc_number=3, + attributed=True, + set_tags=["Punk", "Rock"], ) expected = { "@context": jsonld.get_default_context(), @@ -626,6 +630,10 @@ def test_activity_pub_track_serializer_to_ap(factories): track.album, context={"include_ap_context": False} ).data, "attributedTo": track.attributed_to.fid, + "tag": [ + {"type": "Hashtag", "name": "#Punk"}, + {"type": "Hashtag", "name": "#Rock"}, + ], } serializer = serializers.TrackSerializer(track) @@ -633,6 +641,7 @@ def test_activity_pub_track_serializer_to_ap(factories): def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): + add_tags = mocker.patch("funkwhale_api.tags.models.add_tags") track_attributed_to = factories["federation.Actor"]() album_attributed_to = factories["federation.Actor"]() album_artist_attributed_to = factories["federation.Actor"]() @@ -685,6 +694,10 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): "published": published.isoformat(), } ], + "tag": [ + {"type": "Hashtag", "name": "#Hello"}, + {"type": "Hashtag", "name": "World"}, + ], } r_mock.get(data["album"]["cover"]["href"], body=io.BytesIO(b"coucou")) serializer = serializers.TrackSerializer(data=data, context={"activity": activity}) @@ -728,6 +741,48 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): assert album_artist.creation_date == published assert album_artist.attributed_to == album_artist_attributed_to + add_tags.assert_called_once_with(track, *["Hello", "World"]) + + +def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker): + set_tags = mocker.patch("funkwhale_api.tags.models.set_tags") + track_attributed_to = factories["federation.Actor"]() + track = factories["music.Track"]() + + published = timezone.now() + data = { + "@context": jsonld.get_default_context(), + "type": "Track", + "id": track.fid, + "published": published.isoformat(), + "musicbrainzId": str(uuid.uuid4()), + "name": "Black in back", + "position": 5, + "disc": 2, + "attributedTo": track_attributed_to.fid, + "album": serializers.AlbumSerializer(track.album).data, + "artists": [serializers.ArtistSerializer(track.artist).data], + "tag": [ + {"type": "Hashtag", "name": "#Hello"}, + # Ensure we can handle tags without a leading # + {"type": "Hashtag", "name": "World"}, + ], + } + serializer = serializers.TrackSerializer(track, data=data) + assert serializer.is_valid(raise_exception=True) + + serializer.save() + track.refresh_from_db() + + assert track.fid == data["id"] + assert track.title == data["name"] + assert track.position == data["position"] + assert track.disc_number == data["disc"] + assert track.attributed_to == track_attributed_to + assert str(track.mbid) == data["musicbrainzId"] + + set_tags.assert_called_once_with(track, *["Hello", "World"]) + def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock): activity = factories["federation.Activity"]()