diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py index d6a0636039f00fc1dfff8af764b62ded83939c0d..9af602de7df70ce22f663161a59cd3e1d56aed22 100644 --- a/api/funkwhale_api/common/factories.py +++ b/api/funkwhale_api/common/factories.py @@ -26,3 +26,12 @@ class AttachmentFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class Meta: model = "common.Attachment" + + +@registry.register +class CommonFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): + text = factory.Faker("paragraph") + content_type = "text/plain" + + class Meta: + model = "common.Content" diff --git a/api/funkwhale_api/common/migrations/0006_content.py b/api/funkwhale_api/common/migrations/0006_content.py new file mode 100644 index 0000000000000000000000000000000000000000..9cab5e765e93b76faee207aef1d2d9d8c8d3b8cc --- /dev/null +++ b/api/funkwhale_api/common/migrations/0006_content.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.7 on 2020-01-13 10:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0005_auto_20191125_1421'), + ] + + operations = [ + migrations.CreateModel( + name='Content', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.CharField(blank=True, max_length=5000, null=True)), + ('content_type', models.CharField(max_length=100)), + ], + ), + ] diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index 4e4fc14dd55ce87ca72df1cd5d1c8d26faaea2cc..8750d5d7a129de2301a5622a608bab485962b748 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -24,6 +24,14 @@ from . import utils from . import validators +CONTENT_TEXT_MAX_LENGTH = 5000 +CONTENT_TEXT_SUPPORTED_TYPES = [ + "text/html", + "text/markdown", + "text/plain", +] + + @Field.register_lookup class NotEqual(Lookup): lookup_name = "ne" @@ -273,6 +281,15 @@ class MutationAttachment(models.Model): unique_together = ("attachment", "mutation") +class Content(models.Model): + """ + A text content that can be associated to other models, like a description, a summary, etc. + """ + + text = models.CharField(max_length=CONTENT_TEXT_MAX_LENGTH, blank=True, null=True) + content_type = models.CharField(max_length=100) + + @receiver(models.signals.post_save, sender=Attachment) def warm_attachment_thumbnails(sender, instance, **kwargs): if not instance.file or not settings.CREATE_IMAGE_THUMBNAILS: @@ -302,3 +319,18 @@ def trigger_mutation_post_init(sender, instance, created, **kwargs): except AttributeError: return handler(instance) + + +CONTENT_FKS = { + "music.Track": ["description"], + "music.Album": ["description"], + "music.Artist": ["description"], +} + + +@receiver(models.signals.post_delete, sender=None) +def remove_attached_content(sender, instance, **kwargs): + fk_fields = CONTENT_FKS.get(instance._meta.label, []) + for field in fk_fields: + if getattr(instance, "{}_id".format(field)): + getattr(instance, field).delete() diff --git a/api/funkwhale_api/common/mutations.py b/api/funkwhale_api/common/mutations.py index 13a5a97de03619f90b81e26b6ce8d6dabd502963..586e86ec17c0865c48d91b88843d5fe0ac0d02bd 100644 --- a/api/funkwhale_api/common/mutations.py +++ b/api/funkwhale_api/common/mutations.py @@ -86,7 +86,6 @@ class MutationSerializer(serializers.Serializer): class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): serialized_relations = {} - previous_state_handlers = {} def __init__(self, *args, **kwargs): # we force partial mode, because update mutations are partial @@ -141,9 +140,12 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): obj, *list(validated_data.keys()), serialized_relations=self.serialized_relations, - handlers=self.previous_state_handlers, + handlers=self.get_previous_state_handlers(), ) + def get_previous_state_handlers(self): + return {} + def get_update_previous_state(obj, *fields, serialized_relations={}, handlers={}): if not fields: diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index fa889f9e887be3511675c521c31789c9d9bc559a..38be2b5bc14551d022a10469399df647b4cc92bd 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -11,6 +11,7 @@ from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ from . import models +from . import utils class RelatedField(serializers.RelatedField): @@ -308,3 +309,12 @@ class AttachmentSerializer(serializers.Serializer): return models.Attachment.objects.create( file=validated_data["file"], actor=validated_data["actor"] ) + + +class ContentSerializer(serializers.Serializer): + text = serializers.CharField(max_length=models.CONTENT_TEXT_MAX_LENGTH) + content_type = serializers.ChoiceField(choices=models.CONTENT_TEXT_SUPPORTED_TYPES,) + html = serializers.SerializerMethodField() + + def get_html(self, o): + return utils.render_html(o.text, o.content_type) diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 4bb18c403a4d536a7d7245b38d21d4fac78a782b..f3f3cc0f1b56d9c300a6e5ea52863df106895c55 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -1,5 +1,7 @@ from django.utils.deconstruct import deconstructible +import bleach.sanitizer +import markdown import os import shutil import uuid @@ -241,3 +243,65 @@ def join_queries_or(left, right): return left | right else: return right + + +def render_markdown(text): + return markdown.markdown(text, extensions=["nl2br"]) + + +HTMl_CLEANER = bleach.sanitizer.Cleaner( + strip=True, + tags=[ + "p", + "a", + "abbr", + "acronym", + "b", + "blockquote", + "code", + "em", + "i", + "li", + "ol", + "strong", + "ul", + ], +) + +HTML_LINKER = bleach.linkifier.Linker() + + +def clean_html(html): + return HTMl_CLEANER.clean(html) + + +def render_html(text, content_type): + rendered = render_markdown(text) + if content_type == "text/html": + rendered = text + elif content_type == "text/markdown": + rendered = render_markdown(text) + else: + rendered = render_markdown(text) + rendered = HTML_LINKER.linkify(rendered) + return clean_html(rendered).strip().replace("\n", "") + + +@transaction.atomic +def attach_content(obj, field, content_data): + from . import models + + existing = getattr(obj, "{}_id".format(field)) + + if existing: + getattr(obj, field).delete() + + if not content_data: + return + + content_obj = models.Content.objects.create( + text=content_data["text"][: models.CONTENT_TEXT_MAX_LENGTH], + content_type=content_data["content_type"], + ) + setattr(obj, field, content_obj) + obj.save(update_fields=[field]) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 6305bd073e231ae65bb675404b9234c3386d9530..2f593111f1f004d945b5a63887a48a6f26fcbef2 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -9,7 +9,7 @@ from django.db import transaction from rest_framework import serializers -from funkwhale_api.common import utils as funkwhale_utils +from funkwhale_api.common import utils as common_utils from funkwhale_api.common import models as common_models from funkwhale_api.music import licenses from funkwhale_api.music import models as music_models @@ -611,9 +611,9 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer): def to_representation(self, conf): paginator = Paginator(conf["items"], conf.get("page_size", 20)) - first = funkwhale_utils.set_query_parameter(conf["id"], page=1) + first = common_utils.set_query_parameter(conf["id"], page=1) current = first - last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages) + last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages) d = { "id": conf["id"], # XXX Stable release: remove the obsolete actor field @@ -646,7 +646,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): ) class Meta: - jsonld_mapping = funkwhale_utils.concat_dicts( + jsonld_mapping = common_utils.concat_dicts( PAGINATED_COLLECTION_JSONLD_MAPPING, { "name": jsonld.first_val(contexts.AS.name), @@ -740,11 +740,11 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): def to_representation(self, conf): page = conf["page"] - first = funkwhale_utils.set_query_parameter(conf["id"], page=1) - last = funkwhale_utils.set_query_parameter( + first = common_utils.set_query_parameter(conf["id"], page=1) + last = common_utils.set_query_parameter( conf["id"], page=page.paginator.num_pages ) - id = funkwhale_utils.set_query_parameter(conf["id"], page=page.number) + id = common_utils.set_query_parameter(conf["id"], page=page.number) d = { "id": id, "partOf": conf["id"], @@ -764,12 +764,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): } if page.has_previous(): - d["prev"] = funkwhale_utils.set_query_parameter( + d["prev"] = common_utils.set_query_parameter( conf["id"], page=page.previous_page_number() ) if page.has_next(): - d["next"] = funkwhale_utils.set_query_parameter( + d["next"] = common_utils.set_query_parameter( conf["id"], page=page.next_page_number() ) d.update(get_additional_fields(conf)) @@ -784,6 +784,8 @@ MUSIC_ENTITY_JSONLD_MAPPING = { "musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId), "attributedTo": jsonld.first_id(contexts.AS.attributedTo), "tags": jsonld.raw(contexts.AS.tag), + "mediaType": jsonld.first_val(contexts.AS.mediaType), + "content": jsonld.first_val(contexts.AS.content), } @@ -805,6 +807,28 @@ def repr_tag(tag_name): return {"type": "Hashtag", "name": "#{}".format(tag_name)} +def include_content(repr, content_obj): + if not content_obj: + return + + repr["content"] = common_utils.render_html( + content_obj.text, content_obj.content_type + ) + repr["mediaType"] = "text/html" + + +class TruncatedCharField(serializers.CharField): + def __init__(self, *args, **kwargs): + self.truncate_length = kwargs.pop("truncate_length") + super().__init__(*args, **kwargs) + + def to_internal_value(self, v): + v = super().to_internal_value(v) + if v: + v = v[: self.truncate_length] + return v + + class MusicEntitySerializer(jsonld.JsonLdSerializer): id = serializers.URLField(max_length=500) published = serializers.DateTimeField() @@ -815,13 +839,23 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): tags = serializers.ListField( child=TagSerializer(), min_length=0, required=False, allow_null=True ) + mediaType = serializers.ChoiceField( + choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES, + default="text/html", + required=False, + ) + content = TruncatedCharField( + truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH, + required=False, + allow_null=True, + ) @transaction.atomic def update(self, instance, validated_data): attributed_to_fid = validated_data.get("attributedTo") if attributed_to_fid: validated_data["attributedTo"] = actors.get_actor(attributed_to_fid) - updated_fields = funkwhale_utils.get_updated_fields( + updated_fields = common_utils.get_updated_fields( self.updateable_fields, validated_data, instance ) updated_fields = self.validate_updated_data(instance, updated_fields) @@ -831,6 +865,9 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): tags = [t["name"] for t in validated_data.get("tags", []) or []] tags_models.set_tags(instance, *tags) + common_utils.attach_content( + instance, "description", validated_data.get("description") + ) return instance def get_tags_repr(self, instance): @@ -842,6 +879,15 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): def validate_updated_data(self, instance, validated_data): return validated_data + def validate(self, data): + validated_data = super().validate(data) + if data.get("content"): + validated_data["description"] = { + "content_type": data["mediaType"], + "text": data["content"], + } + return validated_data + class ArtistSerializer(MusicEntitySerializer): updateable_fields = [ @@ -866,7 +912,7 @@ class ArtistSerializer(MusicEntitySerializer): else None, "tag": self.get_tags_repr(instance), } - + include_content(d, instance.description) if self.context.get("include_ap_context", self.parent is None): d["@context"] = jsonld.get_default_context() return d @@ -888,7 +934,7 @@ class AlbumSerializer(MusicEntitySerializer): class Meta: model = music_models.Album - jsonld_mapping = funkwhale_utils.concat_dicts( + jsonld_mapping = common_utils.concat_dicts( MUSIC_ENTITY_JSONLD_MAPPING, { "released": jsonld.first_val(contexts.FW.released), @@ -917,6 +963,7 @@ class AlbumSerializer(MusicEntitySerializer): else None, "tag": self.get_tags_repr(instance), } + include_content(d, instance.description) if instance.attachment_cover: d["cover"] = { "type": "Link", @@ -968,7 +1015,7 @@ class TrackSerializer(MusicEntitySerializer): class Meta: model = music_models.Track - jsonld_mapping = funkwhale_utils.concat_dicts( + jsonld_mapping = common_utils.concat_dicts( MUSIC_ENTITY_JSONLD_MAPPING, { "album": jsonld.first_obj(contexts.FW.album), @@ -1006,7 +1053,7 @@ class TrackSerializer(MusicEntitySerializer): else None, "tag": self.get_tags_repr(instance), } - + include_content(d, instance.description) if self.context.get("include_ap_context", self.parent is None): d["@context"] = jsonld.get_default_context() return d @@ -1017,23 +1064,21 @@ class TrackSerializer(MusicEntitySerializer): references = {} actors_to_fetch = set() actors_to_fetch.add( - funkwhale_utils.recursive_getattr( + common_utils.recursive_getattr( validated_data, "attributedTo", permissive=True ) ) actors_to_fetch.add( - funkwhale_utils.recursive_getattr( + common_utils.recursive_getattr( validated_data, "album.attributedTo", permissive=True ) ) artists = ( - funkwhale_utils.recursive_getattr( - validated_data, "artists", permissive=True - ) + common_utils.recursive_getattr(validated_data, "artists", permissive=True) or [] ) album_artists = ( - funkwhale_utils.recursive_getattr( + common_utils.recursive_getattr( validated_data, "album.artists", permissive=True ) or [] @@ -1244,6 +1289,7 @@ class ChannelUploadSerializer(serializers.Serializer): }, ], } + include_content(data, upload.track.description) tags = [item.tag.name for item in upload.get_all_tagged_items()] if tags: data["tag"] = [repr_tag(name) for name in tags] diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index f0ac6687dbb789ccf281417f141cd4b5387894b4..b732712dc6937282f14641d29179b87c00fd4b25 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -225,11 +225,14 @@ class MusicLibraryViewSet( "album__attributed_to", "attributed_to", "album__attachment_cover", + "description", ).prefetch_related( "tagged_items__tag", "album__tagged_items__tag", "album__artist__tagged_items__tag", "artist__tagged_items__tag", + "artist__description", + "album__description", ), ) ), @@ -278,6 +281,7 @@ class MusicUploadViewSet( "library__actor", "track__artist", "track__album__artist", + "track__description", "track__album__attachment_cover", ) serializer_class = serializers.UploadSerializer @@ -299,7 +303,7 @@ class MusicArtistViewSet( ): authentication_classes = [authentication.SignatureAuthentication] renderer_classes = renderers.get_ap_renderers() - queryset = music_models.Artist.objects.local() + queryset = music_models.Artist.objects.local().select_related("description") serializer_class = serializers.ArtistSerializer lookup_field = "uuid" @@ -309,7 +313,9 @@ class MusicAlbumViewSet( ): authentication_classes = [authentication.SignatureAuthentication] renderer_classes = renderers.get_ap_renderers() - queryset = music_models.Album.objects.local().select_related("artist") + queryset = music_models.Album.objects.local().select_related( + "artist__description", "description" + ) serializer_class = serializers.AlbumSerializer lookup_field = "uuid" @@ -320,7 +326,7 @@ class MusicTrackViewSet( authentication_classes = [authentication.SignatureAuthentication] renderer_classes = renderers.get_ap_renderers() queryset = music_models.Track.objects.local().select_related( - "album__artist", "artist" + "album__artist", "album__description", "artist__description", "description" ) serializer_class = serializers.TrackSerializer lookup_field = "uuid" diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index dfa04bbe0120e424188aab214021392b4d92df01..20c1d023861836669f452eff8b04ac84d07716fb 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -383,7 +383,9 @@ class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer): return getattr(obj, "tracks_count", None) -class ManageArtistSerializer(ManageBaseArtistSerializer): +class ManageArtistSerializer( + music_serializers.OptionalDescriptionMixin, ManageBaseArtistSerializer +): albums = ManageNestedAlbumSerializer(many=True) tracks = ManageNestedTrackSerializer(many=True) attributed_to = ManageBaseActorSerializer() @@ -407,7 +409,9 @@ class ManageNestedArtistSerializer(ManageBaseArtistSerializer): pass -class ManageAlbumSerializer(ManageBaseAlbumSerializer): +class ManageAlbumSerializer( + music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer +): tracks = ManageNestedTrackSerializer(many=True) attributed_to = ManageBaseActorSerializer() artist = ManageNestedArtistSerializer() @@ -435,7 +439,9 @@ class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer): fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"] -class ManageTrackSerializer(ManageNestedTrackSerializer): +class ManageTrackSerializer( + music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer +): artist = ManageNestedArtistSerializer() album = ManageTrackAlbumSerializer() attributed_to = ManageBaseActorSerializer() diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index c946c37e28bb0b749b25c06ac1c9e7f2284934c6..1754a8970a3cf904c90ec235fad2b71ddf70dc48 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -100,6 +100,11 @@ class ManageArtistViewSet( result = serializer.save() return response.Response(result, status=200) + def get_serializer_context(self): + context = super().get_serializer_context() + context["description"] = self.action in ["retrieve", "create", "update"] + return context + class ManageAlbumViewSet( mixins.ListModelMixin, @@ -134,6 +139,11 @@ class ManageAlbumViewSet( result = serializer.save() return response.Response(result, status=200) + def get_serializer_context(self): + context = super().get_serializer_context() + context["description"] = self.action in ["retrieve", "create", "update"] + return context + uploads_subquery = ( music_models.Upload.objects.filter(track_id=OuterRef("pk")) @@ -186,6 +196,11 @@ class ManageTrackViewSet( result = serializer.save() return response.Response(result, status=200) + def get_serializer_context(self): + context = super().get_serializer_context() + context["description"] = self.action in ["retrieve", "create", "update"] + return context + uploads_subquery = ( music_models.Upload.objects.filter(library_id=OuterRef("pk")) diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 33cf2667ead03dd0eeb4dfd7c2f79ef6be0ec0c4..78ca79168c302bf8b4776851f1d57226b410ca5b 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -168,6 +168,17 @@ def get_mp3_recording_id(f, k): raise TagNotFound(k) +def get_mp3_comment(f, k): + keys_to_try = ["COMM", "COMM::eng"] + for key in keys_to_try: + try: + return get_id3_tag(f, key) + except TagNotFound: + pass + + raise TagNotFound("COMM") + + VALIDATION = {} CONF = { @@ -192,6 +203,7 @@ CONF = { "field": "metadata_block_picture", "to_application": clean_ogg_pictures, }, + "comment": {"field": "comment"}, }, }, "OggVorbis": { @@ -215,6 +227,7 @@ CONF = { "field": "metadata_block_picture", "to_application": clean_ogg_pictures, }, + "comment": {"field": "comment"}, }, }, "OggTheora": { @@ -234,6 +247,7 @@ CONF = { "license": {}, "copyright": {}, "genre": {}, + "comment": {"field": "comment"}, }, }, "MP3": { @@ -255,6 +269,7 @@ CONF = { "pictures": {}, "license": {"field": "WCOP"}, "copyright": {"field": "TCOP"}, + "comment": {"field": "COMM", "getter": get_mp3_comment}, }, }, "MP4": { @@ -282,6 +297,7 @@ CONF = { "pictures": {}, "license": {"field": "----:com.apple.iTunes:LICENSE"}, "copyright": {"field": "cprt"}, + "comment": {"field": "©cmt"}, }, }, "FLAC": { @@ -304,6 +320,7 @@ CONF = { "pictures": {}, "license": {}, "copyright": {}, + "comment": {}, }, }, } @@ -322,6 +339,7 @@ ALL_FIELDS = [ "mbid", "license", "copyright", + "comment", ] @@ -657,6 +675,21 @@ class PositionField(serializers.CharField): pass +class DescriptionField(serializers.CharField): + def get_value(self, data): + return data + + def to_internal_value(self, data): + try: + value = data.get("comment") or None + except TagNotFound: + return None + if not value: + return None + value = super().to_internal_value(value) + return {"text": value, "content_type": "text/plain"} + + class TrackMetadataSerializer(serializers.Serializer): title = serializers.CharField() position = PositionField(allow_blank=True, allow_null=True, required=False) @@ -665,6 +698,7 @@ class TrackMetadataSerializer(serializers.Serializer): license = serializers.CharField(allow_blank=True, allow_null=True, required=False) mbid = MBIDField() tags = TagsField(allow_blank=True, allow_null=True, required=False) + description = DescriptionField(allow_null=True, allow_blank=True, required=False) album = AlbumField() artists = ArtistField() @@ -672,6 +706,7 @@ class TrackMetadataSerializer(serializers.Serializer): remove_blank_null_fields = [ "copyright", + "description", "license", "position", "disc_number", diff --git a/api/funkwhale_api/music/migrations/0046_auto_20200113_1018.py b/api/funkwhale_api/music/migrations/0046_auto_20200113_1018.py new file mode 100644 index 0000000000000000000000000000000000000000..1e8183317134bf09005a5f5ae4a804daa03dcdc6 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0046_auto_20200113_1018.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.7 on 2020-01-13 10:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0006_content'), + ('music', '0045_full_text_search_stop_words'), + ] + + operations = [ + migrations.AddField( + model_name='album', + name='description', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'), + ), + migrations.AddField( + model_name='artist', + name='description', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'), + ), + migrations.AddField( + model_name='track', + name='description', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 424a307ebbfbc5f2c1ef18031d01e3f2460a22fd..9165e4658ed320c46d0348c709eaeb16c491fbb8 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -227,6 +227,9 @@ class Artist(APIModelMixin): content_type_field="object_content_type", object_id_field="object_id", ) + description = models.ForeignKey( + "common.Content", null=True, blank=True, on_delete=models.SET_NULL + ) api = musicbrainz.api.artists objects = ArtistQuerySet.as_manager() @@ -327,6 +330,10 @@ class Album(APIModelMixin): object_id_field="object_id", ) + description = models.ForeignKey( + "common.Content", null=True, blank=True, on_delete=models.SET_NULL + ) + api_includes = ["artist-credits", "recordings", "media", "release-groups"] api = musicbrainz.api.releases federation_namespace = "albums" @@ -508,6 +515,10 @@ class Track(APIModelMixin): copyright = models.CharField( max_length=MAX_LENGTHS["COPYRIGHT"], null=True, blank=True ) + description = models.ForeignKey( + "common.Content", null=True, blank=True, on_delete=models.SET_NULL + ) + federation_namespace = "tracks" musicbrainz_model = "recording" api = musicbrainz.api.recordings diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py index aad3b0e9654381ce9b4b342ed00ac0fed09ab720..d158857f06b98c58d50eb3aca1d2181d4e8b2b8b 100644 --- a/api/funkwhale_api/music/mutations.py +++ b/api/funkwhale_api/music/mutations.py @@ -1,6 +1,8 @@ from funkwhale_api.common import models as common_models from funkwhale_api.common import mutations from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import utils as common_utils + from funkwhale_api.federation import routes from funkwhale_api.tags import models as tags_models from funkwhale_api.tags import serializers as tags_serializers @@ -23,11 +25,13 @@ def can_approve(obj, actor): class TagMutation(mutations.UpdateMutationSerializer): tags = tags_serializers.TagsListField() - previous_state_handlers = { - "tags": lambda obj: list( + + def get_previous_state_handlers(self): + handlers = super().get_previous_state_handlers() + handlers["tags"] = lambda obj: list( sorted(obj.tagged_items.values_list("tag__name", flat=True)) ) - } + return handlers def update(self, instance, validated_data): tags = validated_data.pop("tags", []) @@ -36,17 +40,36 @@ class TagMutation(mutations.UpdateMutationSerializer): return r +class DescriptionMutation(mutations.UpdateMutationSerializer): + description = common_serializers.ContentSerializer() + + def get_previous_state_handlers(self): + handlers = super().get_previous_state_handlers() + handlers["description"] = ( + lambda obj: common_serializers.ContentSerializer(obj.description).data + if obj.description_id + else None + ) + return handlers + + def update(self, instance, validated_data): + description = validated_data.pop("description", None) + r = super().update(instance, validated_data) + common_utils.attach_content(instance, "description", description) + return r + + @mutations.registry.connect( "update", models.Track, perm_checkers={"suggest": can_suggest, "approve": can_approve}, ) -class TrackMutationSerializer(TagMutation): +class TrackMutationSerializer(TagMutation, DescriptionMutation): serialized_relations = {"license": "code"} class Meta: model = models.Track - fields = ["license", "title", "position", "copyright", "tags"] + fields = ["license", "title", "position", "copyright", "tags", "description"] def post_apply(self, obj, validated_data): routes.outbox.dispatch( @@ -59,10 +82,10 @@ class TrackMutationSerializer(TagMutation): models.Artist, perm_checkers={"suggest": can_suggest, "approve": can_approve}, ) -class ArtistMutationSerializer(TagMutation): +class ArtistMutationSerializer(TagMutation, DescriptionMutation): class Meta: model = models.Artist - fields = ["name", "tags"] + fields = ["name", "tags", "description"] def post_apply(self, obj, validated_data): routes.outbox.dispatch( @@ -75,27 +98,23 @@ class ArtistMutationSerializer(TagMutation): models.Album, perm_checkers={"suggest": can_suggest, "approve": can_approve}, ) -class AlbumMutationSerializer(TagMutation): +class AlbumMutationSerializer(TagMutation, DescriptionMutation): cover = common_serializers.RelatedField( "uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None ) serialized_relations = {"cover": "uuid"} - previous_state_handlers = dict( - list(TagMutation.previous_state_handlers.items()) - + [ - ( - "cover", - lambda obj: str(obj.attachment_cover.uuid) - if obj.attachment_cover - else None, - ), - ] - ) class Meta: model = models.Album - fields = ["title", "release_date", "tags", "cover"] + fields = ["title", "release_date", "tags", "cover", "description"] + + def get_previous_state_handlers(self): + handlers = super().get_previous_state_handlers() + handlers["cover"] = ( + lambda obj: str(obj.attachment_cover.uuid) if obj.attachment_cover else None + ) + return handlers def post_apply(self, obj, validated_data): routes.outbox.dispatch( diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 4e45190e97c92b2fb7e34876d7c283ce97ec73aa..1049b3d9fb8370887151f90088936917e097f453 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -49,6 +49,20 @@ def serialize_attributed_to(self, obj): return federation_serializers.APIActorSerializer(obj.attributed_to).data +class OptionalDescriptionMixin(object): + def to_representation(self, obj): + repr = super().to_representation(obj) + if self.context.get("description", False): + description = obj.description + repr["description"] = ( + common_serializers.ContentSerializer(description).data + if description + else None + ) + + return repr + + class LicenseSerializer(serializers.Serializer): id = serializers.SerializerMethodField() url = serializers.URLField() @@ -96,7 +110,7 @@ class ArtistAlbumSerializer(serializers.Serializer): DATETIME_FIELD = serializers.DateTimeField() -class ArtistWithAlbumsSerializer(serializers.Serializer): +class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serializer): albums = ArtistAlbumSerializer(many=True) tags = serializers.SerializerMethodField() attributed_to = serializers.SerializerMethodField() @@ -152,7 +166,7 @@ def serialize_album_track(track): } -class AlbumSerializer(serializers.Serializer): +class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer): tracks = serializers.SerializerMethodField() artist = serializers.SerializerMethodField() cover = cover_field @@ -225,7 +239,7 @@ def serialize_upload(upload): } -class TrackSerializer(serializers.Serializer): +class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer): artist = serializers.SerializerMethodField() album = TrackAlbumSerializer(read_only=True) uploads = serializers.SerializerMethodField() diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 71f7106868f67a2e714e8655d7fff8b5c5690ea3..e6b63207249af27472ffd68210d2d840fe13ad31 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -12,6 +12,7 @@ from musicbrainzngs import ResponseError from requests.exceptions import RequestException from funkwhale_api.common import channels, preferences +from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import routes from funkwhale_api.federation import library as lb from funkwhale_api.federation import utils as federation_utils @@ -309,6 +310,7 @@ def federation_audio_track_to_metadata(payload, references): "disc_number": payload.get("disc"), "license": payload.get("license"), "copyright": payload.get("copyright"), + "description": payload.get("description"), "attributed_to": references.get(payload.get("attributedTo")), "mbid": str(payload.get("musicbrainzId")) if payload.get("musicbrainzId") @@ -317,6 +319,7 @@ def federation_audio_track_to_metadata(payload, references): "title": payload["album"]["name"], "fdate": payload["album"]["published"], "fid": payload["album"]["id"], + "description": payload["album"].get("description"), "attributed_to": references.get(payload["album"].get("attributedTo")), "mbid": str(payload["album"]["musicbrainzId"]) if payload["album"].get("musicbrainzId") @@ -328,6 +331,7 @@ def federation_audio_track_to_metadata(payload, references): "fid": a["id"], "name": a["name"], "fdate": a["published"], + "description": a.get("description"), "attributed_to": references.get(a.get("attributedTo")), "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, "tags": [t["name"] for t in a.get("tags", []) or []], @@ -340,6 +344,7 @@ def federation_audio_track_to_metadata(payload, references): "fid": a["id"], "name": a["name"], "fdate": a["published"], + "description": a.get("description"), "attributed_to": references.get(a.get("attributedTo")), "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, "tags": [t["name"] for t in a.get("tags", []) or []], @@ -505,6 +510,9 @@ def _get_track(data, attributed_to=None, **forced_values): ) if created: tags_models.add_tags(artist, *artist_data.get("tags", [])) + common_utils.attach_content( + artist, "description", artist_data.get("description") + ) if "album" in forced_values: album = forced_values["album"] @@ -539,6 +547,9 @@ def _get_track(data, attributed_to=None, **forced_values): ) if created: tags_models.add_tags(album_artist, *album_artist_data.get("tags", [])) + common_utils.attach_content( + album_artist, "description", album_artist_data.get("description") + ) # get / create album album_data = data["album"] @@ -569,6 +580,9 @@ def _get_track(data, attributed_to=None, **forced_values): ) if created: tags_models.add_tags(album, *album_data.get("tags", [])) + common_utils.attach_content( + album, "description", album_data.get("description") + ) # get / create track track_title = ( @@ -602,6 +616,7 @@ def _get_track(data, attributed_to=None, **forced_values): query |= Q(mbid=track_mbid) if track_fid: query |= Q(fid=track_fid) + defaults = { "title": track_title, "album": album, @@ -627,6 +642,8 @@ def _get_track(data, attributed_to=None, **forced_values): forced_values["tags"] if "tags" in forced_values else data.get("tags", []) ) tags_models.add_tags(track, *tags) + common_utils.attach_content(track, "description", data.get("description")) + return track diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 39c327e373016a1fdcef6d93d48cd4b63f65132b..f7fb6e653b80576bc82d71b898e688b23dc659ab 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -143,6 +143,11 @@ class ArtistViewSet( obj = refetch_obj(obj, self.get_queryset()) return obj + def get_serializer_context(self): + context = super().get_serializer_context() + context["description"] = self.action in ["retrieve", "create", "update"] + return context + def get_queryset(self): queryset = super().get_queryset() albums = models.Album.objects.with_tracks_count().select_related( @@ -194,6 +199,11 @@ class AlbumViewSet( obj = refetch_obj(obj, self.get_queryset()) return obj + def get_serializer_context(self): + context = super().get_serializer_context() + context["description"] = self.action in ["retrieve", "create", "update"] + return context + def get_queryset(self): queryset = super().get_queryset() tracks = ( @@ -332,6 +342,11 @@ class TrackViewSet( get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track=o)) ) + def get_serializer_context(self): + context = super().get_serializer_context() + context["description"] = self.action in ["retrieve", "create", "update"] + return context + def strip_absolute_media_url(path): if ( diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 599f7b9ff82961de6bb169c62abba270a091111b..9cdbb477d1b669d099e7583cec586a1e2bb30293 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -76,3 +76,5 @@ django-cacheops==4.2 click>=7,<8 service_identity==18.1.0 +markdown>=3,<4 +bleach>=3,<4 diff --git a/api/tests/common/test_models.py b/api/tests/common/test_models.py index b5a56614d5be547b8ed04ecbbcd75b40f084d493..9fdde0378b1471babc5805cd00e42f75226fc358 100644 --- a/api/tests/common/test_models.py +++ b/api/tests/common/test_models.py @@ -71,3 +71,17 @@ def test_attachment_queryset_attached(args, expected, factories, queryset_equal_ queryset = attachments[0].__class__.objects.attached(*args).order_by("id") expected_objs = [attachments[i] for i in expected] assert queryset == expected_objs + + +def test_removing_obj_removes_content(factories): + kept_content = factories["common.Content"]() + removed_content = factories["common.Content"]() + track1 = factories["music.Track"](description=removed_content) + factories["music.Track"](description=kept_content) + + track1.delete() + + with pytest.raises(removed_content.DoesNotExist): + removed_content.refresh_from_db() + + kept_content.refresh_from_db() diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index 067a9e4a20c7ddc0869127659a52e87286f1971d..8fdb21edbaa98e556a84fcbe2bd67aecd176d5f9 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -7,6 +7,7 @@ from django.urls import reverse import django_filters from funkwhale_api.common import serializers +from funkwhale_api.common import utils from funkwhale_api.users import models from funkwhale_api.federation import utils as federation_utils @@ -252,3 +253,17 @@ def test_attachment_serializer_remote_file(factories, to_api_date): serializer = serializers.AttachmentSerializer(attachment) assert serializer.data == expected + + +def test_content_serializer(factories): + content = factories["common.Content"]() + + expected = { + "text": content.text, + "content_type": content.content_type, + "html": utils.render_html(content.text, content.content_type), + } + + serializer = serializers.ContentSerializer(content) + + assert serializer.data == expected diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py index 74a3d0bca61526e1cf7b5e87772a18a17a3a77aa..def5434a4b8c1ef3daf07bf91929b61a8216ef8a 100644 --- a/api/tests/common/test_utils.py +++ b/api/tests/common/test_utils.py @@ -99,3 +99,28 @@ def test_get_updated_fields(conf, mock_args, data, expected, mocker): ) def test_join_url(start, end, expected): assert utils.join_url(start, end) == expected + + +@pytest.mark.parametrize( + "text, content_type, expected", + [ + ("hello world", "text/markdown", "<p>hello world</p>"), + ("hello world", "text/plain", "<p>hello world</p>"), + ("<strong>hello world</strong>", "text/html", "<strong>hello world</strong>"), + # images and other non whitelisted html should be removed + ("hello world\n", "text/markdown", "<p>hello world</p>"), + ( + "hello world\n\n<script></script>\n\n<style></style>", + "text/markdown", + "<p>hello world</p>", + ), + ( + "<p>hello world</p><script></script>\n\n<style></style>", + "text/html", + "<p>hello world</p>", + ), + ], +) +def test_render_html(text, content_type, expected): + result = utils.render_html(text, content_type) + assert result == expected diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 4076fdd5a1ca853d74d7a1b3f5881a1bda221582..e158b604186ef02e16fde04810b0a99c3c7b8437 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -5,6 +5,7 @@ import uuid from django.core.paginator import Paginator from django.utils import timezone +from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import contexts from funkwhale_api.federation import keys from funkwhale_api.federation import jsonld @@ -560,7 +561,10 @@ def test_music_library_serializer_from_private(factories, mocker): def test_activity_pub_artist_serializer_to_ap(factories): - artist = factories["music.Artist"](attributed=True, set_tags=["Punk", "Rock"]) + content = factories["common.Content"]() + artist = factories["music.Artist"]( + description=content, attributed=True, set_tags=["Punk", "Rock"] + ) expected = { "@context": jsonld.get_default_context(), "type": "Artist", @@ -569,6 +573,8 @@ def test_activity_pub_artist_serializer_to_ap(factories): "musicbrainzId": artist.mbid, "published": artist.creation_date.isoformat(), "attributedTo": artist.attributed_to.fid, + "mediaType": "text/html", + "content": common_utils.render_html(content.text, content.content_type), "tag": [ {"type": "Hashtag", "name": "#Punk"}, {"type": "Hashtag", "name": "#Rock"}, @@ -580,7 +586,10 @@ def test_activity_pub_artist_serializer_to_ap(factories): def test_activity_pub_album_serializer_to_ap(factories): - album = factories["music.Album"](attributed=True, set_tags=["Punk", "Rock"]) + content = factories["common.Content"]() + album = factories["music.Album"]( + description=content, attributed=True, set_tags=["Punk", "Rock"] + ) expected = { "@context": jsonld.get_default_context(), @@ -601,6 +610,8 @@ def test_activity_pub_album_serializer_to_ap(factories): ).data ], "attributedTo": album.attributed_to.fid, + "mediaType": "text/html", + "content": common_utils.render_html(content.text, content.content_type), "tag": [ {"type": "Hashtag", "name": "#Punk"}, {"type": "Hashtag", "name": "#Rock"}, @@ -653,7 +664,9 @@ def test_activity_pub_album_serializer_from_ap_update(factories, faker): def test_activity_pub_track_serializer_to_ap(factories): + content = factories["common.Content"]() track = factories["music.Track"]( + description=content, license="cc-by-4.0", copyright="test", disc_number=3, @@ -680,6 +693,8 @@ def test_activity_pub_track_serializer_to_ap(factories): track.album, context={"include_ap_context": False} ).data, "attributedTo": track.attributed_to.fid, + "mediaType": "text/html", + "content": common_utils.render_html(content.text, content.content_type), "tag": [ {"type": "Hashtag", "name": "#Punk"}, {"type": "Hashtag", "name": "#Rock"}, @@ -709,6 +724,7 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): "name": "Black in back", "position": 5, "disc": 1, + "content": "Hello there", "attributedTo": track_attributed_to.fid, "album": { "type": "Album", @@ -717,6 +733,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): "musicbrainzId": str(uuid.uuid4()), "published": published.isoformat(), "released": released.isoformat(), + "content": "Album summary", + "mediaType": "text/markdown", "attributedTo": album_attributed_to.fid, "cover": { "type": "Link", @@ -727,6 +745,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): "artists": [ { "type": "Artist", + "mediaType": "text/plain", + "content": "Artist summary", "id": "http://hello.artist", "name": "John Smith", "musicbrainzId": str(uuid.uuid4()), @@ -741,6 +761,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): "type": "Artist", "id": "http://hello.trackartist", "name": "Bob Smith", + "mediaType": "text/plain", + "content": "Other artist summary", "musicbrainzId": str(uuid.uuid4()), "attributedTo": artist_attributed_to.fid, "published": published.isoformat(), @@ -769,6 +791,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): assert track.creation_date == published assert track.attributed_to == track_attributed_to assert str(track.mbid) == data["musicbrainzId"] + assert track.description.text == data["content"] + assert track.description.content_type == "text/html" assert album.from_activity == activity assert album.attachment_cover.file.read() == b"coucou" @@ -779,6 +803,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): assert album.creation_date == published assert album.release_date == released assert album.attributed_to == album_attributed_to + assert album.description.text == data["album"]["content"] + assert album.description.content_type == data["album"]["mediaType"] assert artist.from_activity == activity assert artist.name == data["artists"][0]["name"] @@ -786,6 +812,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): assert str(artist.mbid) == data["artists"][0]["musicbrainzId"] assert artist.creation_date == published assert artist.attributed_to == artist_attributed_to + assert artist.description.text == data["artists"][0]["content"] + assert artist.description.content_type == data["artists"][0]["mediaType"] assert album_artist.from_activity == activity assert album_artist.name == data["album"]["artists"][0]["name"] @@ -793,6 +821,11 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"] assert album_artist.creation_date == published assert album_artist.attributed_to == album_artist_attributed_to + assert album_artist.description.text == data["album"]["artists"][0]["content"] + assert ( + album_artist.description.content_type + == data["album"]["artists"][0]["mediaType"] + ) add_tags.assert_any_call(track, *["Hello", "World"]) add_tags.assert_any_call(album, *["AlbumTag"]) @@ -802,8 +835,9 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker): def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker): set_tags = mocker.patch("funkwhale_api.tags.models.set_tags") + content = factories["common.Content"]() track_attributed_to = factories["federation.Actor"]() - track = factories["music.Track"]() + track = factories["music.Track"](description=content) published = timezone.now() data = { @@ -815,6 +849,7 @@ def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker) "name": "Black in back", "position": 5, "disc": 2, + "content": "hello there", "attributedTo": track_attributed_to.fid, "album": serializers.AlbumSerializer(track.album).data, "artists": [serializers.ArtistSerializer(track.artist).data], @@ -835,10 +870,15 @@ def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker) assert track.position == data["position"] assert track.disc_number == data["disc"] assert track.attributed_to == track_attributed_to + assert track.description.content_type == "text/html" + assert track.description.text == "hello there" assert str(track.mbid) == data["musicbrainzId"] set_tags.assert_called_once_with(track, *["Hello", "World"]) + with pytest.raises(content.DoesNotExist): + content.refresh_from_db() + def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock): activity = factories["federation.Activity"]() @@ -1083,11 +1123,13 @@ def test_channel_actor_outbox_serializer(factories): def test_channel_upload_serializer(factories): channel = factories["audio.Channel"]() + content = factories["common.Content"]() upload = factories["music.Upload"]( playable=True, library=channel.library, import_status="finished", track__set_tags=["Punk"], + track__description=content, track__album__set_tags=["Rock"], track__artist__set_tags=["Indie"], ) @@ -1100,6 +1142,8 @@ def test_channel_upload_serializer(factories): "summary": "#Indie #Punk #Rock", "attributedTo": channel.actor.fid, "published": upload.creation_date.isoformat(), + "mediaType": "text/html", + "content": common_utils.render_html(content.text, content.content_type), "to": "https://www.w3.org/ns/activitystreams#Public", "url": [ { diff --git a/api/tests/music/sample.flac b/api/tests/music/sample.flac index a8aafa39239a199663c6673c678064826ecf965b..b89db5b8c580dacd2f954b4238d266348c96cee0 100644 Binary files a/api/tests/music/sample.flac and b/api/tests/music/sample.flac differ diff --git a/api/tests/music/test.m4a b/api/tests/music/test.m4a index 57b65b7b53489c85cb7a9e5ca5fa92fa976c35ea..24c49c2db1b09724aff7608663d6abd8f7b0748f 100644 Binary files a/api/tests/music/test.m4a and b/api/tests/music/test.m4a differ diff --git a/api/tests/music/test.mp3 b/api/tests/music/test.mp3 index 5f8dc2c727d63873ed7cada78379030b9cadb0d7..5545c42f55e71637250379bb397a050de3e337f1 100644 Binary files a/api/tests/music/test.mp3 and b/api/tests/music/test.mp3 differ diff --git a/api/tests/music/test.ogg b/api/tests/music/test.ogg index 9975cd9fe447824358da6dc9a89780c61ade8aee..7d1f523dc4e4c44c011c2aa8d8ee2794f6d51f5f 100644 Binary files a/api/tests/music/test.ogg and b/api/tests/music/test.ogg differ diff --git a/api/tests/music/test.opus b/api/tests/music/test.opus index 92634ce507bbe0bc0f0d36b7ea6abe34965cafe9..c1c324bcc336b13924b64f3e266895bb9028c1a5 100644 Binary files a/api/tests/music/test.opus and b/api/tests/music/test.opus differ diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index f6aa513ab97c674f2f9a33c150b204e523dc4ef4..d842d33183780e35e2e8f2668dced3d8f19ec31c 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -30,6 +30,7 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__)) ), ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), ("copyright", "Someone"), + ("comment", "Hello there"), ], ) def test_can_get_metadata_from_ogg_file(field, value): @@ -58,6 +59,7 @@ def test_can_get_metadata_all(): "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", "copyright": "Someone", "genre": "Classical", + "comment": "Hello there", } assert data.all() == expected @@ -81,6 +83,7 @@ def test_can_get_metadata_all(): ), ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), ("copyright", "Someone"), + ("comment", "Hello there"), ], ) def test_can_get_metadata_from_opus_file(field, value): @@ -104,6 +107,7 @@ def test_can_get_metadata_from_opus_file(field, value): ("mbid", "124d0150-8627-46bc-bc14-789a3bc960c8"), ("musicbrainz_artistid", "c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), ("musicbrainz_albumartistid", "c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), + ("comment", "Hello there"), # somehow, I cannot successfully create an ogg theora file # with the proper license field # ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), @@ -132,6 +136,7 @@ def test_can_get_metadata_from_ogg_theora_file(field, value): ("musicbrainz_albumartistid", "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), ("license", "https://creativecommons.org/licenses/by-nc-nd/2.5/"), ("copyright", "Someone"), + ("comment", "Hello there"), ], ) def test_can_get_metadata_from_id3_mp3_file(field, value): @@ -181,6 +186,7 @@ def test_can_get_pictures(name): ("musicbrainz_albumartistid", "b7ffd2af-418f-4be2-bdd1-22f8b48613da"), ("license", "http://creativecommons.org/licenses/by-nc-sa/3.0/us/"), ("copyright", "2008 nin"), + ("comment", "Hello there"), ], ) def test_can_get_metadata_from_flac_file(field, value): @@ -210,6 +216,7 @@ def test_can_get_metadata_from_flac_file(field, value): ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), ("copyright", "Someone"), ("genre", "Dubstep"), + ("comment", "Hello there"), ], ) def test_can_get_metadata_from_m4a_file(field, value): @@ -294,6 +301,7 @@ def test_metadata_fallback_ogg_theora(mocker): "license": "https://creativecommons.org/licenses/by-nc-nd/2.5/", "copyright": "Someone", "tags": ["Funk"], + "description": {"text": "Hello there", "content_type": "text/plain"}, }, ), ( @@ -327,6 +335,7 @@ def test_metadata_fallback_ogg_theora(mocker): "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", "copyright": "Someone", "tags": ["Classical"], + "description": {"text": "Hello there", "content_type": "text/plain"}, }, ), ( @@ -360,6 +369,7 @@ def test_metadata_fallback_ogg_theora(mocker): "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", "copyright": "Someone", "tags": ["Classical"], + "description": {"text": "Hello there", "content_type": "text/plain"}, }, ), ( @@ -391,6 +401,7 @@ def test_metadata_fallback_ogg_theora(mocker): # with the proper license field # ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), "copyright": "℗ 2012 JKP GmbH & Co. KG", + "description": {"text": "Hello there", "content_type": "text/plain"}, }, ), ( @@ -420,6 +431,7 @@ def test_metadata_fallback_ogg_theora(mocker): "license": "http://creativecommons.org/licenses/by-nc-sa/3.0/us/", "copyright": "2008 nin", "tags": ["Industrial"], + "description": {"text": "Hello there", "content_type": "text/plain"}, }, ), ], @@ -528,10 +540,12 @@ def test_fake_metadata_with_serializer(): "musicbrainz_albumartistid": "013c8e5b-d72a-4cd3-8dee-6c64d6125823;5b4d7d2d-36df-4b38-95e3-a964234f520f", "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", "copyright": "Someone", + "comment": "hello there", } expected = { "title": "Peer Gynt Suite no. 1, op. 46: I. Morning", + "description": {"text": "hello there", "content_type": "text/plain"}, "artists": [ { "name": "Edvard Grieg", diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py index ff2982dffc3c7c1748de57daa192822b8ef712ae..eff6925d39ad0d9c3fc8b8b87ec3f9c988b9e44e 100644 --- a/api/tests/music/test_mutations.py +++ b/api/tests/music/test_mutations.py @@ -1,6 +1,7 @@ import datetime import pytest +from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import licenses from funkwhale_api.music import mutations @@ -195,3 +196,26 @@ def test_mutation_set_attachment_cover(factories, now, mocker): assert obj.attachment_cover == new_attachment assert mutation.previous_state["cover"] == old_attachment.uuid + + +@pytest.mark.parametrize( + "factory_name", ["music.Track", "music.Album", "music.Artist"], +) +def test_album_mutation_description(factory_name, factories, mocker): + mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + content = factories["common.Content"]() + obj = factories[factory_name](description=content) + mutation = factories["common.Mutation"]( + type="update", + target=obj, + payload={"description": {"content_type": "text/plain", "text": "hello there"}}, + ) + mutation.apply() + obj.refresh_from_db() + + assert obj.description.content_type == "text/plain" + assert obj.description.text == "hello there" + assert ( + mutation.previous_state["description"] + == common_serializers.ContentSerializer(content).data + ) diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index ae486997ed69799651e083f960def76e9429c382..a35180cefcb7e5c584d28a8d1a0e57bdcdbc3fcf 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -501,3 +501,21 @@ def test_upload_with_channel_validates_import_metadata(factories, uploaded_audio ) with pytest.raises(serializers.serializers.ValidationError): assert serializer.is_valid(raise_exception=True) + + +@pytest.mark.parametrize( + "factory_name, serializer_class", + [ + ("music.Artist", serializers.ArtistWithAlbumsSerializer), + ("music.Album", serializers.AlbumSerializer), + ("music.Track", serializers.TrackSerializer), + ], +) +def test_detail_serializers_with_description_description( + factory_name, serializer_class, factories +): + content = factories["common.Content"]() + obj = factories[factory_name](description=content) + expected = common_serializers.ContentSerializer(content).data + serializer = serializer_class(obj, context={"description": True}) + assert serializer.data["description"] == expected diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 449be0c04b17b7a3ce765ee4d2cb18eed4ea7cce..3f09c537838667c12fa382351bcb6b762300ebcc 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -128,6 +128,21 @@ def test_can_create_track_from_file_metadata_featuring(factories): assert track.artist.name == "Santana feat. Chris Cornell" +def test_can_create_track_from_file_metadata_description(factories): + metadata = { + "title": "Whole Lotta Love", + "position": 1, + "disc_number": 1, + "description": {"text": "hello there", "content_type": "text/plain"}, + "album": {"title": "Test album"}, + "artists": [{"name": "Santana"}], + } + track = tasks.get_track_from_import_metadata(metadata) + + assert track.description.text == "hello there" + assert track.description.content_type == "text/plain" + + def test_can_create_track_from_file_metadata_mbid(factories, mocker): metadata = { "title": "Test track", @@ -607,6 +622,7 @@ def test_federation_audio_track_to_metadata(now, mocker): "copyright": "2018 Someone", "attributedTo": "http://track.attributed", "tag": [{"type": "Hashtag", "name": "TrackTag"}], + "content": "hello there", "album": { "published": published.isoformat(), "type": "Album", @@ -616,12 +632,16 @@ def test_federation_audio_track_to_metadata(now, mocker): "released": released.isoformat(), "tag": [{"type": "Hashtag", "name": "AlbumTag"}], "attributedTo": "http://album.attributed", + "content": "album desc", + "mediaType": "text/plain", "artists": [ { "type": "Artist", "published": published.isoformat(), "id": "http://hello.artist", "name": "John Smith", + "content": "album artist desc", + "mediaType": "text/markdown", "musicbrainzId": str(uuid.uuid4()), "attributedTo": "http://album-artist.attributed", "tag": [{"type": "Hashtag", "name": "AlbumArtistTag"}], @@ -639,6 +659,8 @@ def test_federation_audio_track_to_metadata(now, mocker): "type": "Artist", "id": "http://hello.trackartist", "name": "Bob Smith", + "content": "artist desc", + "mediaType": "text/html", "musicbrainzId": str(uuid.uuid4()), "attributedTo": "http://artist.attributed", "tag": [{"type": "Hashtag", "name": "ArtistTag"}], @@ -658,6 +680,7 @@ def test_federation_audio_track_to_metadata(now, mocker): "fid": payload["id"], "attributed_to": references["http://track.attributed"], "tags": ["TrackTag"], + "description": {"content_type": "text/html", "text": "hello there"}, "album": { "title": payload["album"]["name"], "attributed_to": references["http://album.attributed"], @@ -666,6 +689,7 @@ def test_federation_audio_track_to_metadata(now, mocker): "fid": payload["album"]["id"], "fdate": serializer.validated_data["album"]["published"], "tags": ["AlbumTag"], + "description": {"content_type": "text/plain", "text": "album desc"}, "artists": [ { "name": a["name"], @@ -675,6 +699,10 @@ def test_federation_audio_track_to_metadata(now, mocker): "fdate": serializer.validated_data["album"]["artists"][i][ "published" ], + "description": { + "content_type": "text/markdown", + "text": "album artist desc", + }, "tags": ["AlbumArtistTag"], } for i, a in enumerate(payload["album"]["artists"]) @@ -690,6 +718,7 @@ def test_federation_audio_track_to_metadata(now, mocker): "fdate": serializer.validated_data["artists"][i]["published"], "attributed_to": references["http://artist.attributed"], "tags": ["ArtistTag"], + "description": {"content_type": "text/html", "text": "artist desc"}, } for i, a in enumerate(payload["artists"]) ], diff --git a/api/tests/music/test_theora.ogg b/api/tests/music/test_theora.ogg index 3aa7117387a87115d9316f9d34205254381c95ec..ae21c94bc8a505a8c9e3f16c2bb9f7678dce452c 100644 Binary files a/api/tests/music/test_theora.ogg and b/api/tests/music/test_theora.ogg differ diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 744edd2d05ddbff8cde7cfac02486cccdc83c1a8..f78e6988c9aee7a0bd72d669e0b4ea3f8db39d5a 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1256,3 +1256,22 @@ def test_search_get_fts_stop_words(settings, logged_in_api_client, factories): assert response.status_code == 200 assert response.data == expected + + +@pytest.mark.parametrize( + "route, factory_name", + [ + ("api:v1:artists-detail", "music.Artist"), + ("api:v1:albums-detail", "music.Album"), + ("api:v1:tracks-detail", "music.Track"), + ], +) +def test_detail_includes_description_key( + route, factory_name, logged_in_api_client, factories +): + obj = factories[factory_name]() + url = reverse(route, kwargs={"pk": obj.pk}) + + response = logged_in_api_client.get(url) + + assert response.data["description"] is None diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index 6f4afccacbe968a19aa13bf4d2914b6b41e7d894..70f83d0496b2524648531f4130c24a35a7af1727 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -77,6 +77,10 @@ </button> </template> + <template v-else-if="fieldConfig.type === 'content'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + <textarea v-model="values[fieldConfig.id].text" :name="fieldConfig.id" :id="fieldConfig.id" rows="3"></textarea> + </template> <template v-else-if="fieldConfig.type === 'attachment'"> <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> <attachment-input @@ -100,8 +104,8 @@ <translate translate-context="Content/Library/Button.Label">Clear</translate> </button> </template> - <div v-if="values[fieldConfig.id] != initialValues[fieldConfig.id]"> - <button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = initialValues[fieldConfig.id]"> + <div v-if="!lodash.isEqual(values[fieldConfig.id], initialValues[fieldConfig.id])"> + <button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = lodash.clone(initialValues[fieldConfig.id])"> <i class="undo icon"></i> <translate translate-context="Content/Library/Button.Label">Reset to initial value</translate> </button> @@ -156,6 +160,7 @@ export default { summary: '', submittedMutation: null, showPendingReview: true, + lodash, } }, created () { @@ -216,8 +221,8 @@ export default { setValues () { let self = this this.config.fields.forEach(f => { - self.$set(self.values, f.id, f.getValue(self.object)) - self.$set(self.initialValues, f.id, self.values[f.id]) + self.$set(self.values, f.id, lodash.clone(f.getValue(self.object))) + self.$set(self.initialValues, f.id, lodash.clone(self.values[f.id])) }) }, submit() { diff --git a/front/src/edits.js b/front/src/edits.js index 8449677129d02b3396ed15fdeb8cf5b881547e3c..0757ed0d4a169873984e0ebc192beac14ef74a41 100644 --- a/front/src/edits.js +++ b/front/src/edits.js @@ -5,8 +5,20 @@ function getTagsValueRepr (val) { return val.slice().sort().join('\n') } +function getContentValueRepr (val) { + return val.text +} + export default { getConfigs () { + const description = { + id: 'description', + type: 'content', + required: true, + label: this.$pgettext('*/*/*/Noun', 'Description'), + getValue: (obj) => { return obj.description || {text: null, content_type: 'text/markdown'}}, + getValueRepr: getContentValueRepr + } return { artist: { fields: [ @@ -17,6 +29,7 @@ export default { label: this.$pgettext('*/*/*/Noun', 'Name'), getValue: (obj) => { return obj.name } }, + description, { id: 'tags', type: 'tags', @@ -24,7 +37,7 @@ export default { label: this.$pgettext('*/*/*/Noun', 'Tags'), getValue: (obj) => { return obj.tags }, getValueRepr: getTagsValueRepr - } + }, ] }, album: { @@ -36,6 +49,7 @@ export default { label: this.$pgettext('*/*/*/Noun', 'Title'), getValue: (obj) => { return obj.title } }, + description, { id: 'release_date', type: 'text', @@ -75,6 +89,7 @@ export default { label: this.$pgettext('*/*/*/Noun', 'Title'), getValue: (obj) => { return obj.title } }, + description, { id: 'position', type: 'text', diff --git a/front/src/views/admin/library/AlbumDetail.vue b/front/src/views/admin/library/AlbumDetail.vue index fdfc4c7faab2aa11efe793534368e9c99a1b1a99..6ca2acf8812123955462dfeb7f567406c0009daf 100644 --- a/front/src/views/admin/library/AlbumDetail.vue +++ b/front/src/views/admin/library/AlbumDetail.vue @@ -129,6 +129,12 @@ {{ object.domain }} </td> </tr> + <tr v-if="object.description"> + <td> + <translate translate-context="'*/*/*/Noun">Description</translate> + </td> + <td v-html="object.description.html"></td> + </tr> </tbody> </table> </section> diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue index e6b4a127b34a5175ebff959f659a52e099609d9e..72e1bcb0848b0e4acbcb6964d1cb789bd59bdf16 100644 --- a/front/src/views/admin/library/ArtistDetail.vue +++ b/front/src/views/admin/library/ArtistDetail.vue @@ -117,6 +117,12 @@ {{ object.domain }} </td> </tr> + <tr v-if="object.description"> + <td> + <translate translate-context="'*/*/*/Noun">Description</translate> + </td> + <td v-html="object.description.html"></td> + </tr> </tbody> </table> </section> diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue index 152d3390bf76034319f3ff2802c1a4c8fd30b6a8..d7f836d3a97d7c29a7ced121584e92cf69e9c427 100644 --- a/front/src/views/admin/library/TrackDetail.vue +++ b/front/src/views/admin/library/TrackDetail.vue @@ -181,6 +181,12 @@ {{ object.domain }} </td> </tr> + <tr v-if="object.description"> + <td> + <translate translate-context="'*/*/*/Noun">Description</translate> + </td> + <td v-html="object.description.html"></td> + </tr> </tbody> </table> </section>