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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 IwdF -
+
+ @@ -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 @@