diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 45854515c..dea9cbbe0 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -960,3 +960,5 @@ MIN_DELAY_BETWEEN_DOWNLOADS_COUNT = env.int( "MIN_DELAY_BETWEEN_DOWNLOADS_COUNT", default=60 * 60 * 6 ) MARKDOWN_EXTENSIONS = env.list("MARKDOWN_EXTENSIONS", default=["nl2br", "extra"]) + +LINKIFIER_SUPPORTED_TLDS = ["audio"] + env.list("LINKINFIER_SUPPORTED_TLDS", default=[]) diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py index 19aefa2ed..730fcc298 100644 --- a/api/config/spa_urls.py +++ b/api/config/spa_urls.py @@ -23,7 +23,12 @@ urlpatterns = [ ), urls.re_path( r"^channels/(?P[0-9a-f-]+)/?$", - audio_spa_views.channel_detail, + audio_spa_views.channel_detail_uuid, + name="channel_detail", + ), + urls.re_path( + r"^channels/(?P[^/]+)/?$", + audio_spa_views.channel_detail_username, name="channel_detail", ), ] diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py index e95bb5d66..308113836 100644 --- a/api/funkwhale_api/audio/models.py +++ b/api/funkwhale_api/audio/models.py @@ -6,6 +6,8 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.urls import reverse from django.utils import timezone +from django.db.models.signals import post_delete +from django.dispatch import receiver from funkwhale_api.federation import keys from funkwhale_api.federation import models as federation_models @@ -44,14 +46,22 @@ class Channel(models.Model): ) def get_absolute_url(self): - return federation_utils.full_url("/channels/{}".format(self.uuid)) + suffix = self.uuid + if self.actor.is_local: + suffix = self.actor.preferred_username + else: + suffix = self.actor.full_username + return federation_utils.full_url("/channels/{}".format(suffix)) def get_rss_url(self): if not self.artist.is_local: return self.rss_url return federation_utils.full_url( - reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid}) + reverse( + "api:v1:channels-rss", + kwargs={"composite": self.actor.preferred_username}, + ) ) @@ -62,3 +72,10 @@ def generate_actor(username, **kwargs): actor_data["public_key"] = public.decode("utf-8") return federation_models.Actor.objects.create(**actor_data) + + +@receiver(post_delete, sender=Channel) +def delete_channel_related_objs(instance, **kwargs): + instance.library.delete() + instance.actor.delete() + instance.artist.delete() diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py index df98bde32..a162ae9c0 100644 --- a/api/funkwhale_api/audio/serializers.py +++ b/api/funkwhale_api/audio/serializers.py @@ -3,6 +3,8 @@ from django.db import transaction from rest_framework import serializers +from django.contrib.staticfiles.templatetags.staticfiles import static + from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils from funkwhale_api.common import locales @@ -24,7 +26,7 @@ class ChannelMetadataSerializer(serializers.Serializer): itunes_category = serializers.ChoiceField( choices=categories.ITUNES_CATEGORIES, required=True ) - itunes_subcategory = serializers.CharField(required=False) + itunes_subcategory = serializers.CharField(required=False, allow_null=True) language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES) copyright = serializers.CharField(required=False, allow_null=True, max_length=255) owner_name = serializers.CharField(required=False, allow_null=True, max_length=255) @@ -64,6 +66,7 @@ class ChannelCreateSerializer(serializers.Serializer): choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES ) metadata = serializers.DictField(required=False) + cover = music_serializers.COVER_WRITE_FIELD def validate(self, validated_data): existing_channels = self.context["actor"].owned_channels.count() @@ -95,15 +98,15 @@ class ChannelCreateSerializer(serializers.Serializer): def create(self, validated_data): from . import views + cover = validated_data.pop("cover", None) description = validated_data.get("description") artist = music_models.Artist.objects.create( attributed_to=validated_data["attributed_to"], name=validated_data["name"], content_category=validated_data["content_category"], + attachment_cover=cover, ) - description_obj = common_utils.attach_content( - artist, "description", description - ) + common_utils.attach_content(artist, "description", description) if validated_data.get("tags", []): tags_models.set_tags(artist, *validated_data["tags"]) @@ -113,9 +116,8 @@ class ChannelCreateSerializer(serializers.Serializer): attributed_to=validated_data["attributed_to"], metadata=validated_data["metadata"], ) - summary = description_obj.rendered if description_obj else None channel.actor = models.generate_actor( - validated_data["username"], summary=summary, name=validated_data["name"], + validated_data["username"], name=validated_data["name"], ) channel.library = music_models.Library.objects.create( @@ -142,6 +144,7 @@ class ChannelUpdateSerializer(serializers.Serializer): choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES ) metadata = serializers.DictField(required=False) + cover = music_serializers.COVER_WRITE_FIELD def validate(self, validated_data): validated_data = super().validate(validated_data) @@ -194,6 +197,9 @@ class ChannelUpdateSerializer(serializers.Serializer): ("content_category", validated_data["content_category"]) ) + if "cover" in validated_data: + artist_update_fields.append(("attachment_cover", validated_data["cover"])) + if actor_update_fields: for field, value in actor_update_fields: setattr(obj.actor, field, value) @@ -292,7 +298,7 @@ def rss_serialize_item(upload): # we enforce MP3, since it's the only format supported everywhere "url": federation_utils.full_url(upload.get_listen_url(to="mp3")), "length": upload.size or 0, - "type": upload.mimetype or "audio/mpeg", + "type": "audio/mpeg", } ], } @@ -362,6 +368,11 @@ def rss_serialize_channel(channel): data["itunes:image"] = [ {"href": channel.artist.attachment_cover.download_url_original} ] + else: + placeholder_url = federation_utils.full_url( + static("images/podcasts-cover-placeholder.png") + ) + data["itunes:image"] = [{"href": placeholder_url}] tagged_items = getattr(channel.artist, "_prefetched_tagged_items", []) diff --git a/api/funkwhale_api/audio/spa_views.py b/api/funkwhale_api/audio/spa_views.py index 097e00cf4..c76669d39 100644 --- a/api/funkwhale_api/audio/spa_views.py +++ b/api/funkwhale_api/audio/spa_views.py @@ -1,26 +1,33 @@ import urllib.parse from django.conf import settings +from django.db.models import Q from django.urls import reverse +from rest_framework import serializers + from funkwhale_api.common import preferences from funkwhale_api.common import utils +from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import spa_views from . import models -def channel_detail(request, uuid): - queryset = models.Channel.objects.filter(uuid=uuid).select_related( +def channel_detail(query): + queryset = models.Channel.objects.filter(query).select_related( "artist__attachment_cover", "actor", "library" ) try: obj = queryset.get() except models.Channel.DoesNotExist: return [] + obj_url = utils.join_url( settings.FUNKWHALE_URL, - utils.spa_reverse("channel_detail", kwargs={"uuid": obj.uuid}), + utils.spa_reverse( + "channel_detail", kwargs={"username": obj.actor.full_username} + ), ) metas = [ {"tag": "meta", "property": "og:url", "content": obj_url}, @@ -72,3 +79,25 @@ def channel_detail(request, uuid): # twitter player is also supported in various software metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid) return metas + + +def channel_detail_uuid(request, uuid): + validator = serializers.UUIDField().to_internal_value + try: + uuid = validator(uuid) + except serializers.ValidationError: + return [] + return channel_detail(Q(uuid=uuid)) + + +def channel_detail_username(request, username): + validator = federation_utils.get_actor_data_from_username + try: + username_data = validator(username) + except serializers.ValidationError: + return [] + query = Q( + actor__domain=username_data["domain"], + actor__preferred_username__iexact=username_data["username"], + ) + return channel_detail(query) diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py index 6d463d369..974797c35 100644 --- a/api/funkwhale_api/audio/views.py +++ b/api/funkwhale_api/audio/views.py @@ -7,18 +7,21 @@ from rest_framework import viewsets from django import http from django.db import transaction -from django.db.models import Count, Prefetch +from django.db.models import Count, Prefetch, Q from django.db.utils import IntegrityError +from funkwhale_api.common import locales from funkwhale_api.common import permissions from funkwhale_api.common import preferences +from funkwhale_api.common.mixins import MultipleLookupDetailMixin from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import routes +from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views from funkwhale_api.users.oauth import permissions as oauth_permissions -from . import filters, models, renderers, serializers +from . import categories, filters, models, renderers, serializers ARTIST_PREFETCH_QS = ( music_models.Artist.objects.select_related("description", "attachment_cover",) @@ -36,6 +39,7 @@ class ChannelsMixin(object): class ChannelViewSet( ChannelsMixin, + MultipleLookupDetailMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, @@ -43,7 +47,20 @@ class ChannelViewSet( mixins.DestroyModelMixin, viewsets.GenericViewSet, ): - lookup_field = "uuid" + url_lookups = [ + { + "lookup_field": "uuid", + "validator": serializers.serializers.UUIDField().to_internal_value, + }, + { + "lookup_field": "username", + "validator": federation_utils.get_actor_data_from_username, + "get_query": lambda v: Q( + actor__domain=v["domain"], + actor__preferred_username__iexact=v["username"], + ), + }, + ] filterset_class = filters.ChannelFilter serializer_class = serializers.ChannelSerializer queryset = ( @@ -134,6 +151,25 @@ class ChannelViewSet( data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads) return response.Response(data, status=200) + @decorators.action( + methods=["get"], + detail=False, + url_path="metadata-choices", + url_name="metadata_choices", + permission_classes=[], + ) + def metedata_choices(self, request, *args, **kwargs): + data = { + "language": [ + {"value": code, "label": name} for code, name in locales.ISO_639_CHOICES + ], + "itunes_category": [ + {"value": code, "label": code, "children": children} + for code, children in categories.ITUNES_CATEGORIES.items() + ], + } + return response.Response(data) + def get_serializer_context(self): context = super().get_serializer_context() context["subscriptions_count"] = self.action in [ @@ -152,7 +188,7 @@ class ChannelViewSet( {"type": "Delete", "object": {"type": instance.actor.type}}, context={"actor": instance.actor}, ) - instance.delete() + instance.__class__.objects.filter(pk=instance.pk).delete() class SubscriptionsViewSet( diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py index 9e2a59dca..6122adbca 100644 --- a/api/funkwhale_api/common/middleware.py +++ b/api/funkwhale_api/common/middleware.py @@ -1,4 +1,5 @@ import html +import logging import io import os import re @@ -20,6 +21,8 @@ from . import utils EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"] +logger = logging.getLogger(__name__) + def should_fallback_to_spa(path): if path == "/": @@ -270,6 +273,17 @@ class ThrottleStatusMiddleware: return response +class VerboseBadRequestsMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if response.status_code == 400: + logger.warning("Bad request: %s", response.content) + return response + + class ProfilerMiddleware: """ from https://github.com/omarish/django-cprofile-middleware/blob/master/django_cprofile_middleware/middleware.py diff --git a/api/funkwhale_api/common/mixins.py b/api/funkwhale_api/common/mixins.py new file mode 100644 index 000000000..ed619d637 --- /dev/null +++ b/api/funkwhale_api/common/mixins.py @@ -0,0 +1,34 @@ +from rest_framework import serializers + +from django.db.models import Q +from django.shortcuts import get_object_or_404 + + +class MultipleLookupDetailMixin(object): + lookup_value_regex = "[^/]+" + lookup_field = "composite" + + def get_object(self): + queryset = self.filter_queryset(self.get_queryset()) + + relevant_lookup = None + value = None + for lookup in self.url_lookups: + field_validator = lookup["validator"] + try: + value = field_validator(self.kwargs["composite"]) + except serializers.ValidationError: + continue + else: + relevant_lookup = lookup + break + get_query = relevant_lookup.get( + "get_query", lambda value: Q(**{relevant_lookup["lookup_field"]: value}) + ) + query = get_query(value) + obj = get_object_or_404(queryset, query) + + # May raise a permission denied + self.check_object_permissions(self.request, obj) + + return obj diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index ec2c1d1f8..35f64406e 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -359,4 +359,7 @@ 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() + try: + getattr(instance, field).delete() + except Content.DoesNotExist: + pass diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 7af83be94..b36d1cd64 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -279,7 +279,11 @@ HTML_PERMISSIVE_CLEANER = bleach.sanitizer.Cleaner( attributes=["class", "rel", "alt", "title"], ) -HTML_LINKER = bleach.linkifier.Linker() +# support for additional tlds +# cf https://github.com/mozilla/bleach/issues/367#issuecomment-384631867 +ALL_TLDS = set(settings.LINKIFIER_SUPPORTED_TLDS + bleach.linkifier.TLDS) +URL_RE = bleach.linkifier.build_url_re(tlds=sorted(ALL_TLDS, reverse=True)) +HTML_LINKER = bleach.linkifier.Linker(url_re=URL_RE) def clean_html(html, permissive=False): @@ -338,29 +342,34 @@ def attach_file(obj, field, file_data, fetch=False): if not file_data: return - extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} - extension = extensions.get(file_data["mimetype"], "jpg") - attachment = models.Attachment(mimetype=file_data["mimetype"]) - name_fields = ["uuid", "full_username", "pk"] - name = [getattr(obj, field) for field in name_fields if getattr(obj, field, None)][ - 0 - ] - filename = "{}-{}.{}".format(field, name, extension) - if "url" in file_data: - attachment.url = file_data["url"] + if isinstance(file_data, models.Attachment): + attachment = file_data else: - f = ContentFile(file_data["content"]) - attachment.file.save(filename, f, save=False) + extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} + extension = extensions.get(file_data["mimetype"], "jpg") + attachment = models.Attachment(mimetype=file_data["mimetype"]) + name_fields = ["uuid", "full_username", "pk"] + name = [ + getattr(obj, field) for field in name_fields if getattr(obj, field, None) + ][0] + filename = "{}-{}.{}".format(field, name, extension) + if "url" in file_data: + attachment.url = file_data["url"] + else: + f = ContentFile(file_data["content"]) + attachment.file.save(filename, f, save=False) - if not attachment.file and fetch: - try: - tasks.fetch_remote_attachment(attachment, filename=filename, save=False) - except Exception as e: - logger.warn("Cannot download attachment at url %s: %s", attachment.url, e) - attachment = None + if not attachment.file and fetch: + try: + tasks.fetch_remote_attachment(attachment, filename=filename, save=False) + except Exception as e: + logger.warn( + "Cannot download attachment at url %s: %s", attachment.url, e + ) + attachment = None - if attachment: - attachment.save() + if attachment: + attachment.save() setattr(obj, field, attachment) obj.save(update_fields=[field]) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index c200e4f6e..820c93bae 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -246,6 +246,8 @@ class Actor(models.Model): return self.followers.filter(pk__in=follows.values_list("actor", flat=True)) def should_autoapprove_follow(self, actor): + if self.get_channel(): + return True return False def get_user(self): diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 351e1ac79..2e769f38a 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -134,7 +134,9 @@ class ActorSerializer(jsonld.JsonLdSerializer): ) preferredUsername = serializers.CharField() manuallyApprovesFollowers = serializers.NullBooleanField(required=False) - name = serializers.CharField(required=False, max_length=200) + name = serializers.CharField( + required=False, max_length=200, allow_blank=True, allow_null=True + ) summary = TruncatedCharField( truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH, required=False, @@ -209,6 +211,8 @@ class ActorSerializer(jsonld.JsonLdSerializer): }, ] include_image(ret, channel.artist.attachment_cover, "icon") + if channel.artist.description_id: + ret["summary"] = channel.artist.description.rendered else: ret["url"] = [ { diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 11840e52c..c20d792bc 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -71,7 +71,7 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV @action(methods=["get", "post"], detail=True) def outbox(self, request, *args, **kwargs): actor = self.get_object() - channel = actor.channel + channel = actor.get_channel() if channel: return self.get_channel_outbox_response(request, channel) return response.Response({}, status=200) diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 484a078f7..2395a8381 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -107,12 +107,14 @@ class TrackFilter( class UploadFilter(audio_filters.IncludeChannelsFilterSet): library = filters.CharFilter("library__uuid") + channel = filters.CharFilter("library__channel__uuid") track = filters.UUIDFilter("track__uuid") track_artist = filters.UUIDFilter("track__artist__uuid") album_artist = filters.UUIDFilter("track__album__artist__uuid") library = filters.UUIDFilter("library__uuid") playable = filters.BooleanFilter(field_name="_", method="filter_playable") scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True) + import_status = common_filters.MultipleQueryFilter(coerce=str) q = fields.SmartSearchFilter( config=search.SearchConfig( search_fields={ @@ -143,6 +145,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet): "library", "import_reference", "scope", + "channel", ] include_channels_field = "track__artist__channel" diff --git a/api/funkwhale_api/music/licenses.py b/api/funkwhale_api/music/licenses.py index 5690a912f..599d6a7da 100644 --- a/api/funkwhale_api/music/licenses.py +++ b/api/funkwhale_api/music/licenses.py @@ -30,12 +30,12 @@ def load(data): try: license = existing_by_code[row["code"]] except KeyError: - logger.info("Loading new license: {}".format(row["code"])) + logger.debug("Loading new license: {}".format(row["code"])) to_create.append( models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS}) ) else: - logger.info("Updating license: {}".format(row["code"])) + logger.debug("Updating license: {}".format(row["code"])) stored = [getattr(license, f) for f in MODEL_FIELDS] wanted = [row[f] for f in MODEL_FIELDS] if wanted == stored: diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index ee24995e7..4e0558010 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -512,9 +512,10 @@ class ArtistField(serializers.Field): mbid = None artist = {"name": name, "mbid": mbid} final.append(artist) - - field = serializers.ListField(child=ArtistSerializer(), min_length=1) - + field = serializers.ListField( + child=ArtistSerializer(strict=self.context.get("strict", True)), + min_length=1, + ) return field.to_internal_value(final) @@ -647,15 +648,29 @@ class MBIDField(serializers.UUIDField): class ArtistSerializer(serializers.Serializer): - name = serializers.CharField() + name = serializers.CharField(required=False, allow_null=True) mbid = MBIDField() + def __init__(self, *args, **kwargs): + self.strict = kwargs.pop("strict", True) + super().__init__(*args, **kwargs) + + def validate_name(self, v): + if self.strict and not v: + raise serializers.ValidationError("This field is required.") + return v + class AlbumSerializer(serializers.Serializer): - title = serializers.CharField() + title = serializers.CharField(required=False, allow_null=True) mbid = MBIDField() release_date = PermissiveDateField(required=False, allow_null=True) + def validate_title(self, v): + if self.context.get("strict", True) and not v: + raise serializers.ValidationError("This field is required.") + return v + class PositionField(serializers.CharField): def to_internal_value(self, v): @@ -691,7 +706,7 @@ class DescriptionField(serializers.CharField): class TrackMetadataSerializer(serializers.Serializer): - title = serializers.CharField() + title = serializers.CharField(required=False, allow_null=True) position = PositionField(allow_blank=True, allow_null=True, required=False) disc_number = PositionField(allow_blank=True, allow_null=True, required=False) copyright = serializers.CharField(allow_blank=True, allow_null=True, required=False) @@ -714,6 +729,11 @@ class TrackMetadataSerializer(serializers.Serializer): "tags", ] + def validate_title(self, v): + if self.context.get("strict", True) and not v: + raise serializers.ValidationError("This field is required.") + return v + def validate(self, validated_data): validated_data = super().validate(validated_data) for field in self.remove_blank_null_fields: diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index a5bc37275..f27ed3d50 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -950,7 +950,11 @@ class Upload(models.Model): def get_all_tagged_items(self): track_tags = self.track.tagged_items.all() - album_tags = self.track.album.tagged_items.all() + album_tags = ( + self.track.album.tagged_items.all() + if self.track.album + else tags_models.TaggedItem.objects.none() + ) artist_tags = self.track.artist.tagged_items.all() items = (track_tags | album_tags | artist_tags).order_by("tag__name") diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 4113c3d7a..7099231ba 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -6,16 +6,30 @@ from django.conf import settings from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers -from funkwhale_api.audio import serializers as audio_serializers +from funkwhale_api.common import models as common_models 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.federation import utils as federation_utils from funkwhale_api.playlists import models as playlists_models -from funkwhale_api.tags.models import Tag +from funkwhale_api.tags import models as tag_models from funkwhale_api.tags import serializers as tags_serializers -from . import filters, models, tasks +from . import filters, models, tasks, utils + +NOOP = object() + +COVER_WRITE_FIELD = common_serializers.RelatedField( + "uuid", + queryset=common_models.Attachment.objects.all().local(), + serializer=None, + allow_null=True, + required=False, + queryset_filter=lambda qs, context: qs.filter(actor=context["request"].user.actor), + write_only=True, +) + +from funkwhale_api.audio import serializers as audio_serializers # NOQA class CoverField( @@ -381,9 +395,30 @@ class UploadSerializer(serializers.ModelSerializer): "import_date", ] + def validate(self, data): + validated_data = super().validate(data) + if "audio_file" in validated_data: + audio_data = utils.get_audio_file_data(validated_data["audio_file"]) + if audio_data: + validated_data["duration"] = audio_data["length"] + validated_data["bitrate"] = audio_data["bitrate"] + return validated_data + + +def filter_album(qs, context): + if "channel" in context: + return qs.filter(artist__channel=context["channel"]) + if "actor" in context: + return qs.filter(artist__attributed_to=context["actor"]) + + return qs.none() + class ImportMetadataSerializer(serializers.Serializer): title = serializers.CharField(max_length=500, required=True) + description = serializers.CharField( + max_length=5000, required=False, allow_null=True + ) mbid = serializers.UUIDField(required=False, allow_null=True) copyright = serializers.CharField(max_length=500, required=False, allow_null=True) position = serializers.IntegerField(min_value=1, required=False, allow_null=True) @@ -391,12 +426,32 @@ class ImportMetadataSerializer(serializers.Serializer): license = common_serializers.RelatedField( "code", LicenseSerializer(), required=False, allow_null=True ) + cover = common_serializers.RelatedField( + "uuid", + queryset=common_models.Attachment.objects.all().local(), + serializer=None, + queryset_filter=lambda qs, context: qs.filter(actor=context["actor"]), + write_only=True, + required=False, + allow_null=True, + ) + album = common_serializers.RelatedField( + "id", + queryset=models.Album.objects.all(), + serializer=None, + queryset_filter=filter_album, + write_only=True, + required=False, + allow_null=True, + ) class ImportMetadataField(serializers.JSONField): def to_internal_value(self, v): v = super().to_internal_value(v) - s = ImportMetadataSerializer(data=v) + s = ImportMetadataSerializer( + data=v, context={"actor": self.context["user"].actor} + ) s.is_valid(raise_exception=True) return v @@ -464,6 +519,7 @@ class UploadActionSerializer(common_serializers.ActionSerializer): actions = [ common_serializers.Action("delete", allow_all=True), common_serializers.Action("relaunch_import", allow_all=True), + common_serializers.Action("publish", allow_all=False), ] filterset_class = filters.UploadFilter pk_field = "uuid" @@ -490,10 +546,18 @@ class UploadActionSerializer(common_serializers.ActionSerializer): for pk in pks: common_utils.on_commit(tasks.process_upload.delay, upload_id=pk) + @transaction.atomic + def handle_publish(self, objects): + qs = objects.filter(import_status="draft") + pks = list(qs.values_list("id", flat=True)) + qs.update(import_status="pending") + for pk in pks: + common_utils.on_commit(tasks.process_upload.delay, upload_id=pk) + class TagSerializer(serializers.ModelSerializer): class Meta: - model = Tag + model = tag_models.Tag fields = ("id", "name", "creation_date") @@ -509,7 +573,7 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() name = serializers.CharField(source="title") artist = serializers.CharField(source="artist.name") - album = serializers.CharField(source="album.title") + album = serializers.SerializerMethodField() class Meta: model = models.Track @@ -518,6 +582,10 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): def get_type(self, obj): return "Audio" + def get_album(self, o): + if o.album: + return o.album.title + def get_embed_url(type, id): return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id) @@ -561,7 +629,13 @@ class OembedSerializer(serializers.Serializer): embed_type = "track" embed_id = track.pk data["title"] = "{} by {}".format(track.title, track.artist.name) - if track.album.attachment_cover: + if track.attachment_cover: + data[ + "thumbnail_url" + ] = track.album.attachment_cover.download_url_medium_square_crop + data["thumbnail_width"] = 200 + data["thumbnail_height"] = 200 + elif track.album and track.album.attachment_cover: data[ "thumbnail_url" ] = track.album.attachment_cover.download_url_medium_square_crop @@ -630,7 +704,16 @@ class OembedSerializer(serializers.Serializer): elif match.url_name == "channel_detail": from funkwhale_api.audio.models import Channel - qs = Channel.objects.filter(uuid=match.kwargs["uuid"]).select_related( + kwargs = {} + if "uuid" in match.kwargs: + kwargs["uuid"] = match.kwargs["uuid"] + else: + username_data = federation_utils.get_actor_data_from_username( + match.kwargs["username"] + ) + kwargs["actor__domain"] = username_data["domain"] + kwargs["actor__preferred_username__iexact"] = username_data["username"] + qs = Channel.objects.filter(**kwargs).select_related( "artist__attachment_cover" ) try: @@ -705,3 +788,46 @@ class OembedSerializer(serializers.Serializer): def create(self, data): return data + + +class AlbumCreateSerializer(serializers.Serializer): + title = serializers.CharField(required=True, max_length=255) + cover = COVER_WRITE_FIELD + release_date = serializers.DateField(required=False, allow_null=True) + tags = tags_serializers.TagsListField(required=False) + description = common_serializers.ContentSerializer(allow_null=True, required=False) + + artist = common_serializers.RelatedField( + "id", + queryset=models.Artist.objects.exclude(channel__isnull=True), + required=True, + serializer=None, + filters=lambda context: {"attributed_to": context["user"].actor}, + ) + + def validate(self, validated_data): + duplicates = validated_data["artist"].albums.filter( + title__iexact=validated_data["title"] + ) + if duplicates.exists(): + raise serializers.ValidationError("An album with this title already exist") + + return super().validate(validated_data) + + def to_representation(self, obj): + obj.artist.attachment_cover + return AlbumSerializer(obj, context=self.context).data + + def create(self, validated_data): + instance = models.Album.objects.create( + attributed_to=self.context["user"].actor, + artist=validated_data["artist"], + release_date=validated_data.get("release_date"), + title=validated_data["title"], + attachment_cover=validated_data.get("cover"), + ) + common_utils.attach_content( + instance, "description", validated_data.get("description") + ) + tag_models.set_tags(instance, *(validated_data.get("tags", []) or [])) + return instance diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py index e96ce5fee..0619166a6 100644 --- a/api/funkwhale_api/music/spa_views.py +++ b/api/funkwhale_api/music/spa_views.py @@ -49,16 +49,29 @@ def library_track(request, pk): utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}), ), }, - { - "tag": "meta", - "property": "music:album", - "content": utils.join_url( - settings.FUNKWHALE_URL, - utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}), - ), - }, ] - if obj.album.attachment_cover: + + if obj.album: + metas.append( + { + "tag": "meta", + "property": "music:album", + "content": utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}), + ), + }, + ) + + if obj.attachment_cover: + metas.append( + { + "tag": "meta", + "property": "og:image", + "content": obj.attachment_cover.download_url_medium_square_crop, + } + ) + elif obj.album and obj.album.attachment_cover: metas.append( { "tag": "meta", diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index dce014f15..1d53c75fb 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -175,46 +175,69 @@ def fail_import(upload, error_code, detail=None, **fields): "upload", ) def process_upload(upload, update_denormalization=True): + """ + Main handler to process uploads submitted by user and create the corresponding + metadata (tracks/artists/albums) in our DB. + """ from . import serializers + channel = upload.library.get_channel() + # When upload is linked to a channel instead of a library + # we willingly ignore the metadata embedded in the file itself + # and rely on user metadata only + use_file_metadata = channel is None + import_metadata = upload.import_metadata or {} internal_config = {"funkwhale": import_metadata.get("funkwhale", {})} forced_values_serializer = serializers.ImportMetadataSerializer( - data=import_metadata + data=import_metadata, + context={"actor": upload.library.actor, "channel": channel}, ) if forced_values_serializer.is_valid(): forced_values = forced_values_serializer.validated_data else: forced_values = {} + if not use_file_metadata: + detail = forced_values_serializer.errors + metadata_dump = import_metadata + return fail_import( + upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump + ) - if upload.library.get_channel(): + if channel: # ensure the upload is associated with the channel artist forced_values["artist"] = upload.library.channel.artist + old_status = upload.import_status - audio_file = upload.get_audio_file() - additional_data = {} + additional_data = {"upload_source": upload.source} - m = metadata.Metadata(audio_file) - try: - serializer = metadata.TrackMetadataSerializer(data=m) - serializer.is_valid() - except Exception: - fail_import(upload, "unknown_error") - raise - if not serializer.is_valid(): - detail = serializer.errors + if use_file_metadata: + audio_file = upload.get_audio_file() + + m = metadata.Metadata(audio_file) try: - metadata_dump = m.all() - except Exception as e: - logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e)) - return fail_import( - upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump - ) + serializer = metadata.TrackMetadataSerializer(data=m) + serializer.is_valid() + except Exception: + fail_import(upload, "unknown_error") + raise + if not serializer.is_valid(): + detail = serializer.errors + try: + metadata_dump = m.all() + except Exception as e: + logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e)) + return fail_import( + upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump + ) - final_metadata = collections.ChainMap( - additional_data, serializer.validated_data, internal_config - ) - additional_data["upload_source"] = upload.source + final_metadata = collections.ChainMap( + additional_data, serializer.validated_data, internal_config + ) + else: + final_metadata = collections.ChainMap( + additional_data, forced_values, internal_config, + ) try: track = get_track_from_import_metadata( final_metadata, attributed_to=upload.library.actor, **forced_values @@ -275,7 +298,7 @@ def process_upload(upload, update_denormalization=True): ) # update album cover, if needed - if not track.album.attachment_cover: + if track.album and not track.album.attachment_cover: populate_album_cover( track.album, source=final_metadata.get("upload_source"), ) @@ -466,7 +489,11 @@ def _get_track(data, attributed_to=None, **forced_values): track_mbid = ( forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None) ) - album_mbid = getter(data, "album", "mbid") + try: + album_mbid = getter(data, "album", "mbid") + except TypeError: + # album is forced + album_mbid = None track_fid = getter(data, "fid") query = None @@ -528,81 +555,94 @@ def _get_track(data, attributed_to=None, **forced_values): if "album" in forced_values: album = forced_values["album"] else: - album_artists = getter(data, "album", "artists", default=artists) or artists - album_artist_data = album_artists[0] - album_artist_name = truncate( - album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"] - ) - if album_artist_name == artist_name: - album_artist = artist + if "artist" in forced_values: + album_artist = forced_values["artist"] else: - query = Q(name__iexact=album_artist_name) - album_artist_mbid = album_artist_data.get("mbid", None) - album_artist_fid = album_artist_data.get("fid", None) - if album_artist_mbid: - query |= Q(mbid=album_artist_mbid) - if album_artist_fid: - query |= Q(fid=album_artist_fid) - defaults = { - "name": album_artist_name, - "mbid": album_artist_mbid, - "fid": album_artist_fid, - "from_activity_id": from_activity_id, - "attributed_to": album_artist_data.get("attributed_to", attributed_to), - } - if album_artist_data.get("fdate"): - defaults["creation_date"] = album_artist_data.get("fdate") - - album_artist, created = get_best_candidate_or_create( - models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] + album_artists = getter(data, "album", "artists", default=artists) or artists + album_artist_data = album_artists[0] + album_artist_name = truncate( + album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"] ) - 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") - ) - common_utils.attach_file( - album_artist, - "attachment_cover", - album_artist_data.get("cover_data"), + if album_artist_name == artist_name: + album_artist = artist + else: + query = Q(name__iexact=album_artist_name) + album_artist_mbid = album_artist_data.get("mbid", None) + album_artist_fid = album_artist_data.get("fid", None) + if album_artist_mbid: + query |= Q(mbid=album_artist_mbid) + if album_artist_fid: + query |= Q(fid=album_artist_fid) + defaults = { + "name": album_artist_name, + "mbid": album_artist_mbid, + "fid": album_artist_fid, + "from_activity_id": from_activity_id, + "attributed_to": album_artist_data.get( + "attributed_to", attributed_to + ), + } + if album_artist_data.get("fdate"): + defaults["creation_date"] = album_artist_data.get("fdate") + + album_artist, created = get_best_candidate_or_create( + models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] ) + 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"), + ) + common_utils.attach_file( + album_artist, + "attachment_cover", + album_artist_data.get("cover_data"), + ) # get / create album - album_data = data["album"] - album_title = truncate(album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"]) - album_fid = album_data.get("fid", None) + if "album" in data: + album_data = data["album"] + album_title = truncate( + album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"] + ) + album_fid = album_data.get("fid", None) - if album_mbid: - query = Q(mbid=album_mbid) + if album_mbid: + query = Q(mbid=album_mbid) + else: + query = Q(title__iexact=album_title, artist=album_artist) + + if album_fid: + query |= Q(fid=album_fid) + defaults = { + "title": album_title, + "artist": album_artist, + "mbid": album_mbid, + "release_date": album_data.get("release_date"), + "fid": album_fid, + "from_activity_id": from_activity_id, + "attributed_to": album_data.get("attributed_to", attributed_to), + } + if album_data.get("fdate"): + defaults["creation_date"] = album_data.get("fdate") + + album, created = get_best_candidate_or_create( + models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"] + ) + if created: + tags_models.add_tags(album, *album_data.get("tags", [])) + common_utils.attach_content( + album, "description", album_data.get("description") + ) + common_utils.attach_file( + album, "attachment_cover", album_data.get("cover_data") + ) else: - query = Q(title__iexact=album_title, artist=album_artist) - - if album_fid: - query |= Q(fid=album_fid) - defaults = { - "title": album_title, - "artist": album_artist, - "mbid": album_mbid, - "release_date": album_data.get("release_date"), - "fid": album_fid, - "from_activity_id": from_activity_id, - "attributed_to": album_data.get("attributed_to", attributed_to), - } - if album_data.get("fdate"): - defaults["creation_date"] = album_data.get("fdate") - - album, created = get_best_candidate_or_create( - models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"] - ) - if created: - tags_models.add_tags(album, *album_data.get("tags", [])) - common_utils.attach_content( - album, "description", album_data.get("description") - ) - common_utils.attach_file( - album, "attachment_cover", album_data.get("cover_data") - ) - + album = None # get / create track track_title = ( forced_values["title"] @@ -629,6 +669,14 @@ def _get_track(data, attributed_to=None, **forced_values): if "copyright" in forced_values else truncate(data.get("copyright"), models.MAX_LENGTHS["COPYRIGHT"]) ) + description = ( + {"text": forced_values["description"], "content_type": "text/markdown"} + if "description" in forced_values + else data.get("description") + ) + cover_data = ( + forced_values["cover"] if "cover" in forced_values else data.get("cover_data") + ) query = Q( title__iexact=track_title, @@ -670,8 +718,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")) - common_utils.attach_file(track, "attachment_cover", data.get("cover_data")) + common_utils.attach_content(track, "description", description) + common_utils.attach_file(track, "attachment_cover", cover_data) return track diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 8ca2164f6..47aa60b89 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -173,6 +173,8 @@ class ArtistViewSet( class AlbumViewSet( HandleInvalidSearch, common_views.SkipFilterForGetObject, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet, ): queryset = ( @@ -202,11 +204,19 @@ class AlbumViewSet( def get_serializer_context(self): context = super().get_serializer_context() - context["description"] = self.action in ["retrieve", "create", "update"] + context["description"] = self.action in [ + "retrieve", + "create", + ] + context["user"] = self.request.user return context def get_queryset(self): queryset = super().get_queryset() + if self.action in ["destroy"]: + queryset = queryset.exclude(artist__channel=None).filter( + artist__attributed_to=self.request.user.actor + ) tracks = ( models.Track.objects.prefetch_related("artist") .with_playable_uploads(utils.get_actor_from_request(self.request)) @@ -221,6 +231,11 @@ class AlbumViewSet( get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o)) ) + def get_serializer_class(self): + if self.action in ["create"]: + return serializers.AlbumCreateSerializer + return super().get_serializer_class() + class LibraryViewSet( mixins.CreateModelMixin, @@ -288,6 +303,7 @@ class LibraryViewSet( class TrackViewSet( HandleInvalidSearch, common_views.SkipFilterForGetObject, + mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet, ): """ @@ -330,6 +346,10 @@ class TrackViewSet( def get_queryset(self): queryset = super().get_queryset() + if self.action in ["destroy"]: + queryset = queryset.exclude(artist__channel=None).filter( + artist__attributed_to=self.request.user.actor + ) filter_favorites = self.request.GET.get("favorites", None) user = self.request.user if user.is_authenticated and filter_favorites == "true": @@ -617,18 +637,17 @@ class UploadViewSet( m = tasks.metadata.Metadata(upload.get_audio_file()) except FileNotFoundError: return Response({"detail": "File not found"}, status=500) - serializer = tasks.metadata.TrackMetadataSerializer(data=m) + serializer = tasks.metadata.TrackMetadataSerializer( + data=m, context={"strict": False} + ) if not serializer.is_valid(): return Response(serializer.errors, status=500) payload = serializer.validated_data - if ( - "cover_data" in payload - and payload["cover_data"] - and "content" in payload["cover_data"] - ): - payload["cover_data"]["content"] = base64.b64encode( - payload["cover_data"]["content"] - ) + cover_data = payload.get( + "cover_data", payload.get("album", {}).get("cover_data", {}) + ) + if cover_data and "content" in cover_data: + cover_data["content"] = base64.b64encode(cover_data["content"]) return Response(payload, status=200) @action(methods=["post"], detail=False) diff --git a/api/funkwhale_api/static/images/podcasts-cover-placeholder.png b/api/funkwhale_api/static/images/podcasts-cover-placeholder.png new file mode 100644 index 000000000..1f6809a1d Binary files /dev/null and b/api/funkwhale_api/static/images/podcasts-cover-placeholder.png differ diff --git a/api/tests/audio/test_models.py b/api/tests/audio/test_models.py index 8a0d6e4b8..6c5136844 100644 --- a/api/tests/audio/test_models.py +++ b/api/tests/audio/test_models.py @@ -1,3 +1,5 @@ +import pytest + from django.urls import reverse from funkwhale_api.federation import utils as federation_utils @@ -15,7 +17,10 @@ def test_channel(factories, now): def test_channel_get_rss_url_local(factories): channel = factories["audio.Channel"](artist__local=True) expected = federation_utils.full_url( - reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + reverse( + "api:v1:channels-rss", + kwargs={"composite": channel.actor.preferred_username}, + ) ) assert channel.get_rss_url() == expected @@ -23,3 +28,15 @@ def test_channel_get_rss_url_local(factories): def test_channel_get_rss_url_remote(factories): channel = factories["audio.Channel"]() assert channel.get_rss_url() == channel.rss_url + + +def test_channel_delete(factories): + channel = factories["audio.Channel"]() + library = channel.library + actor = channel.library + artist = channel.artist + channel.delete() + + for obj in [library, actor, artist]: + with pytest.raises(obj.DoesNotExist): + obj.refresh_from_db() diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py index 0d664969d..901de126b 100644 --- a/api/tests/audio/test_serializers.py +++ b/api/tests/audio/test_serializers.py @@ -3,6 +3,8 @@ import datetime import pytest import pytz +from django.contrib.staticfiles.templatetags.staticfiles import static + from funkwhale_api.audio import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils @@ -11,20 +13,21 @@ from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import serializers as music_serializers -def test_channel_serializer_create(factories): +def test_channel_serializer_create(factories, mocker): attributed_to = factories["federation.Actor"](local=True) - + attachment = factories["common.Attachment"](actor=attributed_to) + request = mocker.Mock(user=mocker.Mock(actor=attributed_to)) data = { - # TODO: cover "name": "My channel", "username": "mychannel", "description": {"text": "This is my channel", "content_type": "text/markdown"}, "tags": ["hello", "world"], "content_category": "other", + "cover": attachment.uuid, } serializer = serializers.ChannelCreateSerializer( - data=data, context={"actor": attributed_to} + data=data, context={"actor": attributed_to, "request": request} ) assert serializer.is_valid(raise_exception=True) is True @@ -37,14 +40,12 @@ def test_channel_serializer_create(factories): == data["tags"] ) assert channel.artist.description.text == data["description"]["text"] + assert channel.artist.attachment_cover == attachment assert channel.artist.content_category == data["content_category"] assert ( channel.artist.description.content_type == data["description"]["content_type"] ) assert channel.attributed_to == attributed_to - assert channel.actor.summary == common_utils.render_html( - data["description"]["text"], "text/markdown" - ) assert channel.actor.preferred_username == data["username"] assert channel.actor.name == data["name"] assert channel.library.privacy_level == "everyone" @@ -150,24 +151,31 @@ def test_channel_serializer_create_podcast(factories): assert channel.metadata == data["metadata"] -def test_channel_serializer_update(factories): - channel = factories["audio.Channel"](artist__set_tags=["rock"]) - +def test_channel_serializer_update(factories, mocker): + channel = factories["audio.Channel"]( + artist__set_tags=["rock"], attributed_to__local=True + ) + attributed_to = channel.attributed_to + attachment = factories["common.Attachment"](actor=attributed_to) + request = mocker.Mock(user=mocker.Mock(actor=attributed_to)) data = { - # TODO: cover "name": "My channel", "description": {"text": "This is my channel", "content_type": "text/markdown"}, "tags": ["hello", "world"], "content_category": "other", + "cover": attachment.uuid, } - serializer = serializers.ChannelUpdateSerializer(channel, data=data) + serializer = serializers.ChannelUpdateSerializer( + channel, data=data, context={"request": request} + ) assert serializer.is_valid(raise_exception=True) is True serializer.save() channel.refresh_from_db() assert channel.artist.name == data["name"] + assert channel.artist.attachment_cover == attachment assert channel.artist.content_category == data["content_category"] assert ( sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) @@ -281,7 +289,7 @@ def test_rss_item_serializer(factories): { "url": federation_utils.full_url(upload.get_listen_url("mp3")), "length": upload.size, - "type": upload.mimetype, + "type": "audio/mpeg", } ], } @@ -350,6 +358,30 @@ def test_rss_channel_serializer(factories): assert serializers.rss_serialize_channel(channel) == expected +def test_rss_channel_serializer_placeholder_image(factories): + description = factories["common.Content"]() + channel = factories["audio.Channel"]( + artist__set_tags=["pop", "rock"], + artist__description=description, + artist__attachment_cover=None, + ) + setattr( + channel.artist, + "_prefetched_tagged_items", + channel.artist.tagged_items.order_by("tag__name"), + ) + + expected = [ + { + "href": federation_utils.full_url( + static("images/podcasts-cover-placeholder.png") + ) + } + ] + + assert serializers.rss_serialize_channel(channel)["itunes:image"] == expected + + def test_serialize_full_channel(factories): channel = factories["audio.Channel"]() upload1 = factories["music.Upload"](playable=True) diff --git a/api/tests/audio/test_spa_views.py b/api/tests/audio/test_spa_views.py index 9bb96807a..265aea0a3 100644 --- a/api/tests/audio/test_spa_views.py +++ b/api/tests/audio/test_spa_views.py @@ -1,3 +1,5 @@ +import pytest + import urllib.parse from django.urls import reverse @@ -7,18 +9,21 @@ from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import serializers -def test_library_artist(spa_html, no_api_auth, client, factories, settings): +@pytest.mark.parametrize("attribute", ["uuid", "actor.full_username"]) +def test_channel_detail(attribute, spa_html, no_api_auth, client, factories, settings): channel = factories["audio.Channel"]() factories["music.Upload"](playable=True, library=channel.library) - url = "/channels/{}".format(channel.uuid) + url = "/channels/{}".format(utils.recursive_getattr(channel, attribute)) + detail_url = "/channels/{}".format(channel.actor.full_username) response = client.get(url) + assert response.status_code == 200 expected_metas = [ { "tag": "meta", "property": "og:url", - "content": utils.join_url(settings.FUNKWHALE_URL, url), + "content": utils.join_url(settings.FUNKWHALE_URL, detail_url), }, {"tag": "meta", "property": "og:title", "content": channel.artist.name}, {"tag": "meta", "property": "og:type", "content": "profile"}, @@ -47,7 +52,9 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings): "href": ( utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + "?format=json&url={}".format( - urllib.parse.quote_plus(utils.join_url(settings.FUNKWHALE_URL, url)) + urllib.parse.quote_plus( + utils.join_url(settings.FUNKWHALE_URL, detail_url) + ) ) ), }, diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py index 335058f85..4a762c6f1 100644 --- a/api/tests/audio/test_views.py +++ b/api/tests/audio/test_views.py @@ -3,8 +3,11 @@ import pytest from django.urls import reverse +from funkwhale_api.audio import categories from funkwhale_api.audio import serializers from funkwhale_api.audio import views +from funkwhale_api.common import locales +from funkwhale_api.common import utils def test_channel_create(logged_in_api_client): @@ -38,15 +41,25 @@ def test_channel_create(logged_in_api_client): == data["tags"] ) assert channel.attributed_to == actor - assert channel.actor.summary == channel.artist.description.rendered + assert channel.artist.description.text == data["description"]["text"] + assert ( + channel.artist.description.content_type == data["description"]["content_type"] + ) assert channel.actor.preferred_username == data["username"] assert channel.library.privacy_level == "everyone" assert channel.library.actor == actor -def test_channel_detail(factories, logged_in_api_client): - channel = factories["audio.Channel"](artist__description=None) - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) +@pytest.mark.parametrize( + "field", ["uuid", "actor.preferred_username", "actor.full_username"], +) +def test_channel_detail(field, factories, logged_in_api_client): + channel = factories["audio.Channel"](artist__description=None, local=True) + + url = reverse( + "api:v1:channels-detail", + kwargs={"composite": utils.recursive_getattr(channel, field)}, + ) setattr(channel.artist, "_tracks_count", 0) setattr(channel.artist, "_prefetched_tagged_items", []) @@ -85,7 +98,7 @@ def test_channel_update(logged_in_api_client, factories): "name": "new name" } - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) response = logged_in_api_client.patch(url, data) assert response.status_code == 200 @@ -101,7 +114,7 @@ def test_channel_update_permission(logged_in_api_client, factories): data = {"name": "new name"} - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) response = logged_in_api_client.patch(url, data) assert response.status_code == 403 @@ -112,7 +125,7 @@ def test_channel_delete(logged_in_api_client, factories, mocker): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"](attributed_to=actor) - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") response = logged_in_api_client.delete(url) @@ -131,7 +144,7 @@ def test_channel_delete_permission(logged_in_api_client, factories): logged_in_api_client.user.create_actor() channel = factories["audio.Channel"]() - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) response = logged_in_api_client.patch(url) assert response.status_code == 403 @@ -151,7 +164,7 @@ def test_channel_views_disabled_via_feature_flag( def test_channel_subscribe(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"](artist__description=None) - url = reverse("api:v1:channels-subscribe", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-subscribe", kwargs={"composite": channel.uuid}) response = logged_in_api_client.post(url) @@ -173,7 +186,7 @@ def test_channel_unsubscribe(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"]() subscription = factories["audio.Subscription"](target=channel.actor, actor=actor) - url = reverse("api:v1:channels-unsubscribe", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-unsubscribe", kwargs={"composite": channel.uuid}) response = logged_in_api_client.post(url) @@ -229,7 +242,7 @@ def test_channel_rss_feed(factories, api_client, preferences): channel=channel, uploads=[upload2, upload1] ) - url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid}) response = api_client.get(url) @@ -242,7 +255,7 @@ def test_channel_rss_feed_remote(factories, api_client, preferences): preferences["common__api_authentication_required"] = False channel = factories["audio.Channel"]() - url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid}) response = api_client.get(url) @@ -253,8 +266,28 @@ def test_channel_rss_feed_authentication_required(factories, api_client, prefere preferences["common__api_authentication_required"] = True channel = factories["audio.Channel"](local=True) - url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid}) response = api_client.get(url) assert response.status_code == 401 + + +def test_channel_metadata_choices(factories, api_client): + + expected = { + "language": [ + {"value": code, "label": name} for code, name in locales.ISO_639_CHOICES + ], + "itunes_category": [ + {"value": code, "label": code, "children": children} + for code, children in categories.ITUNES_CATEGORIES.items() + ], + } + + url = reverse("api:v1:channels-metadata_choices") + + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py index f5b3836ab..5908c60be 100644 --- a/api/tests/common/test_utils.py +++ b/api/tests/common/test_utils.py @@ -174,6 +174,17 @@ def test_attach_file_url_fetch(factories, r_mock): assert new_attachment.mimetype == data["mimetype"] +def test_attach_file_attachment(factories, r_mock): + album = factories["music.Album"]() + + data = factories["common.Attachment"]() + utils.attach_file(album, "attachment_cover", data) + + album.refresh_from_db() + + assert album.attachment_cover == data + + def test_attach_file_content(factories, r_mock): album = factories["music.Album"]() diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index 0a07acc58..c96251ecb 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -78,12 +78,21 @@ def test_actor_get_quota(factories): audio_file__data=b"aaaa", ) + # this one is in a channel + channel = factories["audio.Channel"](attributed_to=library.actor) + factories["music.Upload"]( + library=channel.library, + import_status="finished", + audio_file__from_path=None, + audio_file__data=b"aaaaa", + ) + expected = { - "total": 19, + "total": 24, "pending": 1, "skipped": 2, "errored": 3, - "finished": 8, + "finished": 13, "draft": 5, } diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 83f10e5cd..587ccc333 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -117,6 +117,41 @@ def test_inbox_follow_library_autoapprove(factories, mocker): ) +def test_inbox_follow_channel_autoapprove(factories, mocker): + mocked_outbox_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" + ) + + local_actor = factories["users.User"]().create_actor() + remote_actor = factories["federation.Actor"]() + channel = factories["audio.Channel"](attributed_to=local_actor) + ii = factories["federation.InboxItem"](actor=channel.actor) + + payload = { + "type": "Follow", + "id": "https://test.follow", + "actor": remote_actor.fid, + "object": channel.actor.fid, + } + + result = routes.inbox_follow( + payload, + context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, + ) + follow = channel.actor.received_follows.latest("id") + + assert result["object"] == channel.actor + assert result["related_object"] == follow + + assert follow.fid == payload["id"] + assert follow.actor == remote_actor + assert follow.approved is True + + mocked_outbox_dispatch.assert_called_once_with( + {"type": "Accept"}, context={"follow": follow} + ) + + def test_inbox_follow_library_manual_approve(factories, mocker): mocked_outbox_dispatch = mocker.patch( "funkwhale_api.federation.activity.OutboxRouter.dispatch" diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index 485502ac7..2488ebcc8 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -657,6 +657,34 @@ def test_serializer_empty_fields(field_name): assert serializer.validated_data == expected +def test_serializer_strict_mode_false(): + data = {} + expected = { + "artists": [{"name": None, "mbid": None}], + "album": { + "title": "[Unknown Album]", + "mbid": None, + "release_date": None, + "artists": [], + "cover_data": None, + }, + } + serializer = metadata.TrackMetadataSerializer( + data=metadata.FakeMetadata(data), context={"strict": False} + ) + assert serializer.is_valid(raise_exception=True) is True + assert serializer.validated_data == expected + + +def test_serializer_strict_mode_true(): + data = {} + serializer = metadata.TrackMetadataSerializer( + data=metadata.FakeMetadata(data), context={"strict": True} + ) + with pytest.raises(metadata.serializers.ValidationError): + assert serializer.is_valid(raise_exception=True) + + def test_artist_field_featuring(): data = { "artist": "Santana feat. Chris Cornell", diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index f70313f3f..3cffcb814 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -361,6 +361,19 @@ def test_manage_upload_action_relaunch_import(factories, mocker): assert m.call_count == 3 +def test_manage_upload_action_publish(factories, mocker): + m = mocker.patch("funkwhale_api.common.utils.on_commit") + + draft = factories["music.Upload"](import_status="draft") + s = serializers.UploadActionSerializer(queryset=None) + + s.handle_publish(models.Upload.objects.all()) + + draft.refresh_from_db() + assert draft.import_status == "pending" + m.assert_any_call(tasks.process_upload.delay, upload_id=draft.pk) + + def test_serialize_upload(factories): upload = factories["music.Upload"]() @@ -511,6 +524,18 @@ def test_upload_import_metadata_serializer_full(): assert serializer.validated_data == expected +def test_upload_import_metadata_serializer_channel_checks_owned_album(factories): + channel = factories["audio.Channel"]() + album = factories["music.Album"]() + data = {"title": "hello", "album": album.pk} + serializer = serializers.ImportMetadataSerializer( + data=data, context={"channel": channel} + ) + + with pytest.raises(serializers.serializers.ValidationError): + serializer.is_valid(raise_exception=True) + + def test_upload_with_channel_keeps_import_metadata(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 f140143ec..d5deb8ce6 100644 --- a/api/tests/music/test_spa_views.py +++ b/api/tests/music/test_spa_views.py @@ -7,7 +7,9 @@ from funkwhale_api.music import serializers def test_library_track(spa_html, no_api_auth, client, factories, settings): - upload = factories["music.Upload"](playable=True, track__disc_number=1) + upload = factories["music.Upload"]( + playable=True, track__disc_number=1, track__attachment_cover=None + ) track = upload.track url = "/library/tracks/{}".format(track.pk) diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index bbbc9c745..35ed27694 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -1014,45 +1014,73 @@ def test_get_track_from_import_metadata_with_forced_values(factories, mocker, fa ) +def test_get_track_from_import_metadata_with_forced_values_album( + factories, mocker, faker +): + channel = factories["audio.Channel"]() + album = factories["music.Album"](artist=channel.artist) + + forced_values = { + "title": "Real title", + "album": album.pk, + } + upload = factories["music.Upload"]( + import_metadata=forced_values, library=channel.library, track=None + ) + tasks.process_upload(upload_id=upload.pk) + upload.refresh_from_db() + assert upload.import_status == "finished" + + assert upload.track.title == forced_values["title"] + assert upload.track.album == album + assert upload.track.artist == channel.artist + + def test_process_channel_upload_forces_artist_and_attributed_to( factories, mocker, faker ): - track = factories["music.Track"]() - channel = factories["audio.Channel"]() + channel = factories["audio.Channel"](attributed_to__local=True) + attachment = factories["common.Attachment"](actor=channel.attributed_to) import_metadata = { "title": "Real title", "position": 3, "copyright": "Real copyright", "tags": ["hello", "world"], + "description": "my description", + "cover": attachment.uuid, } - expected_forced_values = import_metadata.copy() expected_forced_values["artist"] = channel.artist - expected_forced_values["attributed_to"] = channel.attributed_to + expected_forced_values["cover"] = attachment upload = factories["music.Upload"]( track=None, import_metadata=import_metadata, library=channel.library ) - get_track_from_import_metadata = mocker.patch.object( - tasks, "get_track_from_import_metadata", return_value=track - ) + get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata") tasks.process_upload(upload_id=upload.pk) upload.refresh_from_db() - serializer = tasks.metadata.TrackMetadataSerializer( - data=tasks.metadata.Metadata(upload.get_audio_file()) - ) - assert serializer.is_valid() is True - audio_metadata = serializer.validated_data expected_final_metadata = tasks.collections.ChainMap( - {"upload_source": None}, audio_metadata, {"funkwhale": {}}, + {"upload_source": None}, expected_forced_values, {"funkwhale": {}}, ) assert upload.import_status == "finished" get_track_from_import_metadata.assert_called_once_with( - expected_final_metadata, **expected_forced_values + expected_final_metadata, + attributed_to=channel.attributed_to, + **expected_forced_values ) + assert upload.track.description.content_type == "text/markdown" + assert upload.track.description.text == import_metadata["description"] + assert upload.track.title == import_metadata["title"] + assert upload.track.position == import_metadata["position"] + assert upload.track.copyright == import_metadata["copyright"] + assert upload.track.get_tags() == import_metadata["tags"] + assert upload.track.artist == channel.artist + assert upload.track.attributed_to == channel.attributed_to + assert upload.track.attachment_cover == attachment + def test_process_upload_uses_import_metadata_if_valid(factories, mocker): track = factories["music.Track"]() diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 6df25ea63..74fa3f6f8 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -723,6 +723,7 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f "source": "upload://test", "import_reference": "test", "library": library.uuid, + "import_metadata": '{"title": "foo"}', }, ) @@ -735,6 +736,38 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f assert upload.source == "upload://test" assert upload.import_reference == "test" assert upload.import_status == "pending" + assert upload.import_metadata == {"title": "foo"} + assert upload.track is None + m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk) + + +def test_user_can_create_upload_in_channel( + logged_in_api_client, factories, mocker, audio_file +): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + url = reverse("api:v1:uploads-list") + m = mocker.patch("funkwhale_api.common.utils.on_commit") + album = factories["music.Album"](artist=channel.artist) + response = logged_in_api_client.post( + url, + { + "audio_file": audio_file, + "source": "upload://test", + "import_reference": "test", + "channel": channel.uuid, + "import_metadata": '{"title": "foo", "album": ' + str(album.pk) + "}", + }, + ) + + assert response.status_code == 201 + + upload = channel.library.uploads.latest("id") + + assert upload.source == "upload://test" + assert upload.import_reference == "test" + assert upload.import_status == "pending" + assert upload.import_metadata == {"title": "foo", "album": album.pk} assert upload.track is None m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk) @@ -1318,3 +1351,106 @@ def test_detail_includes_description_key( response = logged_in_api_client.get(url) assert response.data["description"] is None + + +def test_channel_owner_can_create_album(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + attachment = factories["common.Attachment"](actor=actor) + url = reverse("api:v1:albums-list") + + data = { + "artist": channel.artist.pk, + "cover": attachment.uuid, + "title": "Hello world", + "release_date": "2019-01-02", + "tags": ["Hello", "World"], + "description": {"content_type": "text/markdown", "text": "hello world"}, + } + + response = logged_in_api_client.post(url, data, format="json") + + assert response.status_code == 201 + + album = channel.artist.albums.get(title=data["title"]) + + assert ( + response.data + == serializers.AlbumSerializer(album, context={"description": True}).data + ) + assert album.attachment_cover == attachment + assert album.attributed_to == actor + assert album.release_date == datetime.date(2019, 1, 2) + assert album.get_tags() == ["Hello", "World"] + assert album.description.content_type == "text/markdown" + assert album.description.text == "hello world" + + +def test_channel_owner_can_delete_album(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + album = factories["music.Album"](artist=channel.artist) + url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + with pytest.raises(album.DoesNotExist): + album.refresh_from_db() + + +def test_other_user_cannot_create_album(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + attachment = factories["common.Attachment"](actor=actor) + url = reverse("api:v1:albums-list") + + data = { + "artist": channel.artist.pk, + "cover": attachment.uuid, + "title": "Hello world", + "release_date": "2019-01-02", + "tags": ["Hello", "World"], + "description": {"content_type": "text/markdown", "text": "hello world"}, + } + + response = logged_in_api_client.post(url, data, format="json") + + assert response.status_code == 400 + + +def test_other_user_cannot_delete_album(factories, logged_in_api_client): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + album = factories["music.Album"](artist=channel.artist) + url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 404 + album.refresh_from_db() + + +def test_channel_owner_can_delete_track(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + track = factories["music.Track"](artist=channel.artist) + url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + with pytest.raises(track.DoesNotExist): + track.refresh_from_db() + + +def test_other_user_cannot_delete_track(factories, logged_in_api_client): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + track = factories["music.Track"](artist=channel.artist) + url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 404 + track.refresh_from_db() diff --git a/front/package.json b/front/package.json index d05c57d10..45b96fe41 100644 --- a/front/package.json +++ b/front/package.json @@ -16,7 +16,7 @@ "axios": "^0.18.0", "diff": "^4.0.1", "django-channels": "^1.1.6", - "fomantic-ui-css": "^2.7", + "fomantic-ui-css": "^2.8.3", "howler": "^2.0.14", "js-logger": "^1.4.1", "jwt-decode": "^2.2.0", diff --git a/front/src/App.vue b/front/src/App.vue index f53cc3a4a..6900ccaf8 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,5 +1,5 @@ diff --git a/front/src/components/ShortcutsModal.vue b/front/src/components/ShortcutsModal.vue index 097672f2c..8f3f41bab 100644 --- a/front/src/components/ShortcutsModal.vue +++ b/front/src/components/ShortcutsModal.vue @@ -36,7 +36,7 @@
-
Close
+
Close
diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue index 6cb1ae416..8adbb9d2c 100644 --- a/front/src/components/audio/ChannelCard.vue +++ b/front/src/components/audio/ChannelCard.vue @@ -1,13 +1,13 @@