diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 45854515cdf7ba53705841701033749249052611..dea9cbbe0faa08a6462a382bd21a48b32a17b581 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 19aefa2ed753bf5375b779ae93ed2c87582fd064..730fcc298c7225e1ed7c2b55afff1aa152864a8d 100644 --- a/api/config/spa_urls.py +++ b/api/config/spa_urls.py @@ -23,7 +23,12 @@ urlpatterns = [ ), urls.re_path( r"^channels/(?P<uuid>[0-9a-f-]+)/?$", - audio_spa_views.channel_detail, + audio_spa_views.channel_detail_uuid, + name="channel_detail", + ), + urls.re_path( + r"^channels/(?P<username>[^/]+)/?$", + 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 e95bb5d66ff0b3b3987ecf6c9a5389c746fb50aa..30811383628a08a8aa62f0ff431b6561fdc3aec6 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 df98bde3263a171ba94b6a156e46224f67938205..a162ae9c041722f4a7bceef90ba951d3d8a62a98 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 097e00cf43828dd9b08b9243b87b8d275610f744..c76669d39ece58f31983d89deaaf2be35ed92184 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 6d463d3693650fda9c56a842a67dd27a1ac1358f..974797c353809993f082d6ce43ff9f0089b02213 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 9e2a59dca25d289953dff7b32de0ddba8a0745ef..6122adbcab26c91a0657494a8c2ee7a25db55831 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 0000000000000000000000000000000000000000..ed619d637ce39f2d623c05a232c9a520c7e144b3 --- /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 ec2c1d1f83975210d0e2698ecf40ad96622c33ac..35f64406e296cc7a54ab7098127c067a70847567 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 7af83be94d5d185911a32f6de1d7dfad9d831625..b36d1cd641be1b5b88e6eabd7c409e71739a16a5 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) - - 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() + 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 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 c200e4f6efd6e70f9521bea38707d4c19153a264..820c93bae1d55fce1a63fe95f5d0f84f9c953653 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 351e1ac79a14808ee7d40f4d63a530b0b21dd543..2e769f38a64b75b9cd2f2423d6b355c7b0435acc 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 11840e52c4c57243e62b4d95e9b1a977ab380e9f..c20d792bc547069e24569d73cf186849b256859c 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 484a078f79fc930744b8b798c2f82333bf16ae8e..2395a8381442f16a2f7c8eaf9459c65d47633cfe 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 5690a912f394edcc76ab7fe9091dd91c6ad74112..599d6a7dad3058e9ec31db8a7fdf1f34862c17cf 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 ee24995e76d60a78dccb4266bf992d9aea365884..4e05580103f15e7282dd04266081f89493775cfb 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 a5bc37275ccaa9aa1a7587a7dc28c4c03b329581..f27ed3d501ef2c6ef4a0ed8014642aa447011ea0 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 4113c3d7a56f3eee70cc73628bb0341eb45ac6f0..7099231bae9b5949d6d23830b175bf6b506e6304 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 e96ce5fee345522adaa9f7c52aa4540c9234d48c..0619166a664d7d028cce756c959f1d5fb48cfa54 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 dce014f15df479a35be8d19d82f9cfa853e176ae..1d53c75fba4248d3b6a39f403e2a419510b2368b 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) + 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 + 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 + 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) + else: + query = Q(title__iexact=album_title, artist=album_artist) + + if album_fid: + query |= Q(fid=album_fid) defaults = { - "name": album_artist_name, - "mbid": album_artist_mbid, - "fid": album_artist_fid, + "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_artist_data.get("attributed_to", attributed_to), + "attributed_to": album_data.get("attributed_to", attributed_to), } - if album_artist_data.get("fdate"): - defaults["creation_date"] = album_artist_data.get("fdate") + if album_data.get("fdate"): + defaults["creation_date"] = album_data.get("fdate") - album_artist, created = get_best_candidate_or_create( - models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] + album, created = get_best_candidate_or_create( + models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"] ) if created: - tags_models.add_tags(album_artist, *album_artist_data.get("tags", [])) + tags_models.add_tags(album, *album_data.get("tags", [])) common_utils.attach_content( - album_artist, "description", album_artist_data.get("description") + album, "description", album_data.get("description") ) common_utils.attach_file( - album_artist, - "attachment_cover", - album_artist_data.get("cover_data"), + album, "attachment_cover", album_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_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") - ) - + 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 8ca2164f6c690a96fe6f49fa797a312f604284a6..47aa60b89ed9a9fa5905fe88eb1c25930c7ce056 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 Binary files /dev/null and b/api/funkwhale_api/static/images/podcasts-cover-placeholder.png differ diff --git a/api/tests/audio/test_models.py b/api/tests/audio/test_models.py index 8a0d6e4b82b4a555669b1643ef0f5592ec2e9d0a..6c513684421f06b816196a95cb4f08f93c32e731 100644 --- a/api/tests/audio/test_models.py +++ b/api/tests/audio/test_models.py @@ -1,3 +1,5 @@ +import pytest + from django.urls import reverse from funkwhale_api.federation import utils as federation_utils @@ -15,7 +17,10 @@ def test_channel(factories, now): def test_channel_get_rss_url_local(factories): channel = factories["audio.Channel"](artist__local=True) expected = federation_utils.full_url( - reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + reverse( + "api:v1:channels-rss", + kwargs={"composite": channel.actor.preferred_username}, + ) ) assert channel.get_rss_url() == expected @@ -23,3 +28,15 @@ def test_channel_get_rss_url_local(factories): def test_channel_get_rss_url_remote(factories): channel = factories["audio.Channel"]() assert channel.get_rss_url() == channel.rss_url + + +def test_channel_delete(factories): + channel = factories["audio.Channel"]() + library = channel.library + actor = channel.library + artist = channel.artist + channel.delete() + + for obj in [library, actor, artist]: + with pytest.raises(obj.DoesNotExist): + obj.refresh_from_db() diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py index 0d664969d91b85a992fcfc12944c6e034660cc77..901de126be5b261b3ea6d13f5cfbf65d4d4259bf 100644 --- a/api/tests/audio/test_serializers.py +++ b/api/tests/audio/test_serializers.py @@ -3,6 +3,8 @@ import datetime import pytest import pytz +from django.contrib.staticfiles.templatetags.staticfiles import static + from funkwhale_api.audio import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils @@ -11,20 +13,21 @@ from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import serializers as music_serializers -def test_channel_serializer_create(factories): +def test_channel_serializer_create(factories, mocker): attributed_to = factories["federation.Actor"](local=True) - + attachment = factories["common.Attachment"](actor=attributed_to) + request = mocker.Mock(user=mocker.Mock(actor=attributed_to)) data = { - # TODO: cover "name": "My channel", "username": "mychannel", "description": {"text": "This is my channel", "content_type": "text/markdown"}, "tags": ["hello", "world"], "content_category": "other", + "cover": attachment.uuid, } serializer = serializers.ChannelCreateSerializer( - data=data, context={"actor": attributed_to} + data=data, context={"actor": attributed_to, "request": request} ) assert serializer.is_valid(raise_exception=True) is True @@ -37,14 +40,12 @@ def test_channel_serializer_create(factories): == data["tags"] ) assert channel.artist.description.text == data["description"]["text"] + assert channel.artist.attachment_cover == attachment assert channel.artist.content_category == data["content_category"] assert ( channel.artist.description.content_type == data["description"]["content_type"] ) assert channel.attributed_to == attributed_to - assert channel.actor.summary == common_utils.render_html( - data["description"]["text"], "text/markdown" - ) assert channel.actor.preferred_username == data["username"] assert channel.actor.name == data["name"] assert channel.library.privacy_level == "everyone" @@ -150,24 +151,31 @@ def test_channel_serializer_create_podcast(factories): assert channel.metadata == data["metadata"] -def test_channel_serializer_update(factories): - channel = factories["audio.Channel"](artist__set_tags=["rock"]) - +def test_channel_serializer_update(factories, mocker): + channel = factories["audio.Channel"]( + artist__set_tags=["rock"], attributed_to__local=True + ) + attributed_to = channel.attributed_to + attachment = factories["common.Attachment"](actor=attributed_to) + request = mocker.Mock(user=mocker.Mock(actor=attributed_to)) data = { - # TODO: cover "name": "My channel", "description": {"text": "This is my channel", "content_type": "text/markdown"}, "tags": ["hello", "world"], "content_category": "other", + "cover": attachment.uuid, } - serializer = serializers.ChannelUpdateSerializer(channel, data=data) + serializer = serializers.ChannelUpdateSerializer( + channel, data=data, context={"request": request} + ) assert serializer.is_valid(raise_exception=True) is True serializer.save() channel.refresh_from_db() assert channel.artist.name == data["name"] + assert channel.artist.attachment_cover == attachment assert channel.artist.content_category == data["content_category"] assert ( sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) @@ -281,7 +289,7 @@ def test_rss_item_serializer(factories): { "url": federation_utils.full_url(upload.get_listen_url("mp3")), "length": upload.size, - "type": upload.mimetype, + "type": "audio/mpeg", } ], } @@ -350,6 +358,30 @@ def test_rss_channel_serializer(factories): assert serializers.rss_serialize_channel(channel) == expected +def test_rss_channel_serializer_placeholder_image(factories): + description = factories["common.Content"]() + channel = factories["audio.Channel"]( + artist__set_tags=["pop", "rock"], + artist__description=description, + artist__attachment_cover=None, + ) + setattr( + channel.artist, + "_prefetched_tagged_items", + channel.artist.tagged_items.order_by("tag__name"), + ) + + expected = [ + { + "href": federation_utils.full_url( + static("images/podcasts-cover-placeholder.png") + ) + } + ] + + assert serializers.rss_serialize_channel(channel)["itunes:image"] == expected + + def test_serialize_full_channel(factories): channel = factories["audio.Channel"]() upload1 = factories["music.Upload"](playable=True) diff --git a/api/tests/audio/test_spa_views.py b/api/tests/audio/test_spa_views.py index 9bb96807acc67dfcb1faf5d41e73ec2e88f09350..265aea0a38cc3e3d0f43137678220026aa894fcb 100644 --- a/api/tests/audio/test_spa_views.py +++ b/api/tests/audio/test_spa_views.py @@ -1,3 +1,5 @@ +import pytest + import urllib.parse from django.urls import reverse @@ -7,18 +9,21 @@ from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import serializers -def test_library_artist(spa_html, no_api_auth, client, factories, settings): +@pytest.mark.parametrize("attribute", ["uuid", "actor.full_username"]) +def test_channel_detail(attribute, spa_html, no_api_auth, client, factories, settings): channel = factories["audio.Channel"]() factories["music.Upload"](playable=True, library=channel.library) - url = "/channels/{}".format(channel.uuid) + url = "/channels/{}".format(utils.recursive_getattr(channel, attribute)) + detail_url = "/channels/{}".format(channel.actor.full_username) response = client.get(url) + assert response.status_code == 200 expected_metas = [ { "tag": "meta", "property": "og:url", - "content": utils.join_url(settings.FUNKWHALE_URL, url), + "content": utils.join_url(settings.FUNKWHALE_URL, detail_url), }, {"tag": "meta", "property": "og:title", "content": channel.artist.name}, {"tag": "meta", "property": "og:type", "content": "profile"}, @@ -47,7 +52,9 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings): "href": ( utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + "?format=json&url={}".format( - urllib.parse.quote_plus(utils.join_url(settings.FUNKWHALE_URL, url)) + urllib.parse.quote_plus( + utils.join_url(settings.FUNKWHALE_URL, detail_url) + ) ) ), }, diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py index 335058f8579f67da5089f8efcb1acbcf91eef576..4a762c6f1bf19b64a9fe5d695009bf1f9a8c859f 100644 --- a/api/tests/audio/test_views.py +++ b/api/tests/audio/test_views.py @@ -3,8 +3,11 @@ import pytest from django.urls import reverse +from funkwhale_api.audio import categories from funkwhale_api.audio import serializers from funkwhale_api.audio import views +from funkwhale_api.common import locales +from funkwhale_api.common import utils def test_channel_create(logged_in_api_client): @@ -38,15 +41,25 @@ def test_channel_create(logged_in_api_client): == data["tags"] ) assert channel.attributed_to == actor - assert channel.actor.summary == channel.artist.description.rendered + assert channel.artist.description.text == data["description"]["text"] + assert ( + channel.artist.description.content_type == data["description"]["content_type"] + ) assert channel.actor.preferred_username == data["username"] assert channel.library.privacy_level == "everyone" assert channel.library.actor == actor -def test_channel_detail(factories, logged_in_api_client): - channel = factories["audio.Channel"](artist__description=None) - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) +@pytest.mark.parametrize( + "field", ["uuid", "actor.preferred_username", "actor.full_username"], +) +def test_channel_detail(field, factories, logged_in_api_client): + channel = factories["audio.Channel"](artist__description=None, local=True) + + url = reverse( + "api:v1:channels-detail", + kwargs={"composite": utils.recursive_getattr(channel, field)}, + ) setattr(channel.artist, "_tracks_count", 0) setattr(channel.artist, "_prefetched_tagged_items", []) @@ -85,7 +98,7 @@ def test_channel_update(logged_in_api_client, factories): "name": "new name" } - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) response = logged_in_api_client.patch(url, data) assert response.status_code == 200 @@ -101,7 +114,7 @@ def test_channel_update_permission(logged_in_api_client, factories): data = {"name": "new name"} - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) response = logged_in_api_client.patch(url, data) assert response.status_code == 403 @@ -112,7 +125,7 @@ def test_channel_delete(logged_in_api_client, factories, mocker): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"](attributed_to=actor) - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") response = logged_in_api_client.delete(url) @@ -131,7 +144,7 @@ def test_channel_delete_permission(logged_in_api_client, factories): logged_in_api_client.user.create_actor() channel = factories["audio.Channel"]() - url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid}) response = logged_in_api_client.patch(url) assert response.status_code == 403 @@ -151,7 +164,7 @@ def test_channel_views_disabled_via_feature_flag( def test_channel_subscribe(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"](artist__description=None) - url = reverse("api:v1:channels-subscribe", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-subscribe", kwargs={"composite": channel.uuid}) response = logged_in_api_client.post(url) @@ -173,7 +186,7 @@ def test_channel_unsubscribe(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"]() subscription = factories["audio.Subscription"](target=channel.actor, actor=actor) - url = reverse("api:v1:channels-unsubscribe", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-unsubscribe", kwargs={"composite": channel.uuid}) response = logged_in_api_client.post(url) @@ -229,7 +242,7 @@ def test_channel_rss_feed(factories, api_client, preferences): channel=channel, uploads=[upload2, upload1] ) - url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid}) response = api_client.get(url) @@ -242,7 +255,7 @@ def test_channel_rss_feed_remote(factories, api_client, preferences): preferences["common__api_authentication_required"] = False channel = factories["audio.Channel"]() - url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid}) response = api_client.get(url) @@ -253,8 +266,28 @@ def test_channel_rss_feed_authentication_required(factories, api_client, prefere preferences["common__api_authentication_required"] = True channel = factories["audio.Channel"](local=True) - url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid}) + url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid}) response = api_client.get(url) assert response.status_code == 401 + + +def test_channel_metadata_choices(factories, api_client): + + expected = { + "language": [ + {"value": code, "label": name} for code, name in locales.ISO_639_CHOICES + ], + "itunes_category": [ + {"value": code, "label": code, "children": children} + for code, children in categories.ITUNES_CATEGORIES.items() + ], + } + + url = reverse("api:v1:channels-metadata_choices") + + response = api_client.get(url) + + assert response.status_code == 200 + assert response.data == expected diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py index f5b3836aba5eb19b15d743ebd3e34f275cd2002e..5908c60bea127cbd9518c20015769a97c015bd2a 100644 --- a/api/tests/common/test_utils.py +++ b/api/tests/common/test_utils.py @@ -174,6 +174,17 @@ def test_attach_file_url_fetch(factories, r_mock): assert new_attachment.mimetype == data["mimetype"] +def test_attach_file_attachment(factories, r_mock): + album = factories["music.Album"]() + + data = factories["common.Attachment"]() + utils.attach_file(album, "attachment_cover", data) + + album.refresh_from_db() + + assert album.attachment_cover == data + + def test_attach_file_content(factories, r_mock): album = factories["music.Album"]() diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index 0a07acc5887b665d2aee817914d8c356d1f3b352..c96251ecb3d9dcfe19bc080f2496f46e8835b412 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -78,12 +78,21 @@ def test_actor_get_quota(factories): audio_file__data=b"aaaa", ) + # this one is in a channel + channel = factories["audio.Channel"](attributed_to=library.actor) + factories["music.Upload"]( + library=channel.library, + import_status="finished", + audio_file__from_path=None, + audio_file__data=b"aaaaa", + ) + expected = { - "total": 19, + "total": 24, "pending": 1, "skipped": 2, "errored": 3, - "finished": 8, + "finished": 13, "draft": 5, } diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index 83f10e5cd402c1d55076a4652a817c96b0705cce..587ccc33323d9135ed9d5918c89d4dd2ccffb79e 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -117,6 +117,41 @@ def test_inbox_follow_library_autoapprove(factories, mocker): ) +def test_inbox_follow_channel_autoapprove(factories, mocker): + mocked_outbox_dispatch = mocker.patch( + "funkwhale_api.federation.activity.OutboxRouter.dispatch" + ) + + local_actor = factories["users.User"]().create_actor() + remote_actor = factories["federation.Actor"]() + channel = factories["audio.Channel"](attributed_to=local_actor) + ii = factories["federation.InboxItem"](actor=channel.actor) + + payload = { + "type": "Follow", + "id": "https://test.follow", + "actor": remote_actor.fid, + "object": channel.actor.fid, + } + + result = routes.inbox_follow( + payload, + context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True}, + ) + follow = channel.actor.received_follows.latest("id") + + assert result["object"] == channel.actor + assert result["related_object"] == follow + + assert follow.fid == payload["id"] + assert follow.actor == remote_actor + assert follow.approved is True + + mocked_outbox_dispatch.assert_called_once_with( + {"type": "Accept"}, context={"follow": follow} + ) + + def test_inbox_follow_library_manual_approve(factories, mocker): mocked_outbox_dispatch = mocker.patch( "funkwhale_api.federation.activity.OutboxRouter.dispatch" diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index 485502ac739975715eb458237afcca219baaa5b2..2488ebcc8dd059ead282ec6137fe1413fc936c79 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -657,6 +657,34 @@ def test_serializer_empty_fields(field_name): assert serializer.validated_data == expected +def test_serializer_strict_mode_false(): + data = {} + expected = { + "artists": [{"name": None, "mbid": None}], + "album": { + "title": "[Unknown Album]", + "mbid": None, + "release_date": None, + "artists": [], + "cover_data": None, + }, + } + serializer = metadata.TrackMetadataSerializer( + data=metadata.FakeMetadata(data), context={"strict": False} + ) + assert serializer.is_valid(raise_exception=True) is True + assert serializer.validated_data == expected + + +def test_serializer_strict_mode_true(): + data = {} + serializer = metadata.TrackMetadataSerializer( + data=metadata.FakeMetadata(data), context={"strict": True} + ) + with pytest.raises(metadata.serializers.ValidationError): + assert serializer.is_valid(raise_exception=True) + + def test_artist_field_featuring(): data = { "artist": "Santana feat. Chris Cornell", diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index f70313f3f98b19f1f917d4437e6fef9f3991812e..3cffcb814412d1dbc1bbd65d391b13f9c0787a7e 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -361,6 +361,19 @@ def test_manage_upload_action_relaunch_import(factories, mocker): assert m.call_count == 3 +def test_manage_upload_action_publish(factories, mocker): + m = mocker.patch("funkwhale_api.common.utils.on_commit") + + draft = factories["music.Upload"](import_status="draft") + s = serializers.UploadActionSerializer(queryset=None) + + s.handle_publish(models.Upload.objects.all()) + + draft.refresh_from_db() + assert draft.import_status == "pending" + m.assert_any_call(tasks.process_upload.delay, upload_id=draft.pk) + + def test_serialize_upload(factories): upload = factories["music.Upload"]() @@ -511,6 +524,18 @@ def test_upload_import_metadata_serializer_full(): assert serializer.validated_data == expected +def test_upload_import_metadata_serializer_channel_checks_owned_album(factories): + channel = factories["audio.Channel"]() + album = factories["music.Album"]() + data = {"title": "hello", "album": album.pk} + serializer = serializers.ImportMetadataSerializer( + data=data, context={"channel": channel} + ) + + with pytest.raises(serializers.serializers.ValidationError): + serializer.is_valid(raise_exception=True) + + def test_upload_with_channel_keeps_import_metadata(factories, uploaded_audio_file): channel = factories["audio.Channel"](attributed_to__local=True) user = channel.attributed_to.user diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py index f140143ec7c4acb74fa1b57123a4ae1a68be4840..d5deb8ce685a1782c5934b49436daf628dc1e4a2 100644 --- a/api/tests/music/test_spa_views.py +++ b/api/tests/music/test_spa_views.py @@ -7,7 +7,9 @@ from funkwhale_api.music import serializers def test_library_track(spa_html, no_api_auth, client, factories, settings): - upload = factories["music.Upload"](playable=True, track__disc_number=1) + upload = factories["music.Upload"]( + playable=True, track__disc_number=1, track__attachment_cover=None + ) track = upload.track url = "/library/tracks/{}".format(track.pk) diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index bbbc9c7458d96022bdd153c10d8cc57997ff0404..35ed276944dac7a922bfa53e9f12677440217244 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -1014,45 +1014,73 @@ def test_get_track_from_import_metadata_with_forced_values(factories, mocker, fa ) -def test_process_channel_upload_forces_artist_and_attributed_to( +def test_get_track_from_import_metadata_with_forced_values_album( factories, mocker, faker ): - track = factories["music.Track"]() channel = factories["audio.Channel"]() + album = factories["music.Album"](artist=channel.artist) + + forced_values = { + "title": "Real title", + "album": album.pk, + } + upload = factories["music.Upload"]( + import_metadata=forced_values, library=channel.library, track=None + ) + tasks.process_upload(upload_id=upload.pk) + upload.refresh_from_db() + assert upload.import_status == "finished" + + assert upload.track.title == forced_values["title"] + assert upload.track.album == album + assert upload.track.artist == channel.artist + + +def test_process_channel_upload_forces_artist_and_attributed_to( + factories, mocker, faker +): + channel = factories["audio.Channel"](attributed_to__local=True) + attachment = factories["common.Attachment"](actor=channel.attributed_to) import_metadata = { "title": "Real title", "position": 3, "copyright": "Real copyright", "tags": ["hello", "world"], + "description": "my description", + "cover": attachment.uuid, } - expected_forced_values = import_metadata.copy() expected_forced_values["artist"] = channel.artist - expected_forced_values["attributed_to"] = channel.attributed_to + expected_forced_values["cover"] = attachment upload = factories["music.Upload"]( track=None, import_metadata=import_metadata, library=channel.library ) - get_track_from_import_metadata = mocker.patch.object( - tasks, "get_track_from_import_metadata", return_value=track - ) + get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata") tasks.process_upload(upload_id=upload.pk) upload.refresh_from_db() - serializer = tasks.metadata.TrackMetadataSerializer( - data=tasks.metadata.Metadata(upload.get_audio_file()) - ) - assert serializer.is_valid() is True - audio_metadata = serializer.validated_data expected_final_metadata = tasks.collections.ChainMap( - {"upload_source": None}, audio_metadata, {"funkwhale": {}}, + {"upload_source": None}, expected_forced_values, {"funkwhale": {}}, ) assert upload.import_status == "finished" get_track_from_import_metadata.assert_called_once_with( - expected_final_metadata, **expected_forced_values + expected_final_metadata, + attributed_to=channel.attributed_to, + **expected_forced_values ) + assert upload.track.description.content_type == "text/markdown" + assert upload.track.description.text == import_metadata["description"] + assert upload.track.title == import_metadata["title"] + assert upload.track.position == import_metadata["position"] + assert upload.track.copyright == import_metadata["copyright"] + assert upload.track.get_tags() == import_metadata["tags"] + assert upload.track.artist == channel.artist + assert upload.track.attributed_to == channel.attributed_to + assert upload.track.attachment_cover == attachment + def test_process_upload_uses_import_metadata_if_valid(factories, mocker): track = factories["music.Track"]() diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 6df25ea634abba206b3965293474357c3d5e838f..74fa3f6f8406f5df1bbbac4c97e6e186f067f330 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -723,6 +723,7 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f "source": "upload://test", "import_reference": "test", "library": library.uuid, + "import_metadata": '{"title": "foo"}', }, ) @@ -735,6 +736,38 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f assert upload.source == "upload://test" assert upload.import_reference == "test" assert upload.import_status == "pending" + assert upload.import_metadata == {"title": "foo"} + assert upload.track is None + m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk) + + +def test_user_can_create_upload_in_channel( + logged_in_api_client, factories, mocker, audio_file +): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + url = reverse("api:v1:uploads-list") + m = mocker.patch("funkwhale_api.common.utils.on_commit") + album = factories["music.Album"](artist=channel.artist) + response = logged_in_api_client.post( + url, + { + "audio_file": audio_file, + "source": "upload://test", + "import_reference": "test", + "channel": channel.uuid, + "import_metadata": '{"title": "foo", "album": ' + str(album.pk) + "}", + }, + ) + + assert response.status_code == 201 + + upload = channel.library.uploads.latest("id") + + assert upload.source == "upload://test" + assert upload.import_reference == "test" + assert upload.import_status == "pending" + assert upload.import_metadata == {"title": "foo", "album": album.pk} assert upload.track is None m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk) @@ -1318,3 +1351,106 @@ def test_detail_includes_description_key( response = logged_in_api_client.get(url) assert response.data["description"] is None + + +def test_channel_owner_can_create_album(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + attachment = factories["common.Attachment"](actor=actor) + url = reverse("api:v1:albums-list") + + data = { + "artist": channel.artist.pk, + "cover": attachment.uuid, + "title": "Hello world", + "release_date": "2019-01-02", + "tags": ["Hello", "World"], + "description": {"content_type": "text/markdown", "text": "hello world"}, + } + + response = logged_in_api_client.post(url, data, format="json") + + assert response.status_code == 201 + + album = channel.artist.albums.get(title=data["title"]) + + assert ( + response.data + == serializers.AlbumSerializer(album, context={"description": True}).data + ) + assert album.attachment_cover == attachment + assert album.attributed_to == actor + assert album.release_date == datetime.date(2019, 1, 2) + assert album.get_tags() == ["Hello", "World"] + assert album.description.content_type == "text/markdown" + assert album.description.text == "hello world" + + +def test_channel_owner_can_delete_album(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + album = factories["music.Album"](artist=channel.artist) + url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + with pytest.raises(album.DoesNotExist): + album.refresh_from_db() + + +def test_other_user_cannot_create_album(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + attachment = factories["common.Attachment"](actor=actor) + url = reverse("api:v1:albums-list") + + data = { + "artist": channel.artist.pk, + "cover": attachment.uuid, + "title": "Hello world", + "release_date": "2019-01-02", + "tags": ["Hello", "World"], + "description": {"content_type": "text/markdown", "text": "hello world"}, + } + + response = logged_in_api_client.post(url, data, format="json") + + assert response.status_code == 400 + + +def test_other_user_cannot_delete_album(factories, logged_in_api_client): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + album = factories["music.Album"](artist=channel.artist) + url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 404 + album.refresh_from_db() + + +def test_channel_owner_can_delete_track(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"](attributed_to=actor) + track = factories["music.Track"](artist=channel.artist) + url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + with pytest.raises(track.DoesNotExist): + track.refresh_from_db() + + +def test_other_user_cannot_delete_track(factories, logged_in_api_client): + logged_in_api_client.user.create_actor() + channel = factories["audio.Channel"]() + track = factories["music.Track"](artist=channel.artist) + url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 404 + track.refresh_from_db() diff --git a/front/package.json b/front/package.json index d05c57d10861009e373e32b1b090ad1b8da1a6e6..45b96fe413785d229d4f1d19365922c2b88f61f7 100644 --- a/front/package.json +++ b/front/package.json @@ -16,7 +16,7 @@ "axios": "^0.18.0", "diff": "^4.0.1", "django-channels": "^1.1.6", - "fomantic-ui-css": "^2.7", + "fomantic-ui-css": "^2.8.3", "howler": "^2.0.14", "js-logger": "^1.4.1", "jwt-decode": "^2.2.0", diff --git a/front/src/App.vue b/front/src/App.vue index f53cc3a4a2cadefc824edf0a1aa0e3f56eac54d6..6900ccaf8dbecf01b6def1dd2df894961987990d 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,5 +1,5 @@ <template> - <div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}]"> + <div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}, `is-${ $store.getters['ui/windowSize']}`]"> <!-- here, we display custom stylesheets, if any --> <link v-for="url in customStylesheets" @@ -33,6 +33,7 @@ @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal" ></app-footer> <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> + <channel-upload-modal v-if="$store.state.auth.authenticated"></channel-upload-modal> <filter-modal v-if="$store.state.auth.authenticated"></filter-modal> <report-modal></report-modal> <shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal> @@ -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("<desktop") { + z-index: 0; + } + } +} #app.queue-focused { .queue-not-focused { @include media("<desktop") { diff --git a/front/src/EmbedFrame.vue b/front/src/EmbedFrame.vue index b302c7e2d5af212109f19e95bb8b1b913e293893..609348899e0c8e811920bb32b7bff9cf636b02be 100644 --- a/front/src/EmbedFrame.vue +++ b/front/src/EmbedFrame.vue @@ -103,7 +103,7 @@ </td> <td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></td> <td class="artist" :title="track.artist.name" ><div class="ellipsis">{{ track.artist.name }}</div></td> - <td class="album"> + <td class="album" v-if="track.album"> <div class="ellipsis " v-if="track.album" :title="track.album.title">{{ track.album.title }}</div> </td> <td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</td> @@ -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 07be79f4339f4b8a7d5cb37b97664e173efae516..a5ff00f8a4f13806ff6a168c3af8d02e7eed628a 100644 --- a/front/src/components/SetInstanceModal.vue +++ b/front/src/components/SetInstanceModal.vue @@ -35,7 +35,7 @@ </form> </div> <div class="actions"> - <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> + <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> </div> </modal> </template> diff --git a/front/src/components/ShortcutsModal.vue b/front/src/components/ShortcutsModal.vue index 097672f2caadce5073a694412ff27a58c9ddb051..8f3f41babf9d7a6f17d478cb260362806b572cfa 100644 --- a/front/src/components/ShortcutsModal.vue +++ b/front/src/components/ShortcutsModal.vue @@ -36,7 +36,7 @@ </div> </section> <footer class="actions"> - <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div> + <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div> </footer> </modal> </template> diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue index 6cb1ae4165ede5954a005eb0e081d853e1366bad..8adbb9d2ce43b55f72b6d10c10b227567b1bec85 100644 --- a/front/src/components/audio/ChannelCard.vue +++ b/front/src/components/audio/ChannelCard.vue @@ -1,13 +1,13 @@ <template> <div class="card app-card"> <div - @click="$router.push({name: 'channels.detail', params: {id: object.uuid}})" - :class="['ui', 'head-image', 'padded image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl"> + @click="$router.push({name: 'channels.detail', params: {id: urlId}})" + :class="['ui', 'head-image', {'circular': object.artist.content_category != 'podcast'}, {'padded': object.artist.content_category === 'podcast'}, 'image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl"> <play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object.artist"></play-button> </div> <div class="content"> <strong> - <router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: object.uuid}}"> + <router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: urlId}}"> {{ object.artist.name }} </router-link> </strong> @@ -49,6 +49,15 @@ export default { return null } return url + }, + urlId () { + if (this.object.actor && this.object.actor.is_local) { + return this.object.actor.preferred_username + } else if (this.object.actor) { + return this.object.actor.full_username + } else { + return this.object.uuid + } } } } diff --git a/front/src/components/audio/ChannelEntryCard.vue b/front/src/components/audio/ChannelEntryCard.vue index 1bdbd6839420f55a63586ec8806a04d663bb08c3..6d1e688bc649e91c690810c642fe53119ca81aac 100644 --- a/front/src/components/audio/ChannelEntryCard.vue +++ b/front/src/components/audio/ChannelEntryCard.vue @@ -1,6 +1,6 @@ <template> <div class="channel-entry-card"> - <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> + <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover && cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png"> <div class="content"> <strong> @@ -39,6 +39,9 @@ export default { return url }, cover () { + if (this.entry.cover) { + return this.entry.cover + } if (this.entry.album && this.entry.album.cover) { return this.entry.album.cover } diff --git a/front/src/components/audio/ChannelForm.vue b/front/src/components/audio/ChannelForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..5f6f59d54a3e4ffccd62e0f17fdcf5c89dc3ba32 --- /dev/null +++ b/front/src/components/audio/ChannelForm.vue @@ -0,0 +1,299 @@ +<template> + <form class="ui form" @submit.prevent.stop="submit"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate translate-context="Content/*/Error message.Title">Error while saving channel</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <template v-if="metadataChoices"> + <div v-if="creating && step === 1" class="ui grouped channel-type required field"> + <label> + <translate translate-context="Content/Channel/Paragraph">What this channel will be used for?</translate> + </label> + <div class="ui hidden divider"></div> + <div class="field"> + <div :class="['ui', 'radio', 'checkbox', {selected: choice.value == newValues.content_category}]" v-for="choice in categoryChoices"> + <input type="radio" name="channel-category" :id="`category-${choice.value}`" :value="choice.value" v-model="newValues.content_category"> + <label :for="`category-${choice.value}`"> + <span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]"></span> + <strong>{{ choice.label }}</strong> + <div class="ui small hidden divider"></div> + {{ choice.helpText }} + </label> + </div> + </div> + </div> + <template v-if="!creating || step === 2"> + <div class="ui required field"> + <label for="channel-name"> + <translate translate-context="Content/Channel/*">Name</translate> + </label> + <input type="text" required v-model="newValues.name" :placeholder="labels.namePlaceholder"> + </div> + <div class="ui required field"> + <label for="channel-username"> + <translate translate-context="Content/Channel/*">Social Network Name</translate> + </label> + <div class="ui left labeled input"> + <div class="ui basic label">@</div> + <input type="text" :required="creating" :disabled="!creating" :placeholder="labels.usernamePlaceholder" v-model="newValues.username"> + </div> + <template v-if="creating"> + <div class="ui small hidden divider"></div> + <p> + <translate translate-context="Content/Channels/Paragraph">Used in URLs and to follow this channel on the federation. You cannot change it afterwards.</translate> + </p> + </template> + </div> + <div class="six wide column"> + <attachment-input + v-model="newValues.cover" + :required="false" + :image-class="newValues.content_category === 'podcast' ? '' : 'circular'" + @delete="newValues.cover = null"> + <translate translate-context="Content/Channel/*" slot="label">Channel Picture</translate> + </attachment-input> + + </div> + <div class="ui small hidden divider"></div> + <div class="ui stackable grid row"> + <div class="ten wide column"> + <div class="ui field"> + <label for="channel-tags"> + <translate translate-context="*/*/*">Tags</translate> + </label> + <tags-selector + v-model="newValues.tags" + id="channel-tags" + :required="false"></tags-selector> + </div> + </div> + <div class="six wide column" v-if="newValues.content_category === 'podcast'"> + <div class="ui required field"> + <label for="channel-language"> + <translate translate-context="*/*/*">Language</translate> + </label> + <select + name="channel-language" + id="channel-language" + v-model="newValues.metadata.language" + required + class="ui search selection dropdown"> + <option v-for="v in metadataChoices.language" :value="v.value">{{ v.label }}</option> + </select> + </div> + </div> + </div> + <div class="ui small hidden divider"></div> + <div class="ui field"> + <label for="channel-name"> + <translate translate-context="*/*/*">Description</translate> + </label> + <content-form v-model="newValues.description"></content-form> + </div> + <div class="ui two fields" v-if="newValues.content_category === 'podcast'"> + <div class="ui required field"> + <label for="channel-itunes-category"> + <translate translate-context="*/*/*">Category</translate> + </label> + <select + name="itunes-category" + id="itunes-category" + v-model="newValues.metadata.itunes_category" + required + class="ui dropdown"> + <option v-for="v in metadataChoices.itunes_category" :value="v.value">{{ v.label }}</option> + </select> + </div> + <div class="ui field"> + <label for="channel-itunes-category"> + <translate translate-context="*/*/*">Subcategory</translate> + </label> + <select + name="itunes-category" + id="itunes-category" + v-model="newValues.metadata.itunes_subcategory" + :disabled="!newValues.metadata.itunes_category" + class="ui dropdown"> + <option v-for="v in itunesSubcategories" :value="v">{{ v }}</option> + </select> + </div> + </div> + </template> + </template> + <div v-else class="ui active inverted dimmer"> + <div class="ui text loader"> + <translate translate-context="*/*/*">Loading</translate> + </div> + </div> + </form> +</template> + +<script> +import axios from 'axios' + +import AttachmentInput from '@/components/common/AttachmentInput' +import TagsSelector from '@/components/library/TagsSelector' + +function slugify(text) { + return text.toString().toLowerCase() + .replace(/\s+/g, '') // Remove spaces + .replace(/[^\w]+/g, '') // Remove all non-word chars +} + +export default { + props: { + object: {type: Object, required: false, default: null}, + step: {type: Number, required: false, default: 1}, + }, + components: { + AttachmentInput, + TagsSelector + }, + + created () { + this.fetchMetadataChoices() + }, + data () { + let oldValues = {} + if (this.object) { + oldValues.metadata = {...(this.object.metadata || {})} + oldValues.name = this.object.artist.name + oldValues.description = this.object.artist.description + oldValues.cover = this.object.artist.cover + oldValues.tags = this.object.artist.tags + oldValues.content_category = this.object.artist.content_category + oldValues.username = this.object.actor.preferred_username + } + return { + isLoading: false, + errors: [], + metadataChoices: null, + newValues: { + name: oldValues.name || "", + username: oldValues.username || "", + tags: oldValues.tags || [], + description: (oldValues.description || {}).text || "", + cover: (oldValues.cover || {}).uuid || null, + content_category: oldValues.content_category || "podcast", + metadata: oldValues.metadata || {}, + } + } + }, + computed: { + creating () { + return this.object === null + }, + categoryChoices () { + return [ + { + value: "podcast", + label: this.$pgettext('*/*/*', "Podcasts"), + helpText: this.$pgettext('Content/Channels/Help', "Host your episodes and keep your community updated."), + }, + { + value: "music", + label: this.$pgettext('*/*/*', "Artist discography"), + helpText: this.$pgettext('Content/Channels/Help', "Publish music you make as a nice discography of albums and singles."), + } + ] + }, + itunesSubcategories () { + for (let index = 0; index < this.metadataChoices.itunes_category.length; index++) { + const element = this.metadataChoices.itunes_category[index]; + if (element.value === this.newValues.metadata.itunes_category) { + return element.children || [] + } + } + return [] + }, + labels () { + return { + namePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "Awesome channel name"), + usernamePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "awesomechannelname"), + } + }, + submittable () { + let v = this.newValues.name && this.newValues.username + if (this.newValues.content_category === 'podcast') { + v = v && this.newValues.metadata.itunes_category && this.newValues.metadata.language + } + return !!v + } + }, + methods: { + fetchMetadataChoices () { + let self = this + axios.get('channels/metadata-choices').then((response) => { + self.metadataChoices = response.data + }, error => { + self.errors = error.backendErrors + }) + }, + submit () { + this.isLoading = true + let self = this + let handler = this.creating ? axios.post : axios.patch + let url = this.creating ? `channels/` : `channels/${this.object.uuid}` + let payload = { + name: this.newValues.name, + username: this.newValues.username, + tags: this.newValues.tags, + content_category: this.newValues.content_category, + cover: this.newValues.cover, + metadata: this.newValues.metadata, + } + if (this.newValues.description) { + payload.description = { + content_type: 'text/markdown', + text: this.newValues.description, + } + } else { + payload.description = null + } + + handler(url, payload).then((response) => { + self.isLoading = false + if (self.creating) { + self.$emit('created', response.data) + } else { + self.$emit('updated', response.data) + } + }, error => { + self.isLoading = false + self.errors = error.backendErrors + self.$emit('errored', self.errors) + }) + } + }, + watch: { + "newValues.name" (v) { + if (this.creating) { + this.newValues.username = slugify(v) + } + }, + "newValues.metadata.itunes_category" (v) { + this.newValues.metadata.itunes_subcategory = null + }, + "newValues.content_category": { + handler (v) { + this.$emit("category", v) + }, + immediate: true + }, + isLoading: { + handler (v) { + this.$emit("loading", v) + }, + immediate: true + }, + submittable: { + handler (v) { + this.$emit("submittable", v) + }, + immediate: true + }, + } +} +</script> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 2c74746727dc198ca0f66f56ffe3145d4019eae1..a4bb95692803e415a1e43ca683c364de6d2af30d 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,5 +1,5 @@ <template> - <span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}]"> + <span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button']"> <button v-if="!dropdownOnly" :title="labels.playNow" @@ -102,7 +102,9 @@ export default { } if (this.track) { return this.track.uploads && this.track.uploads.length > 0 - } else if (this.artist) { + } else if (this.artist && this.artist.tracks_count) { + return this.artist.tracks_count > 0 + } else if (this.artist && this.artist.albums) { return this.artist.albums.filter((a) => { return a.is_playable === true }).length > 0 @@ -189,10 +191,10 @@ export default { resolve(tracks) }) } else if (self.artist) { - let params = {'artist': self.artist.id, 'ordering': 'album__release_date,disc_number,position'} + let params = {'artist': self.artist.id, include_channels: 'true', 'ordering': 'album__release_date,disc_number,position'} self.getTracksPage(1, params, resolve) } else if (self.album) { - let params = {'album': self.album.id, 'ordering': 'disc_number,position'} + let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'} self.getTracksPage(1, params, resolve) } }) @@ -255,9 +257,27 @@ export default { // works as expected self.$refs[$el.data('ref')].click() jQuery(self.$el).find('.ui.dropdown').dropdown('hide') + }, + }) + jQuery(this.$el).find('.ui.dropdown').dropdown('show', function () { + // little magic to ensure the menu is always visible in the viewport + // By default, try to diplay it on the right if there is enough room + let menu = jQuery(self.$el).find('.ui.dropdown').find(".menu") + let viewportOffset = menu.get(0).getBoundingClientRect(); + let left = viewportOffset.left; + let viewportWidth = document.documentElement.clientWidth + let rightOverflow = viewportOffset.right - viewportWidth + let leftOverflow = -viewportOffset.left + let offset = 0 + if (rightOverflow > 0) { + offset = -rightOverflow - 5 + menu.css({cssText: `left: ${offset}px !important;`}); + } + else if (leftOverflow > 0) { + offset = leftOverflow + 5 + menu.css({cssText: `right: -${offset}px !important;`}); } }) - jQuery(this.$el).find('.ui.dropdown').dropdown('show') }) } } diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index ff1e78b3d09070f68f4a0edc4879fd916e36e026..8ad1b3c140a28288e86de08aeb9b5bfba1b73ece 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -48,7 +48,9 @@ @input="submitAvatar($event)" :initial-value="initialAvatar" :required="false" - @delete="avatar = {uuid: null}"></attachment-input> + @delete="avatar = {uuid: null}"> + <translate translate-context="Content/Channel/*" slot="label">Avatar</translate> + </attachment-input> </div> </section> @@ -79,8 +81,7 @@ <password-input required v-model="new_password" /> </div> <dangerous-button - color="yellow" - :class="['ui', {'loading': isLoading}, 'button']" + :class="['ui', {'loading': isLoading}, 'yellow', 'button']" :action="submitPassword"> <translate translate-context="Content/Settings/Button.Label">Change password</translate> <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Change your password?</translate></p> @@ -177,7 +178,7 @@ </td> <td> <dangerous-button - class="ui tiny basic button" + class="ui tiny basic red button" @confirm="revokeApp(app.client_id)"> <translate translate-context="*/*/*/Verb">Revoke</translate> <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Revoke access for application "%{ application }"?</p> @@ -236,7 +237,7 @@ <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> </router-link> <dangerous-button - class="ui tiny basic button" + class="ui tiny basic red button" @confirm="deleteApp(app.client_id)"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Delete application "%{ application }"?</p> @@ -282,7 +283,7 @@ <password-input required v-model="password" /> </div> <dangerous-button - :class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'button']" + :class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'red', 'button']" :action="deleteAccount"> <translate translate-context="*/*/Button.Label">Delete my account…</translate> <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Do you want to delete your account?</translate></p> diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue index fdd9f5e107b2de27b28cb5099b56751b9be180a6..67621bf8dc2e6001a98def6dc56c3abebd01ad7c 100644 --- a/front/src/components/auth/SubsonicTokenForm.vue +++ b/front/src/components/auth/SubsonicTokenForm.vue @@ -33,8 +33,7 @@ </div> <dangerous-button v-if="token" - color="grey" - :class="['ui', {'loading': isLoading}, 'button']" + :class="['ui', {'loading': isLoading}, 'grey', 'button']" :action="requestNewToken"> <translate translate-context="*/Settings/Button.Label/Verb">Request a new password</translate> <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Request a new Subsonic API password?</translate></p> @@ -48,8 +47,7 @@ @click="requestNewToken"><translate translate-context="Content/Settings/Button.Label/Verb">Request a password</translate></button> <dangerous-button v-if="token" - color="yellow" - :class="['ui', {'loading': isLoading}, 'button']" + :class="['ui', {'loading': isLoading}, 'yellow', 'button']" :action="disable"> <translate translate-context="Content/Settings/Button.Label/Verb">Disable Subsonic access</translate> <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Disable Subsonic API access?</translate></p> diff --git a/front/src/components/channels/AlbumForm.vue b/front/src/components/channels/AlbumForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..e224b7addf0c8f48f816a4abdb545115a7a2f190 --- /dev/null +++ b/front/src/components/channels/AlbumForm.vue @@ -0,0 +1,71 @@ +<template> + <form @submit.stop.prevent :class="['ui', {loading: isLoading}, 'form']"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate translate-context="Content/*/Error message.Title">Error while creating</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="ui required field"> + <label for="album-title"> + <translate translate-context="*/*/*/Noun">Title</translate> + </label> + <input type="text" v-model="values.title"> + </div> + </div> + </form> +</template> +<script> +import axios from 'axios' + +export default { + props: { + channel: {type: Object, required: true}, + }, + components: {}, + data () { + return { + errors: [], + isLoading: false, + values: { + title: "", + }, + } + }, + computed: { + submittable () { + return this.values.title.length > 0 + } + }, + methods: { + + submit () { + let self = this + self.isLoading = true + self.errors = [] + let payload = { + ...this.values, + artist: this.channel.artist.id, + } + return axios.post('albums/', payload).then( + response => { + self.isLoading = false + self.$emit("created") + }, + error => { + self.errors = error.backendErrors + self.isLoading = false + } + ) + } + }, + watch: { + submittable (v) { + this.$emit("submittable", v) + }, + isLoading (v) { + this.$emit("loading", v) + } + } +} +</script> diff --git a/front/src/components/channels/AlbumModal.vue b/front/src/components/channels/AlbumModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..197bafe2a99c0c77a2794ca8a9c60b3a7b6cae5e --- /dev/null +++ b/front/src/components/channels/AlbumModal.vue @@ -0,0 +1,48 @@ +<template> + <modal class="small" :show.sync="show"> + <div class="header"> + <translate key="1" v-if="channel.content_category === 'podcasts'" translate-context="Popup/Channels/Title/Verb">New serie</translate> + <translate key="2" v-else translate-context="Popup/Channels/Title">New album</translate> + </div> + <div class="scrolling content"> + <channel-album-form + ref="albumForm" + @loading="isLoading = $event" + @submittable="submittable = $event" + @created="$emit('created', $event)" + :channel="channel"></channel-album-form> + </div> + <div class="actions"> + <button class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button> + <button :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="!submittable" @click.stop.prevent="$refs.albumForm.submit()"> + <translate translate-context="*/*/Button.Label">Create</translate> + </button> + </div> + </modal> +</template> + +<script> +import Modal from '@/components/semantic/Modal' +import ChannelAlbumForm from '@/components/channels/AlbumForm' + +export default { + props: ['channel'], + components: { + Modal, + ChannelAlbumForm + }, + data () { + return { + isLoading: false, + submittable: false, + show: false, + } + }, + watch: { + show () { + this.isLoading = false + this.submittable = false + } + } +} +</script> diff --git a/front/src/components/channels/AlbumSelect.vue b/front/src/components/channels/AlbumSelect.vue new file mode 100644 index 0000000000000000000000000000000000000000..ed4b493e885937a078bedba3426ec004ea73a614 --- /dev/null +++ b/front/src/components/channels/AlbumSelect.vue @@ -0,0 +1,49 @@ +<template> + <div> + <label for="album-dropdown"> + <translate v-if="channel && channel.artist.content_category === 'podcast'" key="1" translate-context="*/*/*">Serie</translate> + <translate v-else key="2" translate-context="*/*/*">Album</translate> + </label> + <select id="album-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown"> + <option value=""> + <translate translate-context="*/*/*">None</translate> + </option> + <option v-for="album in albums" :key="album.id" :value="album.id"> + {{ album.title }} (<translate translate-context="*/*/*" :translate-params="{count: album.tracks.length}" :translate-n="album.tracks.length" translate-plural="%{ count } tracks">%{ count } track</translate>) + </option> + </select> + </div> +</template> +<script> +import axios from 'axios' + +export default { + props: ['value', 'channel'], + data () { + return { + albums: [], + isLoading: false, + } + }, + async created () { + await this.fetchData() + }, + methods: { + async fetchData () { + this.albums = [] + if (!this.channel) { + return + } + this.isLoading = true + let response = await axios.get('albums/', {params: {artist: this.channel.artist.id, include_channels: 'true'}}) + this.albums = response.data.results + this.isLoading = false + }, + }, + watch: { + async channel () { + await this.fetchData() + } + } +} +</script> diff --git a/front/src/components/channels/LicenseSelect.vue b/front/src/components/channels/LicenseSelect.vue new file mode 100644 index 0000000000000000000000000000000000000000..98317b16c49db614a7ffee215adfac0af2103019 --- /dev/null +++ b/front/src/components/channels/LicenseSelect.vue @@ -0,0 +1,69 @@ +<template> + <div> + <label for="license-dropdown"> + <translate translate-context="Content/*/*/Noun">License</translate> + </label> + <select id="license-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown"> + <option value=""> + <translate translate-context="*/*/*">None</translate> + </option> + <option v-for="l in featuredLicenses" :key="l.code" :value="l.code">{{ l.name }}</option> + </select> + <p class="help" v-if="value"> + <div class="ui very small hidden divider"></div> + <a :href="currentLicense.url" v-if="value" target="_blank" rel="noreferrer noopener"> + <translate translate-context="Content/*/*">About this license</translate> + </a> + </p> + </div> +</template> +<script> +import axios from 'axios' + +export default { + props: ['value'], + data () { + return { + availableLicenses: [], + featuredLicensesIds: [ + 'cc0-1.0', + 'cc-by-4.0', + 'cc-by-sa-4.0', + 'cc-by-nc-4.0', + 'cc-by-nc-sa-4.0', + 'cc-by-nc-nd-4.0', + 'cc-by-nd-4.0', + ], + isLoading: false, + } + }, + async created () { + await this.fetchLicenses() + }, + computed: { + featuredLicenses () { + let self = this + return this.availableLicenses.filter((l) => { + return self.featuredLicensesIds.indexOf(l.code) > -1 + }) + }, + currentLicense () { + let self = this + if (this.value) { + return this.availableLicenses.filter((l) => { + return l.code === self.value + })[0] + + } + } + }, + methods: { + async fetchLicenses () { + this.isLoading = true + let response = await axios.get('licenses/') + this.availableLicenses = response.data.results + this.isLoading = false + }, + }, +} +</script> diff --git a/front/src/components/channels/UploadForm.vue b/front/src/components/channels/UploadForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..95105437869d3be7f0f56567e4322b4fbfe0f38f --- /dev/null +++ b/front/src/components/channels/UploadForm.vue @@ -0,0 +1,528 @@ +<template> + <form @submit.stop.prevent :class="['ui', {loading: isLoadingStep1}, 'form']"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate translate-context="Content/*/Error message.Title">Error while publishing</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div :class="['ui', 'required', {hidden: step > 1}, 'field']"> + <label for="channel-dropdown"> + <translate translate-context="*/*/*">Channel</translate> + </label> + <div id="channel-dropdown" class="ui search normal selection dropdown"> + <div class="text"></div> + <i class="dropdown icon"></i> + </div> + </div> + <album-select v-model.number="values.album" :channel="selectedChannel" :class="['ui', {hidden: step > 1}, 'field']"></album-select> + <license-select v-model="values.license" :class="['ui', {hidden: step > 1}, 'field']"></license-select> + <div :class="['ui', {hidden: step > 1}, 'message']"> + <div class="content"> + <p> + <i class="copyright icon"></i> + <translate translate-context="Content/Channels/Popup.Paragraph">Add a license to your upload to ensure some freedoms to your public.</translate> + </p> + </div> + </div> + <template v-if="step >= 2 && step < 4"> + <div class="ui warning message" v-if="remainingSpace === 0"> + <div class="content"> + <p> + <i class="warning icon"></i> + <translate translate-context="Content/Library/Paragraph">You don't have any space left to upload your files. Please contact the moderators.</translate> + </p> + </div> + </div> + <template v-else> + <div class="ui visible info message" v-if="step === 2 && draftUploads && draftUploads.length > 0 && includeDraftUploads === null"> + <p> + <i class="redo icon"></i> + <translate translate-context="Popup/Channels/Paragraph">You have some draft uploads pending publication.</translate> + </p> + <button @click.stop.prevent="includeDraftUploads = false" class="ui basic button"> + <translate translate-context="*/*/*">Ignore</translate> + </button> + <button @click.stop.prevent="includeDraftUploads = true" class="ui basic button"> + <translate translate-context="*/*/*">Resume</translate> + </button> + </div> + <div v-if="uploadedFiles.length > 0" :class="[{hidden: step === 3}]"> + <div class="channel-file" v-for="(file, idx) in uploadedFiles"> + <div class="content"> + <div role="button" + v-if="file.response.uuid" + @click.stop.prevent="selectedUploadId = file.response.uuid" + class="ui basic icon button" + :title="labels.editTitle"> + <i class="pencil icon"></i> + </div> + <div + v-if="file.error" + @click.stop.prevent="selectedUploadId = file.response.uuid" + class="ui basic red icon label" + :title="file.error"> + <i class="warning sign icon"></i> + </div> + <div v-else-if="file.active" class="ui active slow inline loader"></div> + </div> + <h4 class="ui header"> + <template v-if="file.metadata.title"> + {{ file.metadata.title }} + </template> + <template v-else> + {{ file.name }} + </template> + <div class="sub header"> + <template v-if="file.response.uuid"> + {{ file.size | humanSize }} + <template v-if="file.response.duration"> + · <human-duration :duration="file.response.duration"></human-duration> + </template> + </template> + <template v-else> + <translate key="1" v-if="file.active" translate-context="Channels/*/*">Uploading</translate> + <translate key="2" v-else-if="file.error" translate-context="Channels/*/*">Errored</translate> + <translate key="3" v-else translate-context="Channels/*/*">Pending</translate> + · {{ file.size | humanSize }} + · {{ parseInt(file.progress) }}% + </template> + · <a @click.stop.prevent="remove(file)"> + <translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate> + </a> + <template v-if="file.error"> · + <a @click.stop.prevent="retry(file)"> + <translate translate-context="*/*/*">Retry</translate> + </a> + </template> + </div> + </h4> + </div> + </div> + <upload-metadata-form + :key="selectedUploadId" + v-if="selectedUpload" + :upload="selectedUpload" + :values="uploadImportData[selectedUploadId]" + @values="setDynamic('uploadImportData', selectedUploadId, $event)"></upload-metadata-form> + <div class="ui message" v-if="step === 2"> + <div class="content"> + <p> + <i class="info icon"></i> + <translate translate-context="Content/Library/Paragraph" :translate-params="{extensions: $store.state.ui.supportedExtensions.join(', ')}">Supported extensions: %{ extensions }</translate> + </p> + </div> + </div> + <file-upload-widget + :class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]" + :post-action="uploadUrl" + :multiple="true" + :data="baseImportMetadata" + :drop="true" + :extensions="$store.state.ui.supportedExtensions" + :value="files" + @input="updateFiles" + name="audio_file" + :thread="1" + @input-file="inputFile" + ref="upload"> + <div> + <i class="upload icon"></i> + <translate translate-context="Content/Channels/Paragraph">Drag and drop your files here or open the browser to upload your files</translate> + </div> + <div class="ui very small divider"></div> + <div> + <translate translate-context="*/*/*">Browse…</translate> + </div> + </file-upload-widget> + <div class="ui hidden divider"></div> + </template> + </template> + </form> +</template> +<script> +import axios from 'axios' +import $ from 'jquery' + +import LicenseSelect from '@/components/channels/LicenseSelect' +import AlbumSelect from '@/components/channels/AlbumSelect' +import FileUploadWidget from "@/components/library/FileUploadWidget"; +import UploadMetadataForm from '@/components/channels/UploadMetadataForm' + +function setIfEmpty (obj, k, v) { + if (obj[k] != undefined) { + return + } + obj[k] = v +} + +export default { + props: { + channel: {type: Object, default: null, required: false}, + }, + components: { + AlbumSelect, + LicenseSelect, + FileUploadWidget, + UploadMetadataForm, + }, + data () { + return { + availableChannels: { + results: [], + count: 0, + }, + audioMetadata: {}, + uploadData: {}, + uploadImportData: {}, + draftUploads: null, + files: [], + errors: [], + removed: [], + includeDraftUploads: null, + uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"), + quotaStatus: null, + isLoadingStep1: true, + step: 1, + values: { + channel: (this.channel || {}).uuid, + license: null, + album: null, + }, + selectedUploadId: null, + } + }, + async created () { + this.isLoadingStep1 = true + let p1 = this.fetchChannels() + await p1 + this.isLoadingStep1 = false + this.fetchQuota() + }, + computed: { + labels () { + return { + editTitle: this.$pgettext('Content/*/Button.Label/Verb', 'Edit'), + + } + }, + baseImportMetadata () { + return { + channel: this.values.channel, + import_status: 'draft', + import_metadata: {license: this.values.license, album: this.values.album || null} + } + }, + remainingSpace () { + if (!this.quotaStatus) { + return 0 + } + return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000))) + }, + selectedChannel () { + let self = this + return this.availableChannels.results.filter((c) => { + return c.uuid === self.values.channel + })[0] + }, + selectedUpload () { + let self = this + if (!this.selectedUploadId) { + return null + } + let selected = this.uploadedFiles.filter((f) => { + return f.response && f.response.uuid == self.selectedUploadId + })[0] + return { + ...selected.response, + _fileObj: selected._fileObj + } + }, + uploadedFilesById () { + let data = {} + this.uploadedFiles.forEach((u) => { + data[u.response.uuid] = u + }) + return data + }, + uploadedFiles () { + let self = this + self.uploadData + self.audioMetadata + let files = this.files.map((f) => { + let data = { + ...f, + _fileObj: f, + metadata: {} + } + let metadata = {} + if (f.response && f.response.uuid) { + let uploadImportMetadata = self.uploadImportData[f.response.uuid] || self.uploadData[f.response.uuid].import_metadata + data.metadata = { + ...uploadImportMetadata, + } + data.removed = self.removed.indexOf(f.response.uuid) >= 0 + } + return data + }) + let final = [] + if (this.includeDraftUploads) { + // we have two different objects: draft uploads (so already uploaded in a previous) + // session, and files uploaded in the current session + // so we ensure we have a similar structure for both. + + final = [ + ...this.draftUploads.map((u) => { + return { + response: u, + _fileObj: null, + size: u.size, + progress: 100, + name: u.source.replace('upload://', ''), + active: false, + removed: self.removed.indexOf(u.uuid) >= 0, + metadata: self.uploadImportData[u.uuid] || self.audioMetadata[u.uuid] || u.import_metadata + } + }), + ...files + ] + } else { + final = files + } + return final.filter((f) => { + return !f.removed + }) + }, + summaryData () { + let speed = null + let remaining = null + if (this.activeFile) { + speed = this.activeFile.speed + remaining = parseInt(this.totalSize / speed) + } + return { + totalFiles: this.uploadedFiles.length, + totalSize: this.totalSize, + uploadedSize: this.uploadedSize, + progress: parseInt(this.uploadedSize * 100 / this.totalSize), + canSubmit: !this.activeFile && this.uploadedFiles.length > 0, + speed, + remaining, + quotaStatus: this.quotaStatus, + } + }, + totalSize () { + let total = 0 + this.uploadedFiles.forEach((f) => { + if (!f.error) { + total += f.size + } + }) + return total + }, + uploadedSize () { + let uploaded = 0 + this.uploadedFiles.forEach((f) => { + if (f._fileObj && !f.error) { + uploaded += f.size * (f.progress / 100) + } + }) + return uploaded + }, + activeFile () { + return this.files.filter((f) => { + return f.active + })[0] + } + }, + methods: { + async fetchChannels () { + let response = await axios.get('channels/', {params: {scope: 'me'}}) + this.availableChannels = response.data + }, + async patchUpload (id, data) { + let response = await axios.patch(`uploads/${id}/`, data) + this.uploadData[id] = response.data + this.uploadImportData[id] = response.data.import_metadata + }, + fetchQuota () { + let self = this + axios.get('users/users/me/').then((response) => { + self.quotaStatus = response.data.quota_status + }) + }, + publish () { + let self = this + self.isLoading = true + self.errors = [] + let ids = this.uploadedFiles.map((f) => { + return f.response.uuid + }) + let payload = { + action: 'publish', + objects: ids, + } + return axios.post('uploads/action/', payload).then( + response => { + self.isLoading = false + self.$emit("published", { + uploads: self.uploadedFiles.map((u) => { + return { + ...u.response, + import_status: 'pending', + } + }), + channel: self.selectedChannel}) + }, + error => { + self.errors = error.backendErrors + } + ) + }, + setupChannelsDropdown () { + let self = this + $(this.$el).find('#channel-dropdown').dropdown({ + onChange (value, text, $choice) { + self.values.channel = value + }, + values: this.availableChannels.results.map((c) => { + let d = { + name: c.artist.name, + value: c.uuid, + selected: self.channel && self.channel.uuid === c.uuid, + } + if (c.artist.cover && c.artist.cover.small_square_crop) { + let coverUrl = self.$store.getters['instance/absoluteUrl'](c.artist.cover.small_square_crop) + d.image = coverUrl + if (c.artist.content_category === 'podcast') { + d.imageClass = 'ui image' + } else { + d.imageClass = "ui avatar image" + } + } else { + d.icon = "user" + if (c.artist.content_category === 'podcast') { + d.iconClass = "bordered grey icon" + } else { + d.iconClass = "circular grey icon" + + } + } + return d + }) + }) + $(this.$el).find('#channel-dropdown').dropdown('hide') + }, + inputFile(newFile, oldFile) { + if (!newFile) { + return + } + if (this.remainingSpace < newFile.size / (1000 * 1000)) { + newFile.error = 'denied' + } else { + this.$refs.upload.active = true; + } + }, + fetchAudioMetadata (uuid) { + let self = this + self.audioMetadata[uuid] = null + axios.get(`uploads/${uuid}/audio-file-metadata/`).then((response) => { + self.setDynamic('audioMetadata', uuid, response.data) + let uploadedFile = self.uploadedFilesById[uuid] + if (uploadedFile._fileObj && uploadedFile.response.import_metadata.title === uploadedFile._fileObj.name.replace(/\.[^/.]+$/, "") && response.data.title) { + // replace existing title deduced from file by the one in audio file metadat, if any + self.uploadImportData[uuid].title = response.data.title + } else { + setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title) + } + setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title) + setIfEmpty(self.uploadImportData[uuid], 'position', response.data.position) + setIfEmpty(self.uploadImportData[uuid], 'tags', response.data.tags) + setIfEmpty(self.uploadImportData[uuid], 'description', (response.data.description || {}).text) + self.patchUpload(uuid, {import_metadata: self.uploadImportData[uuid]}) + }) + }, + setDynamic (objName, key, data) { + // cf https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats + let newData = {} + newData[key] = data + this[objName] = Object.assign({}, this[objName], newData) + }, + updateFiles (value) { + let self = this + this.files = value + this.files.forEach((f) => { + if (f.response && f.response.uuid && self.audioMetadata[f.response.uuid] === undefined) { + self.uploadData[f.response.uuid] = f.response + self.setDynamic('uploadImportData', f.response.uuid, { + ...f.response.import_metadata + }) + self.fetchAudioMetadata(f.response.uuid) + } + }) + }, + async fetchDraftUploads (channel) { + let self = this + this.draftUploads = null + let response = await axios.get('uploads', {params: {import_status: 'draft', channel: channel}}) + this.draftUploads = response.data.results + this.draftUploads.forEach((u) => { + self.uploadImportData[u.uuid] = u.import_metadata + }) + }, + remove (file) { + if (file.response && file.response.uuid) { + axios.delete(`uploads/${file.response.uuid}/`) + this.removed.push(file.response.uuid) + } else { + this.$refs.upload.remove(file) + } + }, + retry (file) { + this.$refs.upload.update(file, {error: '', progress: '0.00'}) + this.$refs.upload.active = true; + + } + }, + watch: { + "availableChannels.results" () { + this.setupChannelsDropdown() + }, + "values.channel": { + async handler (v) { + this.files = [] + if (v) { + await this.fetchDraftUploads(v) + } + }, + immediate: true, + }, + step: { + handler (value) { + this.$emit('step', value) + if (value === 2) { + this.selectedUploadId = null + } + }, + immediate: true, + }, + async selectedUploadId (v, o) { + if (v) { + this.step = 3 + } else { + this.step = 2 + } + if (o) { + await this.patchUpload(o, {import_metadata: this.uploadImportData[o]}) + } + }, + summaryData: { + handler (v) { + this.$emit('status', v) + }, + immediate: true, + + } + } +} +</script> diff --git a/front/src/components/channels/UploadMetadataForm.vue b/front/src/components/channels/UploadMetadataForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..8824e6e48a2cd6293200145a76cafaeafa99d16f --- /dev/null +++ b/front/src/components/channels/UploadMetadataForm.vue @@ -0,0 +1,72 @@ +<template> + <div :class="['ui', {loading: isLoading}, 'form']"> + <div class="ui required field"> + <label for="upload-title"> + <translate translate-context="*/*/*/Noun">Title</translate> + </label> + <input type="text" v-model="newValues.title"> + </div> + <attachment-input + v-model="newValues.cover" + :required="false" + @delete="newValues.cover = null"> + <translate translate-context="Content/Channel/*" slot="label">Track Picture</translate> + </attachment-input> + <div class="ui small hidden divider"></div> + <div class="ui two fields"> + <div class="ui field"> + <label for="upload-tags"> + <translate translate-context="*/*/*/Noun">Tags</translate> + </label> + <tags-selector + v-model="newValues.tags" + id="upload-tags" + :required="false"></tags-selector> + </div> + <div class="ui field"> + <label for="upload-position"> + <translate translate-context="*/*/*/Short, Noun">Position</translate> + </label> + <input type="number" min="1" step="1" v-model="newValues.position"> + </div> + </div> + <div class="ui field"> + <label for="upload-description"> + <translate translate-context="*/*/*">Description</translate> + </label> + <content-form v-model="newValues.description" field-id="upload-description"></content-form> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import TagsSelector from '@/components/library/TagsSelector' +import AttachmentInput from '@/components/common/AttachmentInput' + +export default { + props: ['upload', 'values'], + components: { + TagsSelector, + AttachmentInput + }, + data () { + return { + newValues: {...this.values} || this.upload.import_metadata + } + }, + computed: { + isLoading () { + return !!this.metadata + } + }, + watch: { + newValues: { + handler (v) { + this.$emit('values', v) + }, + immediate: true + }, + } +} +</script> diff --git a/front/src/components/channels/UploadModal.vue b/front/src/components/channels/UploadModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..98733ee45a1d34696efe75ed98ac8ee32962e7d0 --- /dev/null +++ b/front/src/components/channels/UploadModal.vue @@ -0,0 +1,119 @@ +<template> + <modal class="small" @update:show="update" :show="$store.state.channels.showUploadModal"> + <div class="header"> + <translate key="1" v-if="step === 1" translate-context="Popup/Channels/Title/Verb">Publish audio</translate> + <translate key="2" v-else-if="step === 2" translate-context="Popup/Channels/Title">Files to upload</translate> + <translate key="3" v-else-if="step === 3" translate-context="Popup/Channels/Title">Upload details</translate> + <translate key="4" v-else-if="step === 4" translate-context="Popup/Channels/Title">Processing uploads</translate> + </div> + <div class="scrolling content"> + <channel-upload-form + ref="uploadForm" + @step="step = $event" + @loading="isLoading = $event" + @published="$store.commit('channels/publish', $event)" + @status="statusData = $event" + @submittable="submittable = $event" + :channel="$store.state.channels.uploadModalConfig.channel"></channel-upload-form> + </div> + <div class="actions"> + <div class="left floated text left align"> + <template v-if="statusData && step >= 2"> + {{ statusInfo.join(' · ') }} + </template> + <div class="ui very small hidden divider"></div> + <template v-if="statusData && statusData.quotaStatus"> + <translate translate-context="Content/Library/Paragraph">Remaining storage space:</translate> + {{ (statusData.quotaStatus.remaining * 1000 * 1000) - statusData.uploadedSize | humanSize }} + </template> + </div> + <div class="ui hidden clearing divider mobile-only"></div> + <button class="ui basic cancel button" v-if="step === 1"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button> + <button class="ui basic button" v-else-if="step < 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Previous step</translate></button> + <button class="ui basic button" v-else-if="step === 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Update</translate></button> + <button v-if="step === 1" class="ui primary button" @click.stop.prevent="$refs.uploadForm.step += 1"> + <translate translate-context="*/*/Button.Label">Next step</translate> + </button> + <div class="ui primary buttons" v-if="step === 2"> + <button + :class="['ui', 'primary button', {loading: isLoading}]" + type="submit" + :disabled="!statusData || !statusData.canSubmit" + @click.prevent.stop="$refs.uploadForm.publish"> + <translate translate-context="*/Channels/Button.Label">Publish</translate> + </button> + <button class="ui floating dropdown icon button" ref="dropdown" v-dropdown :disabled="!statusData || !statusData.canSubmit"> + <i class="dropdown icon"></i> + <div class="menu"> + <div + role="button" + @click="update(false)" + class="basic item"> + <translate translate-context="Content/*/Button.Label/Verb">Finish later</translate> + </div> + </div> + </button> + </div> + <button class="ui basic cancel button" @click="update(false)" v-if="step === 4"><translate translate-context="*/*/Button.Label/Verb">Close</translate></button> + </div> + </modal> +</template> + +<script> +import Modal from '@/components/semantic/Modal' +import ChannelUploadForm from '@/components/channels/UploadForm' +import {humanSize} from '@/filters' + +export default { + components: { + Modal, + ChannelUploadForm + }, + data () { + return { + step: 1, + isLoading: false, + submittable: true, + statusData: null, + } + }, + methods: { + update (v) { + this.$store.commit('channels/showUploadModal', {show: v}) + }, + }, + computed: { + labels () { + return {} + }, + statusInfo () { + if (!this.statusData) { + return [] + } + let info = [] + if (this.statusData.totalSize) { + info.push(humanSize(this.statusData.totalSize)) + } + if (this.statusData.totalFiles) { + let msg = this.$npgettext('*/*/*', '%{ count } file', '%{ count } files', this.statusData.totalFiles) + info.push( + this.$gettextInterpolate(msg, {count: this.statusData.totalFiles}), + ) + } + if (this.statusData.progress) { + info.push(`${this.statusData.progress}%`) + } + if (this.statusData.speed) { + info.push(`${humanSize(this.statusData.speed)}/s`) + } + return info + + } + }, + watch: { + '$store.state.route.path' () { + this.$store.commit('channels/showUploadModal', {show: false}) + }, + } +} +</script> diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 1a43a5f9ff1ba4ea1e87ff61992458e8c947f0b1..83033138218a2ddaaed94243d6b47278caf7a7cf 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -31,7 +31,6 @@ <dangerous-button v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" :confirm-color="currentAction.confirmColor || 'green'" - color="" @confirm="launchAction"> <translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate> <p slot="modal-header"> diff --git a/front/src/components/common/AttachmentInput.vue b/front/src/components/common/AttachmentInput.vue index 47b3253a25e4975ce7ab30efefe11d190d7d4965..dccb4eaa16a90916331c711b41eb75cb097ec93c 100644 --- a/front/src/components/common/AttachmentInput.vue +++ b/front/src/components/common/AttachmentInput.vue @@ -1,25 +1,33 @@ <template> - <div> + <div class="ui form"> <div v-if="errors.length > 0" class="ui negative message"> <div class="header"><translate translate-context="Content/*/Error message.Title">Your attachment cannot be saved</translate></div> <ul class="list"> <li v-for="error in errors">{{ error }}</li> </ul> </div> - <div class="ui stackable two column grid"> - <div class="column" v-if="value && value === initialValue"> - <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">Current file</translate></h3> - <img class="ui image" v-if="value" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" /> - </div> - <div class="column" v-else-if="attachment"> - <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3> - <img class="ui image" v-if="attachment && attachment.square_crop" :src="$store.getters['instance/absoluteUrl'](attachment.medium_square_crop)" /> - </div> - <div class="column" v-if="!attachment"> - <div class="ui basic segment"> - <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3> - <p><translate translate-context="Content/*/Paragraph">PNG or JPG. At most 5MB. Will be downscaled to 400x400px.</translate></p> - <input class="ui input" ref="attachment" type="file" accept="image/x-png,image/jpeg" @change="submit" /> + <div class="ui field"> + <label :for="attachmentId"> + <slot name="label"></slot> + </label> + <div class="ui stackable grid row"> + <div class="three wide column"> + <img :class="['ui', imageClass, 'image']" v-if="value && value === initialValue" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" /> + <img :class="['ui', imageClass, 'image']" v-else-if="attachment" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${attachment.uuid}/proxy?next=medium_square_crop`)" /> + <div :class="['ui', imageClass, 'static', 'large placeholder image']" v-else></div> + </div> + <div class="eleven wide column"> + <div class="file-input"> + <label class="ui basic button" :for="attachmentId"> + <translate translate-context="*/*/*">Upload New Picture…</translate> + </label> + <input class="ui hidden input" ref="attachment" type="file" :id="attachmentId" accept="image/x-png,image/jpeg" @change="submit" /> + </div> + <div class="ui very small hidden divider"></div> + <p><translate translate-context="Content/*/Paragraph">PNG or JPG. Dimensions should be between 1400x1400px and 3000x3000px. Maximum file size allowed is 5MB.</translate></p> + <div class="ui basic tiny button" v-if="value" @click.stop.prevent="remove(value)"> + <translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate> + </div> <div v-if="isLoading" class="ui active inverted dimmer"> <div class="ui indeterminate text loader"> <translate translate-context="Content/*/*/Noun">Uploading file…</translate> @@ -27,7 +35,6 @@ </div> </div> </div> - </div> </div> </template> @@ -35,12 +42,17 @@ import axios from 'axios' export default { - props: ['value', 'initialValue'], + props: { + value: {}, + imageClass: {default: '', required: false} + }, data () { return { attachment: null, isLoading: false, errors: [], + initialValue: this.value, + attachmentId: Math.random().toString(36).substring(7), } }, methods: { @@ -69,11 +81,11 @@ export default { } ) }, - remove() { + remove(uuid) { this.isLoading = true this.errors = [] let self = this - axios.delete(`attachments/${this.attachment.uuid}/`) + axios.delete(`attachments/${uuid}/`) .then( response => { this.isLoading = false @@ -91,7 +103,7 @@ export default { value (v) { if (this.attachment && v === this.initialValue) { // we had a reset to initial value - this.remove() + this.remove(this.attachment.uuid) } } } diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue index 415b2fc9bcd88e84aa8e87b996c04808eb6a922b..989bb0346cc0fef0548922b629c94ea575b26c68 100644 --- a/front/src/components/common/CopyInput.vue +++ b/front/src/components/common/CopyInput.vue @@ -3,7 +3,7 @@ <p class="message" v-if="copied"> <translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate> </p> - <input ref="input" :value="value" type="text"> + <input ref="input" :value="value" type="text" readonly> <button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"> <i class="copy icon"></i> <translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate> @@ -43,5 +43,9 @@ export default { position: absolute; right: 0; bottom: -3em; + padding: 0.3em; + box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.3); + background-color: white; + z-index: 999; } </style> diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue index 502accf0f9acc8813cfbd4587c199ac8a118b907..969b6c667d456a380cd05a6732fafa27f362b93f 100644 --- a/front/src/components/common/DangerousButton.vue +++ b/front/src/components/common/DangerousButton.vue @@ -1,5 +1,5 @@ <template> - <div @click="showModal = true" :class="['ui', color, {disabled: disabled}, 'button']" :disabled="disabled"> + <div @click="showModal = true" :class="[{disabled: disabled}]" role="button" :disabled="disabled"> <slot></slot> <modal class="small" :show.sync="showModal"> @@ -14,7 +14,7 @@ </div> </div> <div class="actions"> - <div class="ui cancel button"> + <div class="ui basic cancel button"> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </div> <div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm"> @@ -34,8 +34,7 @@ export default { props: { action: {type: Function, required: false}, disabled: {type: Boolean, default: false}, - color: {type: String, default: 'red'}, - confirmColor: {type: String, default: null, required: false} + confirmColor: {type: String, default: "red", required: false} }, components: { Modal diff --git a/front/src/components/federation/FetchButton.vue b/front/src/components/federation/FetchButton.vue index 065d72f47b3cf7dbd4a0f3c976d1aa85d550bb0c..c31111ea4ed18881d46b3b78722a9055c9f9388c 100644 --- a/front/src/components/federation/FetchButton.vue +++ b/front/src/components/federation/FetchButton.vue @@ -82,7 +82,7 @@ </div> </div> <div class="actions"> - <div role="button" class="ui cancel button"> + <div role="button" class="ui basic cancel button"> <translate translate-context="*/*/Button.Label/Verb">Close</translate> </div> <div role="button" @click="showModal = false; $emit('refresh')" class="ui confirm green button" v-if="fetch && fetch.status === 'finished'"> diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue index e42f3e826be6cec0707577b85203af0d2e6c71c6..9e43be1c97d8cc8930781ccfd9e00e144a619ce4 100644 --- a/front/src/components/library/AlbumBase.vue +++ b/front/src/components/library/AlbumBase.vue @@ -34,7 +34,7 @@ </div> </div> <div class="actions"> - <div class="ui deny button"> + <div class="ui basic deny button"> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </div> </div> @@ -73,6 +73,18 @@ <i class="edit icon"></i> <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> </router-link> + <dangerous-button + :class="['ui', {loading: isLoading}, 'item']" + v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername" + @confirm="remove()"> + <i class="ui trash icon"></i> + <translate translate-context="*/*/*/Verb">Delete…</translate> + <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + </dangerous-button> <div class="divider"></div> <div role="button" @@ -143,6 +155,7 @@ export default { return { isLoading: true, object: null, + artist: null, discs: [], libraries: [], showEmbedModal: false @@ -160,8 +173,23 @@ export default { axios.get(url, {params: {refresh: 'true'}}).then(response => { self.object = backend.Album.clean(response.data) self.discs = self.object.tracks.reduce(groupByDisc, []) + axios.get(`artists/${response.data.artist.id}/`).then(response => { + self.artist = response.data + }) self.isLoading = false }) + }, + remove () { + let self = this + self.isLoading = true + axios.delete(`albums/${this.object.id}`).then((response) => { + self.isLoading = false + self.$emit('deleted') + self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}}) + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) } }, computed: { diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 10ef24f3b1c844b756c4197d5d34f44c37b2b790..bb5fdfa6447539778fc6f183f99b1d40d929577c 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -160,6 +160,7 @@ export default { ordering: this.getOrderingAsString(), playable: "true", tag: this.tags, + include_channels: "true", } logger.default.debug("Fetching artists") axios.get( diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue index 20435a039e550b1ccf7997d5abe1ce7c16e71444..c255b69c74524ffd4ff5f9d87aff8da2731ba70a 100644 --- a/front/src/components/library/EditCard.vue +++ b/front/src/components/library/EditCard.vue @@ -106,7 +106,7 @@ </button> <dangerous-button v-if="canDelete" - :class="['ui', {loading: isLoading}, 'basic button']" + :class="['ui', {loading: isLoading}, 'basic red button']" :action="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this suggestion?</translate></p> diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index 17b781e5997a13b0bb60bc8ca567e038f0228caa..ff219a1c21791ec044612d3b3732bc52475b61a0 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -82,14 +82,15 @@ <content-form v-model="values[fieldConfig.id].text" :field-id="fieldConfig.id" :rows="3"></content-form> </template> <template v-else-if="fieldConfig.type === 'attachment'"> - <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> <attachment-input v-model="values[fieldConfig.id]" :initial-value="initialValues[fieldConfig.id]" :required="fieldConfig.required" :name="fieldConfig.id" :id="fieldConfig.id" - @delete="values[fieldConfig.id] = initialValues[fieldConfig.id]"></attachment-input> + @delete="values[fieldConfig.id] = initialValues[fieldConfig.id]"> + <span slot="label">{{ fieldConfig.label }}</span> + </attachment-input> </template> <template v-else-if="fieldConfig.type === 'tags'"> diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue index 9e86600b37496fb9cfe28c6ff0f300ad8eedf599..7aed6ade5b8190aff63d6fe62e8222ee7725d49b 100644 --- a/front/src/components/library/FileUpload.vue +++ b/front/src/components/library/FileUpload.vue @@ -180,7 +180,6 @@ export default { currentTab: "summary", uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"), importReference, - supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"], isLoadingQuota: false, quotaStatus: null, uploads: { @@ -283,6 +282,9 @@ export default { } }, computed: { + supportedExtensions () { + return this.$store.state.ui.supportedExtensions + }, labels() { let denied = this.$pgettext('Content/Library/Help text', "Upload denied, ensure the file is not too big and that you have not reached your quota" diff --git a/front/src/components/library/FileUploadWidget.vue b/front/src/components/library/FileUploadWidget.vue index 93bead3e7a8bb6b7a263df698761e8d4853a1b72..95c553d03961413a80775bce871e1d155279b4d1 100644 --- a/front/src/components/library/FileUploadWidget.vue +++ b/front/src/components/library/FileUploadWidget.vue @@ -6,10 +6,19 @@ export default { methods: { uploadHtml5 (file) { let form = new window.FormData() + let filename = file.file.filename || file.name let value - for (let key in file.data) { - value = file.data[key] - if (value && typeof value === 'object' && typeof value.toString !== 'function') { + let data = {...file.data} + if (data.import_metadata) { + data.import_metadata = {...(data.import_metadata || {})} + if (data.channel && !data.import_metadata.title) { + data.import_metadata.title = filename.replace(/\.[^/.]+$/, "") + } + data.import_metadata = JSON.stringify(data.import_metadata) + } + for (let key in data) { + value = data[key] + if (value && typeof value === 'object' && typeof value.toString !== 'function') { if (value instanceof File) { form.append(key, value, value.name) } else { @@ -19,7 +28,6 @@ export default { form.append(key, value) } } - let filename = file.file.filename || file.name form.append('source', `upload://${filename}`) form.append(this.name, file.file, filename) let xhr = new XMLHttpRequest() diff --git a/front/src/components/library/TagsSelector.vue b/front/src/components/library/TagsSelector.vue index 6a1fe06e825c8c2309c0638df7dc6a40878a8c9d..971f4e5f14463221bc15289097041699d388e398 100644 --- a/front/src/components/library/TagsSelector.vue +++ b/front/src/components/library/TagsSelector.vue @@ -29,14 +29,30 @@ export default { return value } let settings = { + keys : { + delimiter : 32, + }, + forceSelection: false, saveRemoteData: false, filterRemoteData: true, + preserveHTML : false, apiSettings: { url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'), beforeXHR: function (xhrObject) { xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) return xhrObject }, + onResponse(response) { + let currentSearch = $(self.$refs.dropdown).dropdown('get query') + response = { + results: [], + ...response, + } + if (currentSearch) { + response.results = [{name: currentSearch}, ...response.results] + } + return response + } }, fields: { remoteValues: 'results', @@ -74,4 +90,3 @@ export default { } </style> - diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index ea20dc44ef67fee91ade50328ee44b0d6d65affc..747446f0075971ed193ff5772b3f2afc71400b38 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -1,6 +1,6 @@ <template> <main> - <div v-if="isLoadingTrack" class="ui vertical segment" v-title="labels.title"> + <div v-if="isLoading" class="ui vertical segment" v-title="labels.title"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="track"> @@ -50,7 +50,7 @@ </div> </div> <div class="actions"> - <div class="ui deny button"> + <div class="ui basic deny button"> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </div> </div> @@ -89,6 +89,18 @@ <i class="edit icon"></i> <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> </router-link> + <dangerous-button + :class="['ui', {loading: isLoading}, 'item']" + v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername" + @confirm="remove()"> + <i class="ui trash icon"></i> + <translate translate-context="*/*/*/Verb">Delete…</translate> + <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this track?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The track will be deleted, as well as any related files and data. This action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + </dangerous-button> <div class="divider"></div> <div role="button" @@ -151,8 +163,9 @@ export default { data() { return { time, - isLoadingTrack: true, + isLoading: true, track: null, + artist: null, showEmbedModal: false, libraries: [] } @@ -163,14 +176,29 @@ export default { methods: { fetchData() { var self = this - this.isLoadingTrack = true + this.isLoading = true let url = FETCH_URL + this.id + "/" logger.default.debug('Fetching track "' + this.id + '"') axios.get(url, {params: {refresh: 'true'}}).then(response => { self.track = response.data - self.isLoadingTrack = false + axios.get(`artists/${response.data.artist.id}/`).then(response => { + self.artist = response.data + }) + self.isLoading = false }) }, + remove () { + let self = this + self.isLoading = true + axios.delete(`tracks/${this.track.id}`).then((response) => { + self.isLoading = false + self.$emit('deleted') + self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}}) + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } }, computed: { publicLibraries () { @@ -224,7 +252,9 @@ export default { return u }, cover() { - return null + if (this.track.cover) { + return this.track.cover + } }, albumUrl () { let route = this.$router.resolve({name: 'library.albums.detail', params: {id: this.track.album.id }}) @@ -235,12 +265,12 @@ export default { return route.href }, headerStyle() { - if (!this.cover) { + if (!this.cover || !this.cover.original) { return "" } return ( "background-image: url(" + - this.$store.getters["instance/absoluteUrl"](this.cover) + + this.$store.getters["instance/absoluteUrl"](this.cover.original) + ")" ) }, diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue index b0eabbd6e6054f5be4a6afbee00e16fb2d423a26..023f41f34759147a2915783f8181b8fa343aed3e 100644 --- a/front/src/components/library/radios/Filter.vue +++ b/front/src/components/library/radios/Filter.vue @@ -50,7 +50,7 @@ </div> </div> <div class="actions"> - <div class="ui black deny button"> + <div class="ui basic black deny button"> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </div> </div> diff --git a/front/src/components/manage/moderation/InstancePolicyForm.vue b/front/src/components/manage/moderation/InstancePolicyForm.vue index ebb946d5a661804a6129b02021622b124e7854be..f6b554d00d45999aca4e1be9d9cac1640a0e93a2 100644 --- a/front/src/components/manage/moderation/InstancePolicyForm.vue +++ b/front/src/components/manage/moderation/InstancePolicyForm.vue @@ -58,7 +58,7 @@ <translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-if="object" key="1">Update</translate> <translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-else key="2">Create</translate> </button> - <dangerous-button v-if="object" class="right floated basic button" color='red' @confirm="remove"> + <dangerous-button v-if="object" class="ui right floated basic red button" @confirm="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"> <translate translate-context="Popup/Moderation/Title">Delete this moderation rule?</translate> diff --git a/front/src/components/manage/moderation/NotesThread.vue b/front/src/components/manage/moderation/NotesThread.vue index 705354ec8e7c5f90b8955453f6e80c8f0dbf4395..393e4fcbdca89a3e3c41aaedf41533f41c8b0bf5 100644 --- a/front/src/components/manage/moderation/NotesThread.vue +++ b/front/src/components/manage/moderation/NotesThread.vue @@ -18,8 +18,7 @@ </div> <div class="meta"> <dangerous-button - :class="['ui', {loading: isLoading}, 'basic borderless mini button']" - color="grey" + :class="['ui', {loading: isLoading}, 'basic borderless mini grey button']" @confirm="remove(note)"> <i class="trash icon"></i> <translate translate-context="*/*/*/Verb">Delete</translate> diff --git a/front/src/components/manage/moderation/ReportCard.vue b/front/src/components/manage/moderation/ReportCard.vue index 65fb3773cc4cb665672301d969ed972571943190..73cc142db7a37aa341eba048756eeb02bef9f008 100644 --- a/front/src/components/manage/moderation/ReportCard.vue +++ b/front/src/components/manage/moderation/ReportCard.vue @@ -229,7 +229,6 @@ <dangerous-button v-if="action.dangerous && action.show(obj)" :class="['ui', {loading: isLoading}, 'button']" - color="" :action="action.handler"> <i :class="[action.iconColor, action.icon, 'icon']"></i> {{ action.label }} diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index 8b3d3a2a564c4aed2fc346e5993a11001ba6964d..47eae2b66b33956728631094a57ff44fe0eaca80 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -25,6 +25,10 @@ export default { label: this.$pgettext('Content/Library/*', 'Skipped'), help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries'), }, + draft: { + label: this.$pgettext('Content/Library/*/Short', 'Draft'), + help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been scheduled for processing yet'), + }, pending: { label: this.$pgettext('Content/Library/*/Short', 'Pending'), help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet'), diff --git a/front/src/components/moderation/FilterModal.vue b/front/src/components/moderation/FilterModal.vue index 51e9eb70e9bfb2ea5948967f95f2a03429ba5c8c..bad0b2a23655b2c016fda6b4e9b1375cadd57bc2 100644 --- a/front/src/components/moderation/FilterModal.vue +++ b/front/src/components/moderation/FilterModal.vue @@ -37,7 +37,7 @@ </div> </div> <div class="actions"> - <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> + <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> <div :class="['ui', 'green', {loading: isLoading}, 'button']" @click="hide"><translate translate-context="Popup/*/Button.Label">Hide content</translate></div> </div> </modal> diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue index 6fa1ba8e024cada6d84437227a07ee2e89140bb8..0f58712cd2a1d912b57f4874a54e558a4933636e 100644 --- a/front/src/components/moderation/ReportModal.vue +++ b/front/src/components/moderation/ReportModal.vue @@ -57,7 +57,7 @@ </div> </div> <div class="actions"> - <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> + <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> <button v-if="canSubmit" :class="['ui', 'green', {loading: isLoading}, 'button']" diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue index 59f5ea809a6424e830f9044d77dd1ea82aa8064b..bb732301f66689fa8a57db40b0d819e56db55908 100644 --- a/front/src/components/playlists/Editor.vue +++ b/front/src/components/playlists/Editor.vue @@ -47,7 +47,7 @@ </translate> </div> - <dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist"> + <dangerous-button :disabled="plts.length === 0" class="ui labeled right floated yellow icon button" :action="clearPlaylist"> <i class="eraser icon"></i> <translate translate-context="*/Playlist/Button.Label/Verb">Clear playlist</translate> <p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title" :translate-params="{playlist: playlist.name}"> Do you want to clear the playlist "%{ playlist }"? diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue index 7ada395b6011b016b0c7f3882ab807631bf3e24a..07a75902741e71b8dc017dc969817c33149bbf3e 100644 --- a/front/src/components/playlists/PlaylistModal.vue +++ b/front/src/components/playlists/PlaylistModal.vue @@ -25,7 +25,7 @@ :translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"><strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>.</p> <button @click="duplicateTrackAddConfirm(false)" - class="ui small cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate> + class="ui small basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </button> <button class="ui small green button" @@ -101,7 +101,7 @@ </div> </div> <div class="actions"> - <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> + <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div> </div> </modal> </template> diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue index 828e842ab8a71a49543a6ac78fa569a73c54448d..599ef02229e61be5f35ec03d2832a0bbdc6c1cf2 100644 --- a/front/src/components/semantic/Modal.vue +++ b/front/src/components/semantic/Modal.vue @@ -1,6 +1,6 @@ <template> - <div :class="['ui', {'active': show}, 'modal']"> - <i class="close icon"></i> + <div :class="['ui', {'active': show}, {'overlay fullscreen': ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']"> + <i class="close inside icon"></i> <slot v-if="show"> </slot> diff --git a/front/src/lodash.js b/front/src/lodash.js index f633e9a94f503d836e0f2d36cc64105582a95889..ce045f5a1edefc8c2df20cf6e2d6fa46db0df3ef 100644 --- a/front/src/lodash.js +++ b/front/src/lodash.js @@ -15,4 +15,5 @@ export default { reverse: require('lodash/reverse'), isEqual: require('lodash/isEqual'), sum: require('lodash/sum'), + startCase: require('lodash/startCase'), } diff --git a/front/src/main.js b/front/src/main.js index cea2d095ff2d1748612eed87753fce73d6aaa99f..13fb9f1966ac9017d504ccff2285373b866445dc 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -19,6 +19,7 @@ import { sync } from 'vuex-router-sync' import locales from '@/locales' import filters from '@/filters' // eslint-disable-line +import {parseAPIErrors} from '@/utils' import globals from '@/components/globals' // eslint-disable-line import './registerServiceWorker' @@ -67,6 +68,7 @@ Vue.directive('title', function (el, binding) { Vue.directive('dropdown', function (el, binding) { jQuery(el).dropdown({ selectOnKeydown: false, + ...(binding.value || {}) }) }) axios.interceptors.request.use(function (config) { @@ -127,15 +129,8 @@ axios.interceptors.response.use(function (response) { error.backendErrors.push(error.response.data.detail) } else { error.rawPayload = error.response.data - for (var field in error.response.data) { - // some views (e.g. v1/playlists/{id}/add) have deeper nested data (e.g. data[field] - // is another object), so don't try to unpack non-array fields - if (error.response.data.hasOwnProperty(field) && error.response.data[field].forEach) { - error.response.data[field].forEach(e => { - error.backendErrors.push(e) - }) - } - } + let parsedErrors = parseAPIErrors(error.response.data) + error.backendErrors = [...error.backendErrors, ...parsedErrors] } } if (error.backendErrors.length === 0) { @@ -157,6 +152,19 @@ store.dispatch('instance/fetchFrontSettings').finally(() => { components: { App }, created () { APP = this + window.addEventListener('resize', this.handleResize) + this.handleResize(); + }, + destroyed() { + window.removeEventListener('resize', this.handleResize) + }, + methods: { + handleResize() { + this.$store.commit('ui/window', { + width: window.innerWidth, + height: window.innerHeight, + }) + } }, }) diff --git a/front/src/router/index.js b/front/src/router/index.js index 9a573b2b759c347acc27522d7c39546912131002..233ad1f443fe6586129679a4f465ff1c3b502572 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -9,6 +9,9 @@ export default new Router({ linkActiveClass: "active", base: process.env.VUE_APP_ROUTER_BASE_URL || "/", scrollBehavior(to, from, savedPosition) { + if (to.meta.preserveScrollPosition) { + return savedPosition + } return new Promise(resolve => { setTimeout(() => { if (to.hash) { diff --git a/front/src/store/channels.js b/front/src/store/channels.js index 0f2c8b23c0f9898fa900320201e2079164e6fa31..d6c14f8357b54acf937fff8ebf6fd167c5b6ac2e 100644 --- a/front/src/store/channels.js +++ b/front/src/store/channels.js @@ -5,7 +5,12 @@ export default { namespaced: true, state: { subscriptions: [], - count: 0 + count: 0, + showUploadModal: false, + latestPublication: null, + uploadModalConfig: { + channel: null, + } }, mutations: { subscriptions: (state, {uuid, value}) => { @@ -24,6 +29,22 @@ export default { reset (state) { state.subscriptions = [] state.count = 0 + }, + showUploadModal (state, value) { + state.showUploadModal = value.show + if (value.config) { + state.uploadModalConfig = { + ...value.config + } + } + }, + publish (state, {uploads, channel}) { + state.latestPublication = { + date: new Date(), + uploads, + channel, + } + state.showUploadModal = false } }, getters: { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index 195458d2fe74ad692413890d22e40fce972c6b4f..f6346d6f858bfd74a7fc93cc6658c5bb5c8f228f 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -11,8 +11,13 @@ export default { lastDate: new Date(), maxMessages: 100, messageDisplayDuration: 10000, + supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"], messages: [], theme: 'light', + window: { + height: 0, + width: 0, + }, notifications: { inbox: 0, pendingReviewEdits: 0, @@ -125,6 +130,33 @@ export default { count += 1 } return count + }, + + windowSize: (state, getters) => { + // IMPORTANT: if you modify these breakpoints, also modify the values in + // style/vendor/_media.scss + let width = state.window.width + let breakpoints = [ + {name: 'widedesktop', width: 1200}, + {name: 'desktop', width: 1024}, + {name: 'tablet', width: 768}, + {name: 'phone', width: 320}, + ] + for (let index = 0; index < breakpoints.length; index++) { + const element = breakpoints[index]; + if (width >= element.width) { + return element.name + } + } + return 'phone' + + }, + layoutVersion: (state, getters) => { + if (['tablet', 'phone'].indexOf(getters.windowSize) > -1) { + return 'small' + } else { + return 'large' + } } }, mutations: { @@ -193,6 +225,9 @@ export default { serviceWorker: (state, value) => { state.serviceWorker = {...state.serviceWorker, ...value} + }, + window: (state, value) => { + state.window = value } }, actions: { diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 365e1da22a5d229a143a05faf4db639b82f9825c..125293b994df3878f34f5a7453c9cf1e9d3030b4 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -112,7 +112,6 @@ html { .toast-container { bottom: $bottom-player-height + 1rem; } - } } @@ -141,6 +140,7 @@ html { #app { > .main.pusher, > .footer { + position: relative; @include media(">desktop") { margin-left: $desktop-sidebar-width !important; } @@ -263,6 +263,9 @@ a { .segment.hidden { display: none; } +.hidden:not(.divider) { + display: none !important; +} .nomargin { margin: 0 !important; @@ -291,6 +294,9 @@ button.reset { text-align: inherit; } +.text.align.left { + text-align: left; +} .ui.table > caption { font-weight: bold; padding: 0.5em; @@ -422,6 +428,11 @@ input + .help { display: none !important; } } +.mobile-only { + @include media(">tablet") { + display: none !important; + } +} :not(.menu) > { a, .link { &:not(.button):not(.list) { @@ -433,19 +444,36 @@ input + .help { } .ui.cards.app-cards { $card-width: 14em; - $card-hight: 22em; + $card-height: 22em; + $small-card-width: 11em; + $small-card-height: 19em; .app-card { display: flex; - width: $card-width; - height: $card-hight; + width: $small-card-width; + height: $small-card-height; + font-size: 95%; + @include media(">tablet") { + font-size: 100%; + width: $card-width; + height: $card-height; + } .content:not(.extra) { - padding: 0.5em 1em 0; + padding: 0.25em 0.5em 0; + @include media(">tablet") { + padding: 0.5em 1em 0; + } } .content.extra { - padding: 0.5em 1em; + padding: 0.25em 0.5em; + @include media(">tablet") { + padding: 0.5em 1em; + } } .head-image { - height: $card-width; + height: $small-card-width; + @include media(">tablet") { + height: $card-width; + } background-size: cover !important; background-position: center !important; display: flex !important; @@ -457,9 +485,14 @@ input + .help { &.circular { overflow: visible; border-radius: 50% !important; - height: $card-width - 1em; - width: $card-width - 1em; - margin: 0.5em; + width: $small-card-width - 0.5em; + height: $small-card-width - 0.5em; + margin: 0.25em; + @include media(">tablet") { + width: $card-width - 1em; + height: $card-width - 1em; + margin: 0.5em; + } } &.padded { @@ -543,7 +576,8 @@ input + .help { } } .channel-image { - border: 1px solid rgba(0, 0, 0, 0.5); + border: 1px solid rgba(0, 0, 0, 0.15); + background-color: white; border-radius: 0.3em; &.large { width: 8em !important; @@ -560,5 +594,70 @@ input + .help { width: 100%; } } +.placeholder.image { + background-color: rgba(0,0,0,.08); + width: 3em; + height: 3em; + &.large { + width: 8em; + height: 8em; + } + max-width: 100%; + display: block; + &.circular { + border-radius: 50%; + } + &.static { + animation: none; + } +} +.channel-type.field .radio { + display: block; + padding: 1.5em; + &.selected { + background-color: rgba(0, 0, 0, 0.05); + } +} +.header.with-actions { + display: flex; + justify-content: space-between; + align-items: center; + .actions { + font-weight: normal; + font-size: 0.6em; + } + +} +.file-uploads.channels.ui.button { + display: block; + padding: 2em 1em; + width: 100%; + box-shadow: none; + border-style: dashed !important; + border: 2px solid rgba(50, 50, 50, 0.5); + font-size: 1.2em; + padding: 0; + > div:not(.divider) { + padding: 1em; + } +} + +.channel-file { + display: flex; + align-items: top; + margin-bottom: 1em; + > :first-child { + width: 3em; + } + .header { + margin: 0 1em; + .sub.header { + margin-top: 0.5em; + } + } +} +.modal > .header { + text-align: center; +} @import "./themes/_light.scss"; @import "./themes/_dark.scss"; diff --git a/front/src/style/vendor/_media.scss b/front/src/style/vendor/_media.scss index 8d24baa71780a4c9ca6c8fffeb9e9ab1ae6e94d7..75305323cb2aa37551b5fe29c14b2856f5495499 100644 --- a/front/src/style/vendor/_media.scss +++ b/front/src/style/vendor/_media.scss @@ -31,6 +31,9 @@ /// @example scss - Creates a single breakpoint with the label `phone` /// $breakpoints: ('phone': 320px); /// + +// IMPORTANT: if you modify these breakpoints, also modify the values in +// store/ui.js#windowSize $breakpoints: ( 'phone': 320px, 'tablet': 768px, diff --git a/front/src/utils.js b/front/src/utils.js index eee36f8e68df0aea62b13e2d0e598dfe1c911b3f..ad763fe004da937e3ab614ad9622dd1fda3af5d1 100644 --- a/front/src/utils.js +++ b/front/src/utils.js @@ -6,3 +6,30 @@ export function setUpdate(obj, statuses, value) { statuses[k] = value }) } + +export function parseAPIErrors(responseData, parentField) { + let errors = [] + for (var field in responseData) { + if (responseData.hasOwnProperty(field)) { + let value = responseData[field] + let fieldName = lodash.startCase(field.replace('_', ' ')) + if (parentField) { + fieldName = `${parentField} - ${fieldName}` + } + if (value.forEach) { + value.forEach(e => { + if (e.toLocaleLowerCase().includes('this field ')) { + errors.push(`${fieldName}: ${e}`) + } else { + errors.push(e) + } + }) + } else if (typeof value === 'object') { + // nested errors + let nestedErrors = parseAPIErrors(value, fieldName) + errors = [...errors, ...nestedErrors] + } + } + } + return errors +} diff --git a/front/src/views/admin/library/AlbumDetail.vue b/front/src/views/admin/library/AlbumDetail.vue index 6ca2acf8812123955462dfeb7f567406c0009daf..9cce0c8f4f5391ce4e70ead84eeb4f0a15c255f8 100644 --- a/front/src/views/admin/library/AlbumDetail.vue +++ b/front/src/views/admin/library/AlbumDetail.vue @@ -74,7 +74,7 @@ </div> <div class="ui buttons"> <dangerous-button - :class="['ui', {loading: isLoading}, 'basic button']" + :class="['ui', {loading: isLoading}, 'basic red button']" :action="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p> diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue index 3ee193e3547db5e699869970deca3de444d500fc..5544445bb19767c8cd35fc907eac1ab3b8e3bceb 100644 --- a/front/src/views/admin/library/ArtistDetail.vue +++ b/front/src/views/admin/library/ArtistDetail.vue @@ -73,7 +73,7 @@ </div> <div class="ui buttons"> <dangerous-button - :class="['ui', {loading: isLoading}, 'basic button']" + :class="['ui', {loading: isLoading}, 'basic red button']" :action="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this artist?</translate></p> diff --git a/front/src/views/admin/library/LibraryDetail.vue b/front/src/views/admin/library/LibraryDetail.vue index 2a7b74767029294c8578c3c9276323ce462e6f90..412795d71e3a1fa720feb21a176bf58a602bbb90 100644 --- a/front/src/views/admin/library/LibraryDetail.vue +++ b/front/src/views/admin/library/LibraryDetail.vue @@ -54,7 +54,7 @@ </div> <div class="ui buttons"> <dangerous-button - :class="['ui', {loading: isLoading}, 'basic button']" + :class="['ui', {loading: isLoading}, 'basic red button']" :action="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this library?</translate></p> diff --git a/front/src/views/admin/library/TagDetail.vue b/front/src/views/admin/library/TagDetail.vue index c434f2805f45f278a5e0be8ec5aa9eb74ff4416c..5d5dac2351ae47bfc6ebce74b64697340e408e1d 100644 --- a/front/src/views/admin/library/TagDetail.vue +++ b/front/src/views/admin/library/TagDetail.vue @@ -37,7 +37,7 @@ </div> <div class="ui buttons"> <dangerous-button - :class="['ui', {loading: isLoading}, 'basic button']" + :class="['ui', {loading: isLoading}, 'basic red button']" :action="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this tag?</translate></p> diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue index f01457b1246da444c79a1b16f065ad50ff488d28..dab02cc7bdb96a18a427782203e82a5a2ad30e73 100644 --- a/front/src/views/admin/library/TrackDetail.vue +++ b/front/src/views/admin/library/TrackDetail.vue @@ -74,7 +74,7 @@ </div> <div class="ui buttons"> <dangerous-button - :class="['ui', {loading: isLoading}, 'basic button']" + :class="['ui', {loading: isLoading}, 'basic red button']" :action="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p> diff --git a/front/src/views/admin/library/UploadDetail.vue b/front/src/views/admin/library/UploadDetail.vue index cf2ddc0a98670ff69afa89cd895222b7d2514a1b..3cd32ed41f444bb1abfafebe9ca8def477d29e18 100644 --- a/front/src/views/admin/library/UploadDetail.vue +++ b/front/src/views/admin/library/UploadDetail.vue @@ -61,7 +61,7 @@ </div> <div class="ui buttons"> <dangerous-button - :class="['ui', {loading: isLoading}, 'basic button']" + :class="['ui', {loading: isLoading}, 'basic red button']" :action="remove"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this upload?</translate></p> diff --git a/front/src/views/auth/ProfileActivity.vue b/front/src/views/auth/ProfileActivity.vue index 1db7ea2a0fa807814110b46f1e18906110d208e8..50ef0e9259b27c161bd693cec7ebde5a6537e9dc 100644 --- a/front/src/views/auth/ProfileActivity.vue +++ b/front/src/views/auth/ProfileActivity.vue @@ -1,19 +1,21 @@ <template> - <section class="ui stackable three column grid"> - <div class="column"> + <section> + <div> <h2 class="ui header"> <translate translate-context="Content/Home/Title">Recently listened</translate> </h2> <track-widget :url="'history/listenings/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"> </track-widget> </div> - <div class="column"> + <div class="ui hidden divider"></div> + <div> <h2 class="ui header"> <translate translate-context="Content/Home/Title">Recently favorited</translate> </h2> <track-widget :url="'favorites/tracks/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"></track-widget> </div> - <div class="column"> + <div class="ui hidden divider"></div> + <div> <h2 class="ui header"> <translate translate-context="*/*/*">Playlists</translate> </h2> diff --git a/front/src/views/auth/ProfileBase.vue b/front/src/views/auth/ProfileBase.vue index 927446649304225179b835f91786bb926aa6eb75..91bebf36b23b77e162910abcc1b67325533db6ac 100644 --- a/front/src/views/auth/ProfileBase.vue +++ b/front/src/views/auth/ProfileBase.vue @@ -3,60 +3,77 @@ <div v-if="isLoading" class="ui vertical segment"> <div class="ui centered active inline loader"></div> </div> - <template v-if="object"> - <div class="ui dropdown icon small basic right floated button" ref="dropdown" v-dropdown style="right: 1em; top: 1em; z-index: 5"> - <i class="ellipsis vertical icon"></i> - <div class="menu"> - <div - role="button" - class="basic item" - v-for="obj in getReportableObjs({account: object})" - :key="obj.target.type + obj.target.id" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> - <i class="share icon" /> {{ obj.label }} - </div> + <div class="ui head vertical stripe segment container"> + <div class="ui stackable grid" v-if="object"> + <div class="ui five wide column"> + <div class="ui pointing dropdown icon small basic right floated button" ref="dropdown" v-dropdown="{direction: 'downward'}" style="position: absolute; right: 1em; top: 1em; z-index: 5"> + <i class="ellipsis vertical icon"></i> + <div class="menu"> + <div + role="button" + class="basic item" + v-for="obj in getReportableObjs({account: object})" + :key="obj.target.type + obj.target.id" + @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> + <i class="share icon" /> {{ obj.label }} + </div> - <div class="divider"></div> - <router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}"> - <i class="wrench icon"></i> - <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> - </router-link> - </div> - </div> - <div class="ui head vertical stripe segment"> - <h1 class="ui center aligned icon header"> - <i v-if="!object.icon" class="circular inverted user green icon"></i> - <img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" /> - <div class="ellispsis content"> - <div class="ui very small hidden divider"></div> - <span :title="displayName">{{ displayName }}</span> - <div class="ui very small hidden divider"></div> - <span class="ui grey tiny text" :title="object.full_username">{{ object.full_username }}</span> + <div class="divider"></div> + <router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + </div> </div> - <template v-if="object.full_username === $store.state.auth.fullUsername"> - <div class="ui very small hidden divider"></div> - <div class="ui basic green label"> - <translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate> + <h1 class="ui center aligned icon header"> + <i v-if="!object.icon" class="circular inverted user green icon"></i> + <img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" /> + <div class="ellispsis content"> + <div class="ui very small hidden divider"></div> + <span :title="displayName">{{ displayName }}</span> + <div class="ui very small hidden divider"></div> + <div class="sub header ellipsis" :title="object.full_username"> + {{ object.full_username }} + </div> </div> - </template> - </h1> - <div class="ui container"> - <div class="ui secondary pointing center aligned menu"> - <router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}"> - <translate translate-context="Content/Profile/Link">Overview</translate> - </router-link> - <router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}"> - <translate translate-context="Content/Profile/*">Activity</translate> - </router-link> + <template v-if="object.full_username === $store.state.auth.fullUsername"> + <div class="ui very small hidden divider"></div> + <div class="ui basic green label"> + <translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate> + </div> + </template> + </h1> + <div class="ui small hidden divider"></div> + <div v-if="$store.getters['ui/layoutVersion'] === 'large'"> + <rendered-description + @updated="$emit('updated', $event)" + :content="object.summary" + :field-name="'summary'" + :update-url="`users/users/${$store.state.auth.username}/`" + :can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description> </div> - <div class="ui hidden divider"></div> - <keep-alive> - <router-view @updated="fetch" :object="object"></router-view> - </keep-alive> + </div> + <div class="ui eleven wide column"> + <div class="ui head vertical stripe segment"> + <div class="ui container"> + <div class="ui secondary pointing center aligned menu"> + <router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}"> + <translate translate-context="Content/Profile/Link">Overview</translate> + </router-link> + <router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}"> + <translate translate-context="Content/Profile/*">Activity</translate> + </router-link> + </div> + <div class="ui hidden divider"></div> + <keep-alive> + <router-view @updated="fetch" :object="object"></router-view> + </keep-alive> + </div> + </div> </div> </div> - </template> + </div> </main> </template> @@ -81,6 +98,10 @@ export default { created() { this.fetch() }, + beforeRouteUpdate (to, from, next) { + to.meta.preserveScrollPosition = true + next() + }, methods: { fetch () { let self = this diff --git a/front/src/views/auth/ProfileOverview.vue b/front/src/views/auth/ProfileOverview.vue index cf65e42b9fd230d68a99f0b6e09b70ecc5db5d4f..5c524a107abe206cceab53fcd11903499a94297b 100644 --- a/front/src/views/auth/ProfileOverview.vue +++ b/front/src/views/auth/ProfileOverview.vue @@ -1,17 +1,23 @@ <template> - <section class="ui stackable grid"> - <div class="six wide column"> + <section> + <div v-if="$store.getters['ui/layoutVersion'] === 'small'"> <rendered-description @updated="$emit('updated', $event)" :content="object.summary" :field-name="'summary'" :update-url="`users/users/${$store.state.auth.username}/`" :can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description> - + <div class="ui hidden divider"></div> </div> - <div class="ten wide column"> - <h2 class="ui header"> + <div> + <h2 class="ui with-actions header"> <translate translate-context="*/*/*">Channels</translate> + <div class="actions" v-if="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"> + <a @click.stop.prevent="showCreateModal = true"> + <i class="plus icon"></i> + <translate translate-context="Content/Profile/Button">Add new</translate> + </a> + </div> </h2> <channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget> <h2 class="ui header"> @@ -21,15 +27,61 @@ <translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate> </library-widget> </div> + + <modal :show.sync="showCreateModal"> + <div class="header"> + <translate v-if="step === 1" key="1" translate-context="Content/Channel/*/Verb">Create channel</translate> + <translate v-else-if="category === 'podcast'" key="2" translate-context="Content/Channel/*">Podcast channel</translate> + <translate v-else key="3" translate-context="Content/Channel/*">Artist channel</translate> + </div> + <div class="scrolling content" ref="modalContent"> + <channel-form + ref="createForm" + :object="null" + :step="step" + @loading="isLoading = $event" + @submittable="submittable = $event" + @category="category = $event" + @errored="$refs.modalContent.scrollTop = 0" + @created="$router.push({name: 'channels.detail', params: {id: $event.actor.preferred_username}})"></channel-form> + <div class="ui hidden divider"></div> + </div> + <div class="actions"> + <div v-if="step === 1" class="ui basic deny button"> + <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> + </div> + <button v-if="step > 1" class="ui basic button" @click.stop.prevent="step -= 1"> + <translate translate-context="*/*/Button.Label/Verb">Previous step</translate> + </button> + <button v-if="step === 1" class="ui primary button" @click.stop.prevent="step += 1"> + <translate translate-context="*/*/Button.Label">Next step</translate> + </button> + <button v-if="step === 2" :class="['ui', 'primary button', {loading: isLoading}]" type="submit" @click.prevent.stop="$refs.createForm.submit" :disabled="!submittable && !isLoading"> + <translate translate-context="*/Channels/Button.Label">Create channel</translate> + </button> + </div> + </modal> + </section> </template> <script> +import Modal from '@/components/semantic/Modal' import LibraryWidget from "@/components/federation/LibraryWidget" import ChannelsWidget from "@/components/audio/ChannelsWidget" +import ChannelForm from "@/components/audio/ChannelForm" export default { props: ['object'], - components: {ChannelsWidget, LibraryWidget}, + components: {ChannelsWidget, LibraryWidget, ChannelForm, Modal}, + data () { + return { + showCreateModal: false, + isLoading: false, + submittable: false, + step: 1, + category: 'podcast', + } + } } </script> diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue index e61aab28ed6c54642a6818812a118726cf3c9bfe..f7391dd57e9abbbe5b377e85a245784cb63c7d1d 100644 --- a/front/src/views/channels/DetailBase.vue +++ b/front/src/views/channels/DetailBase.vue @@ -5,8 +5,8 @@ </div> <template v-if="object && !isLoading"> <section class="ui head vertical stripe segment container" v-title="object.artist.name"> - <div class="ui stackable two column grid"> - <div class="column"> + <div class="ui stackable grid"> + <div class="seven wide column"> <div class="ui two column grid"> <div class="column"> <img class="huge channel-image" v-if="object.artist.cover" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.medium_square_crop)"> @@ -28,10 +28,48 @@ </template> </template> <div class="ui hidden small divider"></div> - <a :href="rssUrl" target="_blank" class="ui icon small basic button"> + <a @click.stop.prevent="showSubscribeModal = true" class="ui icon small basic button"> <i class="feed icon"></i> </a> - <div class="ui dropdown icon small basic button" ref="dropdown" v-dropdown> + <modal class="tiny" :show.sync="showSubscribeModal"> + <div class="header"> + <translate translate-context="Popup/Channel/Title/Verb">Subscribe to this channel</translate> + </div> + <div class="scrollable content"> + <div class="description"> + + <template v-if="$store.state.auth.authenticated"> + <h3> + <i class="user icon"></i> + <translate translate-context="Content/Channels/Header">Subscribe on Funkwhale</translate> + </h3> + <subscribe-button @subscribed="object.subscriptions_count += 1" @unsubscribed="object.subscriptions_count -= 1" :channel="object"></subscribe-button> + </template> + <template v-if="object.rss_url"> + <h3> + <i class="feed icon"></i> + <translate translate-context="Content/Channels/Header">Subscribe via RSS</translate> + </h3> + <p><translate translate-context="Content/Channels/Label">Copy-paste the following URL in your favorite podcasting app:</translate></p> + <copy-input :value="object.rss_url" /> + </template> + <template v-if="object.actor"> + <h3> + <i class="bell icon"></i> + <translate translate-context="Content/Channels/Header">Subscribe on the Fediverse</translate> + </h3> + <p><translate translate-context="Content/Channels/Label">If you're using Mastodon or other fediverse applications, you can subscribe to this account:</translate></p> + <copy-input :value="`@${object.actor.full_username}`" /> + </template> + </div> + </div> + <div class="actions"> + <div class="ui basic deny button"> + <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> + </div> + </div> + </modal> + <div class="ui right floated pointing dropdown icon small basic button" ref="dropdown" v-dropdown="{direction: 'downward'}"> <i class="ellipsis vertical icon"></i> <div class="menu"> <div @@ -52,27 +90,55 @@ <i class="share icon" /> {{ obj.label }} </div> - <div class="divider"></div> - <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}"> - <i class="wrench icon"></i> - <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> - </router-link> + <template v-if="isOwner"> + <div class="divider"></div> + <div + class="item" + role="button" + @click.stop="showEditModal = true"> + <translate translate-context="*/*/*/Verb">Edit…</translate> + </div> + <dangerous-button + :class="['ui', {loading: isLoading}, 'item']" + v-if="object" + @confirm="remove()"> + <translate translate-context="*/*/*/Verb">Delete…</translate> + <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this Channel?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The channel will be deleted, as well as any related files and data. This action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + </dangerous-button> + </template> + <template v-if="$store.state.auth.availablePermissions['library']" > + <div class="divider"></div> + <router-link class="basic item" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + </template> </div> </div> </div> </div> <h1 class="ui header"> - <div class="left aligned content ellipsis"> + <div class="left aligned" :title="object.artist.name"> {{ object.artist.name }} <div class="ui hidden very small divider"></div> - <div class="sub header"> + <div class="sub header ellipsis" :title="object.actor.full_username"> {{ object.actor.full_username }} </div> </div> </h1> <div class="header-buttons"> + <div class="ui buttons" v-if="isOwner"> + <button class="ui basic labeled icon button" @click.prevent.stop="$store.commit('channels/showUploadModal', {show: true, config: {channel: object}})"> + <i class="upload icon"></i> + <translate translate-context="Content/Channels/Button.Label/Verb">Upload</translate> + </button> + </div> <div class="ui buttons"> - <play-button :is-playable="isPlayable" class="orange" :channel="object"> + <play-button :is-playable="isPlayable" class="orange" :artist="object.artist"> <translate translate-context="Content/Channels/Button.Label/Verb">Play</translate> </play-button> </div> @@ -90,21 +156,45 @@ </div> </div> <div class="actions"> - <div class="ui deny button"> + <div class="ui basic deny button"> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </div> </div> </modal> + <modal :show.sync="showEditModal" v-if="isOwner"> + <div class="header"> + <translate v-if="object.artist.content_category === 'podcast'" key="1" translate-context="Content/Channel/*">Podcast channel</translate> + <translate v-else key="2" translate-context="Content/Channel/*">Artist channel</translate> + + </div> + <div class="scrolling content"> + <channel-form + ref="editForm" + :object="object" + @loading="edit.isLoading = $event" + @submittable="edit.submittable = $event" + @updated="fetchData"></channel-form> + <div class="ui hidden divider"></div> + </div> + <div class="actions"> + <div class="ui left floated basic deny button"> + <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> + </div> + <button @click.stop="$refs.editForm.submit" :class="['ui', 'primary', 'confirm', {loading: edit.isLoading}, 'button']" :disabled="!edit.submittable"> + <translate translate-context="*/Channels/Button.Label">Update channel</translate> + </button> + </div> + </modal> </div> - <div> + <div v-if="$store.getters['ui/layoutVersion'] === 'large'"> <rendered-description @updated="object = $event" :content="object.artist.description" :update-url="`channels/${object.uuid}/`" - :can-update="$store.state.auth.authenticated && object.attributed_to.full_username === $store.state.auth.fullUsername"></rendered-description> + :can-update="false"></rendered-description> </div> </div> - <div class="column"> + <div class="nine wide column"> <div class="ui secondary pointing center aligned menu"> <router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}"> <translate translate-context="Content/Channels/Link">Overview</translate> @@ -114,9 +204,7 @@ </router-link> </div> <div class="ui hidden divider"></div> - <keep-alive> - <router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event" ></router-view> - </keep-alive> + <router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event"></router-view> </div> </div> </section> @@ -135,6 +223,7 @@ import TagsList from "@/components/tags/List" import ReportMixin from '@/components/mixins/Report' import SubscribeButton from '@/components/channels/SubscribeButton' +import ChannelForm from "@/components/audio/ChannelForm" export default { mixins: [ReportMixin], @@ -146,7 +235,8 @@ export default { TagsList, ChannelEntries, ChannelSeries, - SubscribeButton + SubscribeButton, + ChannelForm, }, data() { return { @@ -155,28 +245,54 @@ export default { totalTracks: 0, latestTracks: null, showEmbedModal: false, + showEditModal: false, + showSubscribeModal: false, + edit: { + submittable: false, + loading: false, + } } }, + beforeRouteUpdate (to, from, next) { + to.meta.preserveScrollPosition = true + next() + }, async created() { await this.fetchData() }, methods: { async fetchData() { var self = this + this.showEditModal = false + this.edit.isLoading = false this.isLoading = true let channelPromise = axios.get(`channels/${this.id}`).then(response => { self.object = response.data - }) - let tracksPromise = axios.get("tracks", {params: {channel: this.id, page_size: 1, playable: true, include_channels: true}}).then(response => { - self.totalTracks = response.data.count + let tracksPromise = axios.get("tracks", {params: {channel: response.data.uuid, page_size: 1, playable: true, include_channels: true}}).then(response => { + self.totalTracks = response.data.count + self.isLoading = false + }) }) await channelPromise - await tracksPromise - self.isLoading = false + }, + remove () { + let self = this + self.isLoading = true + axios.delete(`channels/${this.object.uuid}`).then((response) => { + self.isLoading = false + self.$emit('deleted') + self.$router.push({name: 'profile.overview', params: {username: self.$store.state.auth.username}}) + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) } }, computed: { - labels() { + isOwner () { + return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername + }, + labels () { return { title: this.$pgettext('*/*/*', 'Channel') } @@ -190,9 +306,6 @@ export default { isPlayable () { return this.totalTracks > 0 }, - rssUrl () { - return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`) - } }, watch: { id() { diff --git a/front/src/views/channels/DetailOverview.vue b/front/src/views/channels/DetailOverview.vue index 9596e28d4d3a18c2d37e1d81e990f1fe06938865..9d35b1d1756ce817520abe1bbc93a7bca4e28623 100644 --- a/front/src/views/channels/DetailOverview.vue +++ b/front/src/views/channels/DetailOverview.vue @@ -1,22 +1,86 @@ <template> <section> - <channel-entries :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}"> + <div class="ui info message" v-if="pendingUploads.length > 0"> + <template v-if="isSuccessfull"> + <i role="button" class="close icon" @click="pendingUploads = []"></i> + <h3 class="ui header"> + <translate translate-context="Content/Channel/Header">Uploads published successfully</translate> + </h3> + <p> + <translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }} + </p> + </template> + <template v-else-if="isOver"> + <h3 class="ui header"> + <translate translate-context="Content/Channel/Header">Some uploads couldn't be published</translate> + </h3> + <div class="ui hidden divider"></div> + <router-link + class="ui basic button" + :to="{name: 'content.libraries.files', query: {q: 'status:skipped'}}" + v-if="skippedUploads.length > 0"> + <translate translate-context="Content/Channel/Button">View skipped uploads</translate> + </router-link> + <router-link + class="ui basic button" + :to="{name: 'content.libraries.files', query: {q: 'status:errored'}}" + v-if="erroredUploads.length > 0"> + <translate translate-context="Content/Channel/Button">View errored uploads</translate> + </router-link> + </template> + <template v-else> + <div class="ui inline right floated active loader"></div> + <h3 class="ui header"> + <translate translate-context="Content/Channel/Header">Uploads are being processed</translate> + </h3> + <p> + <translate translate-context="Content/Channel/Paragraph">Your uploads are being processed by Funkwhale and will be live very soon.</translate> + </p> + <p> + <translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }} + </p> + + </template> + </div> + <div v-if="$store.getters['ui/layoutVersion'] === 'small'"> + <rendered-description + :content="object.artist.description" + :update-url="`channels/${object.uuid}/`" + :can-update="false"></rendered-description> + <div class="ui hidden divider"></div> + </div> + <channel-entries :key="String(episodesKey) + 'entries'" :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}"> <h2 class="ui header"> <translate translate-context="Content/Channel/Paragraph">Latest episodes</translate> </h2> </channel-entries> <div class="ui hidden divider"></div> - <channel-series :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}"> - <h2 class="ui header"> + <channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters"> + <h2 class="ui with-actions header"> <translate translate-context="Content/Channel/Paragraph">Series</translate> + <div class="actions" v-if="isOwner"> + <a @click.stop.prevent="$refs.albumModal.show = true"> + <i class="plus icon"></i> + <translate translate-context="Content/Profile/Button">Add new</translate> + </a> + </div> </h2> </channel-series> + <album-modal + ref="albumModal" + v-if="isOwner" + :channel="object" + @created="$refs.albumModal.show = false; seriesKey = new Date()"></album-modal> </section> </template> <script> +import axios from 'axios' +import qs from 'qs' + import ChannelEntries from "@/components/audio/ChannelEntries" import ChannelSeries from "@/components/audio/ChannelSeries" +import AlbumModal from "@/components/channels/AlbumModal" export default { @@ -24,6 +88,107 @@ export default { components: { ChannelEntries, ChannelSeries, + AlbumModal, + }, + data () { + return { + seriesKey: new Date(), + episodesKey: new Date(), + pendingUploads: [], + } + }, + async created () { + if (this.isOwner) { + await this.fetchPendingUploads() + this.$store.commit("ui/addWebsocketEventHandler", { + eventName: "import.status_updated", + id: "fileUploadChannel", + handler: this.handleImportEvent + }); + } + }, + destroyed() { + this.$store.commit("ui/removeWebsocketEventHandler", { + eventName: "import.status_updated", + id: "fileUploadChannel" + }); + }, + computed: { + isOwner () { + return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername + }, + seriesFilters () { + let filters = {artist: this.object.artist.id, ordering: '-creation_date'} + if (!this.isOwner) { + filters.playable = 'true' + } + return filters + }, + processedUploads () { + return this.pendingUploads.filter((u) => { + return u.import_status != "pending" + }) + }, + erroredUploads () { + return this.pendingUploads.filter((u) => { + return u.import_status === "errored" + }) + }, + skippedUploads () { + return this.pendingUploads.filter((u) => { + return u.import_status === "skipped" + }) + }, + finishedUploads () { + return this.pendingUploads.filter((u) => { + return u.import_status === "finished" + }) + }, + pendingUploadsById () { + let d = {} + this.pendingUploads.forEach((u) => { + d[u.uuid] = u + }) + return d + }, + isOver () { + return this.pendingUploads && this.processedUploads.length === this.pendingUploads.length + }, + isSuccessfull () { + return this.pendingUploads && this.finishedUploads.length === this.pendingUploads.length + } + }, + methods: { + handleImportEvent(event) { + let self = this; + if (!this.pendingUploadsById[event.upload.uuid]) { + return; + } + Object.assign(this.pendingUploadsById[event.upload.uuid], event.upload) + }, + async fetchPendingUploads () { + let response = await axios.get('uploads/', { + params: {channel: this.object.uuid, import_status: ['pending', 'skipped', 'errored'], include_channels: 'true'}, + paramsSerializer: function(params) { + return qs.stringify(params, { indices: false }) + } + }) + this.pendingUploads = response.data.results + } }, + watch: { + "$store.state.channels.latestPublication" (v) { + if (v && v.uploads && v.channel.uuid === this.object.uuid) { + let test + this.pendingUploads = [...this.pendingUploads, ...v.uploads] + } + }, + "isOver" (v) { + if (v) { + this.seriesKey = new Date() + this.episodesKey = new Date() + } + } + } } </script> diff --git a/front/src/views/content/Home.vue b/front/src/views/content/Home.vue index fb8128b9f29af3c111e99df3493065e1844888d5..d57ff03085edc9290db98fec10baeeb4082372ca 100644 --- a/front/src/views/content/Home.vue +++ b/front/src/views/content/Home.vue @@ -2,21 +2,39 @@ <section class="ui vertical aligned stripe segment" v-title="labels.title"> <div class="ui text container"> <h1>{{ labels.title }}</h1> - <p><translate translate-context="Content/Library/Paragraph">There are various ways to grab new content and make it available here.</translate></p> + <p> + <strong><translate translate-context="Content/Library/Paragraph" :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space for every user.</translate></strong> + </p> <div class="ui segment"> - <h2><translate translate-context="Content/Library/Title/Verb">Upload audio content</translate></h2> - <p><translate translate-context="Content/Library/Paragraph">Upload music files (MP3, OGG, FLAC, etc.) from your personal library directly from your browser to enjoy them here.</translate></p> + <h2> + <i class="feed icon"></i> + <translate translate-context="Content/Library/Title/Verb">Publish your work in a channel</translate> + </h2> <p> - <strong><translate translate-context="Content/Library/Paragraph" :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space for every user.</translate></strong> + <translate translate-context="Content/Library/Paragraph">If you are a musician or a podcaster, channels are designed for you!</translate> + <translate translate-context="Content/Library/Paragraph">Share your work publicly and get subscribers on Funkwhale, the Fediverse or any podcasting application.</translate> </p> - <router-link :to="{name: 'content.libraries.index'}" class="ui green button"> + <router-link :to="{name: 'profile.overview', params: {username: $store.state.auth.username}, hash: '#channels'}" class="ui primary button"> <translate translate-context="Content/Library/Button.Label/Verb">Get started</translate> </router-link> </div> <div class="ui segment"> - <h2><translate translate-context="Content/Library/Title/Verb">Follow remote libraries</translate></h2> - <p><translate translate-context="Content/Library/Paragraph">You can follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner.</translate></p> - <router-link :to="{name: 'content.remote.index'}" class="ui green button"> + <h2> + <i class="cloud icon"></i> + <translate translate-context="Content/Library/Title/Verb">Upload third-party content in a library</translate> + </h2> + <p><translate translate-context="Content/Library/Paragraph">Upload your personal music library to Funkwhale to enjoy it from anywhere and share it with friends and family.</translate></p> + <router-link :to="{name: 'content.libraries.index'}" class="ui primary button"> + <translate translate-context="Content/Library/Button.Label/Verb">Get started</translate> + </router-link> + </div> + <div class="ui segment"> + <h2> + <i class="download icon"></i> + <translate translate-context="Content/Library/Title/Verb">Follow remote libraries</translate> + </h2> + <p><translate translate-context="Content/Library/Paragraph">Follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner.</translate></p> + <router-link :to="{name: 'content.remote.index'}" class="ui primary button"> <translate translate-context="Content/Library/Button.Label/Verb">Get started</translate> </router-link> </div> diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index a00442d1a73974adfe0bf0c35548ee0b0cc4a75c..79c498a3e0b3fd8a1450b4133d39e3712bab1757 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -28,6 +28,9 @@ <option value> <translate translate-context="Content/*/Dropdown">All</translate> </option> + <option value="draft"> + <translate translate-context="Content/Library/*/Short">Draft</translate> + </option> <option value="pending"> <translate translate-context="Content/Library/*/Short">Pending</translate> </option> @@ -258,7 +261,8 @@ export default { page: this.page, page_size: this.paginateBy, ordering: this.getOrderingAsString(), - q: this.search.query + q: this.search.query, + include_channels: 'true', }, this.filters || {} ); @@ -288,7 +292,8 @@ export default { }, actionFilters() { var currentFilters = { - q: this.search.query + q: this.search.query, + include_channels: 'true', }; if (this.filters) { return _.merge(currentFilters, this.filters); diff --git a/front/src/views/content/libraries/Form.vue b/front/src/views/content/libraries/Form.vue index 48c4f8bc37c7e04710250577db50864c54939634..9918eb42d9c67e4034c2f03494aeef7b6d04d2bb 100644 --- a/front/src/views/content/libraries/Form.vue +++ b/front/src/views/content/libraries/Form.vue @@ -26,7 +26,7 @@ <translate translate-context="Content/Library/Button.Label/Verb" v-if="library">Update library</translate> <translate translate-context="Content/Library/Button.Label/Verb" v-else>Create library</translate> </button> - <dangerous-button v-if="library" class="right floated basic button" color='red' @confirm="remove()"> + <dangerous-button v-if="library" class="ui right floated basic red button" @confirm="remove()"> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header"> <translate translate-context="Popup/Library/Title">Delete this library?</translate> diff --git a/front/src/views/content/libraries/Quota.vue b/front/src/views/content/libraries/Quota.vue index f043b54b67d187abb629593666f99ddd9199cbe5..8b1fcba7eac3af3bdac47c2ca53efbb6c2b12faa 100644 --- a/front/src/views/content/libraries/Quota.vue +++ b/front/src/views/content/libraries/Quota.vue @@ -31,8 +31,7 @@ </router-link> <dangerous-button - color="grey" - class="basic tiny" + class="ui basic tiny grey button" :action="purgePendingFiles"> <translate translate-context="*/*/*/Verb">Purge</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge pending files?</translate></p> @@ -57,8 +56,7 @@ <translate translate-context="Content/Library/Link/Verb">View files</translate> </router-link> <dangerous-button - color="grey" - class="basic tiny" + class="ui basic tiny grey button" :action="purgeSkippedFiles"> <translate translate-context="*/*/*/Verb">Purge</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge skipped files?</translate></p> @@ -83,8 +81,7 @@ <translate translate-context="Content/Library/Link/Verb">View files</translate> </router-link> <dangerous-button - color="grey" - class="basic tiny" + class="ui basic tiny grey button" :action="purgeErroredFiles"> <translate translate-context="*/*/*/Verb">Purge</translate> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge errored files?</translate></p> @@ -154,4 +151,4 @@ export default { } } } -</script> \ No newline at end of file +</script> diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index 1676c073b82457ce390866712ffedba1539f4fb3..2ad16c03f0fe71db69b0e8e9c140d2b3ea87a9e6 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -115,7 +115,6 @@ </template> <template v-else-if="library.follow.approved"> <dangerous-button - color="" :class="['ui', 'button']" :action="unfollow"> <translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate> diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 5dc99e78b97ebb6ef286b2be928ddbefd8a20e88..a58c4d05bd9a1f8df7fb801c64e0ac6894be4d8b 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -39,7 +39,7 @@ <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> </button> - <dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="labeled icon" :action="deletePlaylist"> + <dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="ui labeled red icon button" :action="deletePlaylist"> <i class="trash icon"></i> <translate translate-context="*/*/*/Verb">Delete</translate> <p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title/Call to action" :translate-params="{playlist: playlist.name}"> Do you want to delete the playlist "%{ playlist }"? @@ -58,7 +58,7 @@ </div> </div> <div class="actions"> - <div class="ui deny button"> + <div class="ui basic deny button"> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> </div> </div> diff --git a/front/src/views/radios/Detail.vue b/front/src/views/radios/Detail.vue index 619ac754b3aaff12d59b0928c6fcd035a3baf1c3..4610d6873863de485e50de2607de3b5e7c52b8e7 100644 --- a/front/src/views/radios/Detail.vue +++ b/front/src/views/radios/Detail.vue @@ -22,7 +22,7 @@ <i class="pencil icon"></i> Edit… </router-link> - <dangerous-button class="labeled icon" :action="deleteRadio"> + <dangerous-button class="ui labeled red icon button" :action="deleteRadio"> <i class="trash icon"></i> Delete <p slot="modal-header" v-translate="{radio: radio.name}" translate-context="Popup/Radio/Title" :translate-params="{radio: radio.name}">Do you want to delete the radio "%{ radio }"?</p> <p slot="modal-content"><translate translate-context="Popup/Radio/Paragraph">This will completely delete this radio and cannot be undone.</translate></p> @@ -53,7 +53,7 @@ </div> <router-link v-if="$store.state.auth.username === radio.user.username" - class="ui green icon labeled button" + class="ui green icon labeled button" :to="{name: 'library.radios.edit', params: {id: radio.id}}" exact> <i class="pencil icon"></i> Edit… diff --git a/front/tests/unit/specs/utils.spec.js b/front/tests/unit/specs/utils.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9fc9c36b7a57deb5622ff7231f7ccb8b2d4046b2 --- /dev/null +++ b/front/tests/unit/specs/utils.spec.js @@ -0,0 +1,32 @@ +import {expect} from 'chai' + +import {parseAPIErrors} from '@/utils' + +describe('utils', () => { + describe('parseAPIErrors', () => { + it('handles flat structure', () => { + const input = {"old_password": ["Invalid password"]} + let expected = ["Invalid password"] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + it('handles flat structure with multiple errors per field', () => { + const input = {"old_password": ["Invalid password", "Too short"]} + let expected = ["Invalid password", "Too short"] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + it('translate field name', () => { + const input = {"old_password": ["This field is required"]} + let expected = ["Old Password: This field is required"] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + it('handle nested fields', () => { + const input = {"summary": {"text": ["Ensure this field has no more than 5000 characters."]}} + let expected = ["Summary - Text: Ensure this field has no more than 5000 characters."] + let output = parseAPIErrors(input) + expect(output).to.deep.equal(expected) + }) + }) +}) diff --git a/front/yarn.lock b/front/yarn.lock index 7456a35833a8d5afb9eb5616c694a46a2244357f..5ef45672914b6339ac58f944c3eaaba9608d1ba0 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -3243,11 +3243,6 @@ date-now@^0.1.4: resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= -dateformat@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" - integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== - de-indent@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" @@ -4267,10 +4262,10 @@ follow-redirects@^1.0.0: dependencies: debug "^3.2.6" -fomantic-ui-css@^2.7: - version "2.7.6" - resolved "https://registry.yarnpkg.com/fomantic-ui-css/-/fomantic-ui-css-2.7.6.tgz#8af84c0afce21142bf663979cf7452155562e6e2" - integrity sha512-oruD/DoMDZGSfK6fE3EnWKGad3vbhpiOtXrCwS0Bi+3QWXHwQsDU0k6P0Q8HzawoLXqHff83LmTDJWST5ARTxw== +fomantic-ui-css@^2.8.3: + version "2.8.3" + resolved "https://registry.yarnpkg.com/fomantic-ui-css/-/fomantic-ui-css-2.8.3.tgz#19689dcaf2f6a362a2eb1492bef42134a4658541" + integrity sha512-DvWMUjssWRCuKl4dHkibusE/Ewe1udUztAEbdPkLz3ou8XYjiUeUTX/0sMCFFyo5/S84x+vW8I9juafmtOJxyQ== dependencies: jquery "^3.4.0"