From e59cc33378f1bf2e71654def0d5d03e0e6b8ef93 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 23 Feb 2020 15:31:03 +0100 Subject: [PATCH] First round of improvements to channel management: - use modals - less proeminent button - field styling/labels --- api/config/settings/common.py | 2 + api/config/spa_urls.py | 7 +- api/funkwhale_api/audio/models.py | 21 +- api/funkwhale_api/audio/serializers.py | 25 +- api/funkwhale_api/audio/spa_views.py | 35 +- api/funkwhale_api/audio/views.py | 44 +- api/funkwhale_api/common/middleware.py | 14 + api/funkwhale_api/common/mixins.py | 34 ++ api/funkwhale_api/common/models.py | 5 +- api/funkwhale_api/common/utils.py | 51 +- api/funkwhale_api/federation/models.py | 2 + api/funkwhale_api/federation/serializers.py | 6 +- api/funkwhale_api/federation/views.py | 2 +- api/funkwhale_api/music/filters.py | 3 + api/funkwhale_api/music/licenses.py | 4 +- api/funkwhale_api/music/metadata.py | 32 +- api/funkwhale_api/music/models.py | 6 +- api/funkwhale_api/music/serializers.py | 142 ++++- api/funkwhale_api/music/spa_views.py | 31 +- api/funkwhale_api/music/tasks.py | 238 ++++---- api/funkwhale_api/music/views.py | 39 +- .../images/podcasts-cover-placeholder.png | Bin 0 -> 24245 bytes api/tests/audio/test_models.py | 19 +- api/tests/audio/test_serializers.py | 58 +- api/tests/audio/test_spa_views.py | 15 +- api/tests/audio/test_views.py | 59 +- api/tests/common/test_utils.py | 11 + api/tests/federation/test_models.py | 13 +- api/tests/federation/test_routes.py | 35 ++ api/tests/music/test_metadata.py | 28 + api/tests/music/test_serializers.py | 25 + api/tests/music/test_spa_views.py | 4 +- api/tests/music/test_tasks.py | 56 +- api/tests/music/test_views.py | 136 +++++ front/package.json | 2 +- front/src/App.vue | 11 +- front/src/EmbedFrame.vue | 4 +- front/src/components/SetInstanceModal.vue | 2 +- front/src/components/ShortcutsModal.vue | 2 +- front/src/components/audio/ChannelCard.vue | 15 +- .../src/components/audio/ChannelEntryCard.vue | 5 +- front/src/components/audio/ChannelForm.vue | 299 ++++++++++ front/src/components/audio/PlayButton.vue | 30 +- front/src/components/auth/Settings.vue | 13 +- .../src/components/auth/SubsonicTokenForm.vue | 6 +- front/src/components/channels/AlbumForm.vue | 71 +++ front/src/components/channels/AlbumModal.vue | 48 ++ front/src/components/channels/AlbumSelect.vue | 49 ++ .../src/components/channels/LicenseSelect.vue | 69 +++ front/src/components/channels/UploadForm.vue | 528 ++++++++++++++++++ .../channels/UploadMetadataForm.vue | 72 +++ front/src/components/channels/UploadModal.vue | 119 ++++ front/src/components/common/ActionTable.vue | 1 - .../src/components/common/AttachmentInput.vue | 52 +- front/src/components/common/CopyInput.vue | 6 +- .../src/components/common/DangerousButton.vue | 7 +- .../src/components/federation/FetchButton.vue | 2 +- front/src/components/library/AlbumBase.vue | 30 +- front/src/components/library/Artists.vue | 1 + front/src/components/library/EditCard.vue | 2 +- front/src/components/library/EditForm.vue | 5 +- front/src/components/library/FileUpload.vue | 4 +- .../components/library/FileUploadWidget.vue | 16 +- front/src/components/library/TagsSelector.vue | 17 +- front/src/components/library/TrackBase.vue | 46 +- .../src/components/library/radios/Filter.vue | 2 +- .../manage/moderation/InstancePolicyForm.vue | 2 +- .../manage/moderation/NotesThread.vue | 3 +- .../manage/moderation/ReportCard.vue | 1 - front/src/components/mixins/Translations.vue | 4 + .../src/components/moderation/FilterModal.vue | 2 +- .../src/components/moderation/ReportModal.vue | 2 +- front/src/components/playlists/Editor.vue | 2 +- .../components/playlists/PlaylistModal.vue | 4 +- front/src/components/semantic/Modal.vue | 4 +- front/src/lodash.js | 1 + front/src/main.js | 26 +- front/src/router/index.js | 3 + front/src/store/channels.js | 23 +- front/src/store/ui.js | 35 ++ front/src/style/_main.scss | 121 +++- front/src/style/vendor/_media.scss | 3 + front/src/utils.js | 27 + front/src/views/admin/library/AlbumDetail.vue | 2 +- .../src/views/admin/library/ArtistDetail.vue | 2 +- .../src/views/admin/library/LibraryDetail.vue | 2 +- front/src/views/admin/library/TagDetail.vue | 2 +- front/src/views/admin/library/TrackDetail.vue | 2 +- .../src/views/admin/library/UploadDetail.vue | 2 +- front/src/views/auth/ProfileActivity.vue | 10 +- front/src/views/auth/ProfileBase.vue | 117 ++-- front/src/views/auth/ProfileOverview.vue | 64 ++- front/src/views/channels/DetailBase.vue | 171 +++++- front/src/views/channels/DetailOverview.vue | 171 +++++- front/src/views/content/Home.vue | 34 +- .../views/content/libraries/FilesTable.vue | 9 +- front/src/views/content/libraries/Form.vue | 2 +- front/src/views/content/libraries/Quota.vue | 11 +- front/src/views/content/remote/Card.vue | 1 - front/src/views/playlists/Detail.vue | 4 +- front/src/views/radios/Detail.vue | 4 +- front/tests/unit/specs/utils.spec.js | 32 ++ front/yarn.lock | 13 +- 103 files changed, 3205 insertions(+), 451 deletions(-) create mode 100644 api/funkwhale_api/common/mixins.py create mode 100644 api/funkwhale_api/static/images/podcasts-cover-placeholder.png create mode 100644 front/src/components/audio/ChannelForm.vue create mode 100644 front/src/components/channels/AlbumForm.vue create mode 100644 front/src/components/channels/AlbumModal.vue create mode 100644 front/src/components/channels/AlbumSelect.vue create mode 100644 front/src/components/channels/LicenseSelect.vue create mode 100644 front/src/components/channels/UploadForm.vue create mode 100644 front/src/components/channels/UploadMetadataForm.vue create mode 100644 front/src/components/channels/UploadModal.vue create mode 100644 front/tests/unit/specs/utils.spec.js 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 0000000000000000000000000000000000000000..1f6809a1d0e8e60a41a0a578e4a60d49d401f86f GIT binary patch literal 24245 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 -
+
+ @@ -57,6 +58,7 @@ export default { Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"), Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"), PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"), + ChannelUploadModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/channels/UploadModal"), Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"), AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"), ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"), @@ -393,6 +395,13 @@ export default { top: 0; } } +.dimmed { + .ui.bottom-player { + @include media("
{{ track.title }}
{{ track.artist.name }}
- +
{{ track.album.title }}
{{ time.durationFormatted(track.sources[0].duration) }} @@ -236,7 +236,7 @@ export default { this.fetchTracks({channel: id, playable: true, include_channels: 'true', ordering: "-creation_date"}) } if (type === 'artist') { - this.fetchTracks({artist: id, playable: true, ordering: "-release_date,disc_number,position"}) + this.fetchTracks({artist: id, playable: true, include_channels: 'true', ordering: "-release_date,disc_number,position"}) } if (type === 'playlist') { this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`) diff --git a/front/src/components/SetInstanceModal.vue b/front/src/components/SetInstanceModal.vue index 07be79f43..a5ff00f8a 100644 --- a/front/src/components/SetInstanceModal.vue +++ b/front/src/components/SetInstanceModal.vue @@ -35,7 +35,7 @@
-
Cancel
+
Cancel
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 @@