First round of improvements to channel management:

- use modals
- less proeminent button
- field styling/labels
This commit is contained in:
Eliot Berriot 2020-02-23 15:31:03 +01:00
parent f8675c6080
commit e59cc33378
103 changed files with 3205 additions and 451 deletions

View File

@ -960,3 +960,5 @@ MIN_DELAY_BETWEEN_DOWNLOADS_COUNT = env.int(
"MIN_DELAY_BETWEEN_DOWNLOADS_COUNT", default=60 * 60 * 6 "MIN_DELAY_BETWEEN_DOWNLOADS_COUNT", default=60 * 60 * 6
) )
MARKDOWN_EXTENSIONS = env.list("MARKDOWN_EXTENSIONS", default=["nl2br", "extra"]) MARKDOWN_EXTENSIONS = env.list("MARKDOWN_EXTENSIONS", default=["nl2br", "extra"])
LINKIFIER_SUPPORTED_TLDS = ["audio"] + env.list("LINKINFIER_SUPPORTED_TLDS", default=[])

View File

@ -23,7 +23,12 @@ urlpatterns = [
), ),
urls.re_path( urls.re_path(
r"^channels/(?P<uuid>[0-9a-f-]+)/?$", 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", name="channel_detail",
), ),
] ]

View File

@ -6,6 +6,8 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 keys
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
@ -44,14 +46,22 @@ class Channel(models.Model):
) )
def get_absolute_url(self): 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): def get_rss_url(self):
if not self.artist.is_local: if not self.artist.is_local:
return self.rss_url return self.rss_url
return federation_utils.full_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") actor_data["public_key"] = public.decode("utf-8")
return federation_models.Actor.objects.create(**actor_data) 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()

View File

@ -3,6 +3,8 @@ from django.db import transaction
from rest_framework import serializers 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 serializers as common_serializers
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import locales from funkwhale_api.common import locales
@ -24,7 +26,7 @@ class ChannelMetadataSerializer(serializers.Serializer):
itunes_category = serializers.ChoiceField( itunes_category = serializers.ChoiceField(
choices=categories.ITUNES_CATEGORIES, required=True 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) language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES)
copyright = serializers.CharField(required=False, allow_null=True, max_length=255) copyright = serializers.CharField(required=False, allow_null=True, max_length=255)
owner_name = 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 choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
) )
metadata = serializers.DictField(required=False) metadata = serializers.DictField(required=False)
cover = music_serializers.COVER_WRITE_FIELD
def validate(self, validated_data): def validate(self, validated_data):
existing_channels = self.context["actor"].owned_channels.count() existing_channels = self.context["actor"].owned_channels.count()
@ -95,15 +98,15 @@ class ChannelCreateSerializer(serializers.Serializer):
def create(self, validated_data): def create(self, validated_data):
from . import views from . import views
cover = validated_data.pop("cover", None)
description = validated_data.get("description") description = validated_data.get("description")
artist = music_models.Artist.objects.create( artist = music_models.Artist.objects.create(
attributed_to=validated_data["attributed_to"], attributed_to=validated_data["attributed_to"],
name=validated_data["name"], name=validated_data["name"],
content_category=validated_data["content_category"], content_category=validated_data["content_category"],
attachment_cover=cover,
) )
description_obj = common_utils.attach_content( common_utils.attach_content(artist, "description", description)
artist, "description", description
)
if validated_data.get("tags", []): if validated_data.get("tags", []):
tags_models.set_tags(artist, *validated_data["tags"]) tags_models.set_tags(artist, *validated_data["tags"])
@ -113,9 +116,8 @@ class ChannelCreateSerializer(serializers.Serializer):
attributed_to=validated_data["attributed_to"], attributed_to=validated_data["attributed_to"],
metadata=validated_data["metadata"], metadata=validated_data["metadata"],
) )
summary = description_obj.rendered if description_obj else None
channel.actor = models.generate_actor( 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( channel.library = music_models.Library.objects.create(
@ -142,6 +144,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
) )
metadata = serializers.DictField(required=False) metadata = serializers.DictField(required=False)
cover = music_serializers.COVER_WRITE_FIELD
def validate(self, validated_data): def validate(self, validated_data):
validated_data = super().validate(validated_data) validated_data = super().validate(validated_data)
@ -194,6 +197,9 @@ class ChannelUpdateSerializer(serializers.Serializer):
("content_category", validated_data["content_category"]) ("content_category", validated_data["content_category"])
) )
if "cover" in validated_data:
artist_update_fields.append(("attachment_cover", validated_data["cover"]))
if actor_update_fields: if actor_update_fields:
for field, value in actor_update_fields: for field, value in actor_update_fields:
setattr(obj.actor, field, value) 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 # we enforce MP3, since it's the only format supported everywhere
"url": federation_utils.full_url(upload.get_listen_url(to="mp3")), "url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
"length": upload.size or 0, "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"] = [ data["itunes:image"] = [
{"href": channel.artist.attachment_cover.download_url_original} {"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", []) tagged_items = getattr(channel.artist, "_prefetched_tagged_items", [])

View File

@ -1,26 +1,33 @@
import urllib.parse import urllib.parse
from django.conf import settings from django.conf import settings
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from rest_framework import serializers
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils from funkwhale_api.common import utils
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import spa_views from funkwhale_api.music import spa_views
from . import models from . import models
def channel_detail(request, uuid): def channel_detail(query):
queryset = models.Channel.objects.filter(uuid=uuid).select_related( queryset = models.Channel.objects.filter(query).select_related(
"artist__attachment_cover", "actor", "library" "artist__attachment_cover", "actor", "library"
) )
try: try:
obj = queryset.get() obj = queryset.get()
except models.Channel.DoesNotExist: except models.Channel.DoesNotExist:
return [] return []
obj_url = utils.join_url( obj_url = utils.join_url(
settings.FUNKWHALE_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 = [ metas = [
{"tag": "meta", "property": "og:url", "content": obj_url}, {"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 # twitter player is also supported in various software
metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid) metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid)
return metas 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)

View File

@ -7,18 +7,21 @@ from rest_framework import viewsets
from django import http from django import http
from django.db import transaction 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 django.db.utils import IntegrityError
from funkwhale_api.common import locales
from funkwhale_api.common import permissions from funkwhale_api.common import permissions
from funkwhale_api.common import preferences 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 models as federation_models
from funkwhale_api.federation import routes 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 models as music_models
from funkwhale_api.music import views as music_views from funkwhale_api.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions 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 = ( ARTIST_PREFETCH_QS = (
music_models.Artist.objects.select_related("description", "attachment_cover",) music_models.Artist.objects.select_related("description", "attachment_cover",)
@ -36,6 +39,7 @@ class ChannelsMixin(object):
class ChannelViewSet( class ChannelViewSet(
ChannelsMixin, ChannelsMixin,
MultipleLookupDetailMixin,
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
@ -43,7 +47,20 @@ class ChannelViewSet(
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
viewsets.GenericViewSet, 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 filterset_class = filters.ChannelFilter
serializer_class = serializers.ChannelSerializer serializer_class = serializers.ChannelSerializer
queryset = ( queryset = (
@ -134,6 +151,25 @@ class ChannelViewSet(
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads) data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
return response.Response(data, status=200) 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): def get_serializer_context(self):
context = super().get_serializer_context() context = super().get_serializer_context()
context["subscriptions_count"] = self.action in [ context["subscriptions_count"] = self.action in [
@ -152,7 +188,7 @@ class ChannelViewSet(
{"type": "Delete", "object": {"type": instance.actor.type}}, {"type": "Delete", "object": {"type": instance.actor.type}},
context={"actor": instance.actor}, context={"actor": instance.actor},
) )
instance.delete() instance.__class__.objects.filter(pk=instance.pk).delete()
class SubscriptionsViewSet( class SubscriptionsViewSet(

View File

@ -1,4 +1,5 @@
import html import html
import logging
import io import io
import os import os
import re import re
@ -20,6 +21,8 @@ from . import utils
EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"] EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
logger = logging.getLogger(__name__)
def should_fallback_to_spa(path): def should_fallback_to_spa(path):
if path == "/": if path == "/":
@ -270,6 +273,17 @@ class ThrottleStatusMiddleware:
return response 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: class ProfilerMiddleware:
""" """
from https://github.com/omarish/django-cprofile-middleware/blob/master/django_cprofile_middleware/middleware.py from https://github.com/omarish/django-cprofile-middleware/blob/master/django_cprofile_middleware/middleware.py

View File

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

View File

@ -359,4 +359,7 @@ def remove_attached_content(sender, instance, **kwargs):
fk_fields = CONTENT_FKS.get(instance._meta.label, []) fk_fields = CONTENT_FKS.get(instance._meta.label, [])
for field in fk_fields: for field in fk_fields:
if getattr(instance, "{}_id".format(field)): if getattr(instance, "{}_id".format(field)):
getattr(instance, field).delete() try:
getattr(instance, field).delete()
except Content.DoesNotExist:
pass

View File

@ -279,7 +279,11 @@ HTML_PERMISSIVE_CLEANER = bleach.sanitizer.Cleaner(
attributes=["class", "rel", "alt", "title"], 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): def clean_html(html, permissive=False):
@ -338,29 +342,34 @@ def attach_file(obj, field, file_data, fetch=False):
if not file_data: if not file_data:
return return
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} if isinstance(file_data, models.Attachment):
extension = extensions.get(file_data["mimetype"], "jpg") attachment = file_data
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: else:
f = ContentFile(file_data["content"]) extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
attachment.file.save(filename, f, save=False) 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: if not attachment.file and fetch:
try: try:
tasks.fetch_remote_attachment(attachment, filename=filename, save=False) tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
except Exception as e: except Exception as e:
logger.warn("Cannot download attachment at url %s: %s", attachment.url, e) logger.warn(
attachment = None "Cannot download attachment at url %s: %s", attachment.url, e
)
attachment = None
if attachment: if attachment:
attachment.save() attachment.save()
setattr(obj, field, attachment) setattr(obj, field, attachment)
obj.save(update_fields=[field]) obj.save(update_fields=[field])

View File

@ -246,6 +246,8 @@ class Actor(models.Model):
return self.followers.filter(pk__in=follows.values_list("actor", flat=True)) return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def should_autoapprove_follow(self, actor): def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
return False return False
def get_user(self): def get_user(self):

View File

@ -134,7 +134,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
) )
preferredUsername = serializers.CharField() preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False) 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( summary = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH, truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False, required=False,
@ -209,6 +211,8 @@ class ActorSerializer(jsonld.JsonLdSerializer):
}, },
] ]
include_image(ret, channel.artist.attachment_cover, "icon") include_image(ret, channel.artist.attachment_cover, "icon")
if channel.artist.description_id:
ret["summary"] = channel.artist.description.rendered
else: else:
ret["url"] = [ ret["url"] = [
{ {

View File

@ -71,7 +71,7 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
@action(methods=["get", "post"], detail=True) @action(methods=["get", "post"], detail=True)
def outbox(self, request, *args, **kwargs): def outbox(self, request, *args, **kwargs):
actor = self.get_object() actor = self.get_object()
channel = actor.channel channel = actor.get_channel()
if channel: if channel:
return self.get_channel_outbox_response(request, channel) return self.get_channel_outbox_response(request, channel)
return response.Response({}, status=200) return response.Response({}, status=200)

View File

@ -107,12 +107,14 @@ class TrackFilter(
class UploadFilter(audio_filters.IncludeChannelsFilterSet): class UploadFilter(audio_filters.IncludeChannelsFilterSet):
library = filters.CharFilter("library__uuid") library = filters.CharFilter("library__uuid")
channel = filters.CharFilter("library__channel__uuid")
track = filters.UUIDFilter("track__uuid") track = filters.UUIDFilter("track__uuid")
track_artist = filters.UUIDFilter("track__artist__uuid") track_artist = filters.UUIDFilter("track__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist__uuid") album_artist = filters.UUIDFilter("track__album__artist__uuid")
library = filters.UUIDFilter("library__uuid") library = filters.UUIDFilter("library__uuid")
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True) scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True)
import_status = common_filters.MultipleQueryFilter(coerce=str)
q = fields.SmartSearchFilter( q = fields.SmartSearchFilter(
config=search.SearchConfig( config=search.SearchConfig(
search_fields={ search_fields={
@ -143,6 +145,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
"library", "library",
"import_reference", "import_reference",
"scope", "scope",
"channel",
] ]
include_channels_field = "track__artist__channel" include_channels_field = "track__artist__channel"

View File

@ -30,12 +30,12 @@ def load(data):
try: try:
license = existing_by_code[row["code"]] license = existing_by_code[row["code"]]
except KeyError: except KeyError:
logger.info("Loading new license: {}".format(row["code"])) logger.debug("Loading new license: {}".format(row["code"]))
to_create.append( to_create.append(
models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS}) models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS})
) )
else: else:
logger.info("Updating license: {}".format(row["code"])) logger.debug("Updating license: {}".format(row["code"]))
stored = [getattr(license, f) for f in MODEL_FIELDS] stored = [getattr(license, f) for f in MODEL_FIELDS]
wanted = [row[f] for f in MODEL_FIELDS] wanted = [row[f] for f in MODEL_FIELDS]
if wanted == stored: if wanted == stored:

View File

@ -512,9 +512,10 @@ class ArtistField(serializers.Field):
mbid = None mbid = None
artist = {"name": name, "mbid": mbid} artist = {"name": name, "mbid": mbid}
final.append(artist) final.append(artist)
field = serializers.ListField(
field = serializers.ListField(child=ArtistSerializer(), min_length=1) child=ArtistSerializer(strict=self.context.get("strict", True)),
min_length=1,
)
return field.to_internal_value(final) return field.to_internal_value(final)
@ -647,15 +648,29 @@ class MBIDField(serializers.UUIDField):
class ArtistSerializer(serializers.Serializer): class ArtistSerializer(serializers.Serializer):
name = serializers.CharField() name = serializers.CharField(required=False, allow_null=True)
mbid = MBIDField() 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): class AlbumSerializer(serializers.Serializer):
title = serializers.CharField() title = serializers.CharField(required=False, allow_null=True)
mbid = MBIDField() mbid = MBIDField()
release_date = PermissiveDateField(required=False, allow_null=True) 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): class PositionField(serializers.CharField):
def to_internal_value(self, v): def to_internal_value(self, v):
@ -691,7 +706,7 @@ class DescriptionField(serializers.CharField):
class TrackMetadataSerializer(serializers.Serializer): 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) position = PositionField(allow_blank=True, allow_null=True, required=False)
disc_number = 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) copyright = serializers.CharField(allow_blank=True, allow_null=True, required=False)
@ -714,6 +729,11 @@ class TrackMetadataSerializer(serializers.Serializer):
"tags", "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): def validate(self, validated_data):
validated_data = super().validate(validated_data) validated_data = super().validate(validated_data)
for field in self.remove_blank_null_fields: for field in self.remove_blank_null_fields:

View File

@ -950,7 +950,11 @@ class Upload(models.Model):
def get_all_tagged_items(self): def get_all_tagged_items(self):
track_tags = self.track.tagged_items.all() 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() artist_tags = self.track.artist.tagged_items.all()
items = (track_tags | album_tags | artist_tags).order_by("tag__name") items = (track_tags | album_tags | artist_tags).order_by("tag__name")

View File

@ -6,16 +6,30 @@ from django.conf import settings
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_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 serializers as common_serializers
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.playlists import models as playlists_models 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 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( class CoverField(
@ -381,9 +395,30 @@ class UploadSerializer(serializers.ModelSerializer):
"import_date", "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): class ImportMetadataSerializer(serializers.Serializer):
title = serializers.CharField(max_length=500, required=True) 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) mbid = serializers.UUIDField(required=False, allow_null=True)
copyright = serializers.CharField(max_length=500, 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) position = serializers.IntegerField(min_value=1, required=False, allow_null=True)
@ -391,12 +426,32 @@ class ImportMetadataSerializer(serializers.Serializer):
license = common_serializers.RelatedField( license = common_serializers.RelatedField(
"code", LicenseSerializer(), required=False, allow_null=True "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): class ImportMetadataField(serializers.JSONField):
def to_internal_value(self, v): def to_internal_value(self, v):
v = super().to_internal_value(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) s.is_valid(raise_exception=True)
return v return v
@ -464,6 +519,7 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
actions = [ actions = [
common_serializers.Action("delete", allow_all=True), common_serializers.Action("delete", allow_all=True),
common_serializers.Action("relaunch_import", allow_all=True), common_serializers.Action("relaunch_import", allow_all=True),
common_serializers.Action("publish", allow_all=False),
] ]
filterset_class = filters.UploadFilter filterset_class = filters.UploadFilter
pk_field = "uuid" pk_field = "uuid"
@ -490,10 +546,18 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
for pk in pks: for pk in pks:
common_utils.on_commit(tasks.process_upload.delay, upload_id=pk) 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 TagSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Tag model = tag_models.Tag
fields = ("id", "name", "creation_date") fields = ("id", "name", "creation_date")
@ -509,7 +573,7 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
name = serializers.CharField(source="title") name = serializers.CharField(source="title")
artist = serializers.CharField(source="artist.name") artist = serializers.CharField(source="artist.name")
album = serializers.CharField(source="album.title") album = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Track model = models.Track
@ -518,6 +582,10 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
def get_type(self, obj): def get_type(self, obj):
return "Audio" return "Audio"
def get_album(self, o):
if o.album:
return o.album.title
def get_embed_url(type, id): def get_embed_url(type, id):
return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id) return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id)
@ -561,7 +629,13 @@ class OembedSerializer(serializers.Serializer):
embed_type = "track" embed_type = "track"
embed_id = track.pk embed_id = track.pk
data["title"] = "{} by {}".format(track.title, track.artist.name) 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[ data[
"thumbnail_url" "thumbnail_url"
] = track.album.attachment_cover.download_url_medium_square_crop ] = track.album.attachment_cover.download_url_medium_square_crop
@ -630,7 +704,16 @@ class OembedSerializer(serializers.Serializer):
elif match.url_name == "channel_detail": elif match.url_name == "channel_detail":
from funkwhale_api.audio.models import Channel 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" "artist__attachment_cover"
) )
try: try:
@ -705,3 +788,46 @@ class OembedSerializer(serializers.Serializer):
def create(self, data): def create(self, data):
return 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

View File

@ -49,16 +49,29 @@ def library_track(request, pk):
utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.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( metas.append(
{ {
"tag": "meta", "tag": "meta",

View File

@ -175,46 +175,69 @@ def fail_import(upload, error_code, detail=None, **fields):
"upload", "upload",
) )
def process_upload(upload, update_denormalization=True): 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 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 {} import_metadata = upload.import_metadata or {}
internal_config = {"funkwhale": import_metadata.get("funkwhale", {})} internal_config = {"funkwhale": import_metadata.get("funkwhale", {})}
forced_values_serializer = serializers.ImportMetadataSerializer( forced_values_serializer = serializers.ImportMetadataSerializer(
data=import_metadata data=import_metadata,
context={"actor": upload.library.actor, "channel": channel},
) )
if forced_values_serializer.is_valid(): if forced_values_serializer.is_valid():
forced_values = forced_values_serializer.validated_data forced_values = forced_values_serializer.validated_data
else: else:
forced_values = {} 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 # ensure the upload is associated with the channel artist
forced_values["artist"] = upload.library.channel.artist forced_values["artist"] = upload.library.channel.artist
old_status = upload.import_status old_status = upload.import_status
audio_file = upload.get_audio_file() additional_data = {"upload_source": upload.source}
additional_data = {}
m = metadata.Metadata(audio_file) if use_file_metadata:
try: audio_file = upload.get_audio_file()
serializer = metadata.TrackMetadataSerializer(data=m)
serializer.is_valid() m = metadata.Metadata(audio_file)
except Exception:
fail_import(upload, "unknown_error")
raise
if not serializer.is_valid():
detail = serializer.errors
try: try:
metadata_dump = m.all() serializer = metadata.TrackMetadataSerializer(data=m)
except Exception as e: serializer.is_valid()
logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e)) except Exception:
return fail_import( fail_import(upload, "unknown_error")
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump 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( final_metadata = collections.ChainMap(
additional_data, serializer.validated_data, internal_config additional_data, serializer.validated_data, internal_config
) )
additional_data["upload_source"] = upload.source else:
final_metadata = collections.ChainMap(
additional_data, forced_values, internal_config,
)
try: try:
track = get_track_from_import_metadata( track = get_track_from_import_metadata(
final_metadata, attributed_to=upload.library.actor, **forced_values 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 # update album cover, if needed
if not track.album.attachment_cover: if track.album and not track.album.attachment_cover:
populate_album_cover( populate_album_cover(
track.album, source=final_metadata.get("upload_source"), track.album, source=final_metadata.get("upload_source"),
) )
@ -466,7 +489,11 @@ def _get_track(data, attributed_to=None, **forced_values):
track_mbid = ( track_mbid = (
forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None) 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") track_fid = getter(data, "fid")
query = None query = None
@ -528,81 +555,94 @@ def _get_track(data, attributed_to=None, **forced_values):
if "album" in forced_values: if "album" in forced_values:
album = forced_values["album"] album = forced_values["album"]
else: else:
album_artists = getter(data, "album", "artists", default=artists) or artists if "artist" in forced_values:
album_artist_data = album_artists[0] album_artist = forced_values["artist"]
album_artist_name = truncate(
album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
)
if album_artist_name == artist_name:
album_artist = artist
else: else:
query = Q(name__iexact=album_artist_name) album_artists = getter(data, "album", "artists", default=artists) or artists
album_artist_mbid = album_artist_data.get("mbid", None) album_artist_data = album_artists[0]
album_artist_fid = album_artist_data.get("fid", None) album_artist_name = truncate(
if album_artist_mbid: album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
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: if album_artist_name == artist_name:
tags_models.add_tags(album_artist, *album_artist_data.get("tags", [])) album_artist = artist
common_utils.attach_content( else:
album_artist, "description", album_artist_data.get("description") query = Q(name__iexact=album_artist_name)
) album_artist_mbid = album_artist_data.get("mbid", None)
common_utils.attach_file( album_artist_fid = album_artist_data.get("fid", None)
album_artist, if album_artist_mbid:
"attachment_cover", query |= Q(mbid=album_artist_mbid)
album_artist_data.get("cover_data"), 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 # get / create album
album_data = data["album"] if "album" in data:
album_title = truncate(album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"]) album_data = data["album"]
album_fid = album_data.get("fid", None) album_title = truncate(
album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"]
)
album_fid = album_data.get("fid", None)
if album_mbid: if album_mbid:
query = Q(mbid=album_mbid) query = Q(mbid=album_mbid)
else:
query = Q(title__iexact=album_title, artist=album_artist)
if album_fid:
query |= Q(fid=album_fid)
defaults = {
"title": album_title,
"artist": album_artist,
"mbid": album_mbid,
"release_date": album_data.get("release_date"),
"fid": album_fid,
"from_activity_id": from_activity_id,
"attributed_to": album_data.get("attributed_to", attributed_to),
}
if album_data.get("fdate"):
defaults["creation_date"] = album_data.get("fdate")
album, created = get_best_candidate_or_create(
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
)
if created:
tags_models.add_tags(album, *album_data.get("tags", []))
common_utils.attach_content(
album, "description", album_data.get("description")
)
common_utils.attach_file(
album, "attachment_cover", album_data.get("cover_data")
)
else: else:
query = Q(title__iexact=album_title, artist=album_artist) album = None
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")
)
# get / create track # get / create track
track_title = ( track_title = (
forced_values["title"] forced_values["title"]
@ -629,6 +669,14 @@ def _get_track(data, attributed_to=None, **forced_values):
if "copyright" in forced_values if "copyright" in forced_values
else truncate(data.get("copyright"), models.MAX_LENGTHS["COPYRIGHT"]) 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( query = Q(
title__iexact=track_title, 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", []) forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
) )
tags_models.add_tags(track, *tags) tags_models.add_tags(track, *tags)
common_utils.attach_content(track, "description", data.get("description")) common_utils.attach_content(track, "description", description)
common_utils.attach_file(track, "attachment_cover", data.get("cover_data")) common_utils.attach_file(track, "attachment_cover", cover_data)
return track return track

View File

@ -173,6 +173,8 @@ class ArtistViewSet(
class AlbumViewSet( class AlbumViewSet(
HandleInvalidSearch, HandleInvalidSearch,
common_views.SkipFilterForGetObject, common_views.SkipFilterForGetObject,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
viewsets.ReadOnlyModelViewSet, viewsets.ReadOnlyModelViewSet,
): ):
queryset = ( queryset = (
@ -202,11 +204,19 @@ class AlbumViewSet(
def get_serializer_context(self): def get_serializer_context(self):
context = super().get_serializer_context() 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 return context
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action in ["destroy"]:
queryset = queryset.exclude(artist__channel=None).filter(
artist__attributed_to=self.request.user.actor
)
tracks = ( tracks = (
models.Track.objects.prefetch_related("artist") models.Track.objects.prefetch_related("artist")
.with_playable_uploads(utils.get_actor_from_request(self.request)) .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)) 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( class LibraryViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
@ -288,6 +303,7 @@ class LibraryViewSet(
class TrackViewSet( class TrackViewSet(
HandleInvalidSearch, HandleInvalidSearch,
common_views.SkipFilterForGetObject, common_views.SkipFilterForGetObject,
mixins.DestroyModelMixin,
viewsets.ReadOnlyModelViewSet, viewsets.ReadOnlyModelViewSet,
): ):
""" """
@ -330,6 +346,10 @@ class TrackViewSet(
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() 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) filter_favorites = self.request.GET.get("favorites", None)
user = self.request.user user = self.request.user
if user.is_authenticated and filter_favorites == "true": if user.is_authenticated and filter_favorites == "true":
@ -617,18 +637,17 @@ class UploadViewSet(
m = tasks.metadata.Metadata(upload.get_audio_file()) m = tasks.metadata.Metadata(upload.get_audio_file())
except FileNotFoundError: except FileNotFoundError:
return Response({"detail": "File not found"}, status=500) 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(): if not serializer.is_valid():
return Response(serializer.errors, status=500) return Response(serializer.errors, status=500)
payload = serializer.validated_data payload = serializer.validated_data
if ( cover_data = payload.get(
"cover_data" in payload "cover_data", payload.get("album", {}).get("cover_data", {})
and payload["cover_data"] )
and "content" in payload["cover_data"] if cover_data and "content" in cover_data:
): cover_data["content"] = base64.b64encode(cover_data["content"])
payload["cover_data"]["content"] = base64.b64encode(
payload["cover_data"]["content"]
)
return Response(payload, status=200) return Response(payload, status=200)
@action(methods=["post"], detail=False) @action(methods=["post"], detail=False)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -1,3 +1,5 @@
import pytest
from django.urls import reverse from django.urls import reverse
from funkwhale_api.federation import utils as federation_utils 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): def test_channel_get_rss_url_local(factories):
channel = factories["audio.Channel"](artist__local=True) channel = factories["audio.Channel"](artist__local=True)
expected = federation_utils.full_url( 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 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): def test_channel_get_rss_url_remote(factories):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
assert channel.get_rss_url() == channel.rss_url 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()

View File

@ -3,6 +3,8 @@ import datetime
import pytest import pytest
import pytz import pytz
from django.contrib.staticfiles.templatetags.staticfiles import static
from funkwhale_api.audio import serializers from funkwhale_api.audio import serializers
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils 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 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) attributed_to = factories["federation.Actor"](local=True)
attachment = factories["common.Attachment"](actor=attributed_to)
request = mocker.Mock(user=mocker.Mock(actor=attributed_to))
data = { data = {
# TODO: cover
"name": "My channel", "name": "My channel",
"username": "mychannel", "username": "mychannel",
"description": {"text": "This is my channel", "content_type": "text/markdown"}, "description": {"text": "This is my channel", "content_type": "text/markdown"},
"tags": ["hello", "world"], "tags": ["hello", "world"],
"content_category": "other", "content_category": "other",
"cover": attachment.uuid,
} }
serializer = serializers.ChannelCreateSerializer( 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 assert serializer.is_valid(raise_exception=True) is True
@ -37,14 +40,12 @@ def test_channel_serializer_create(factories):
== data["tags"] == data["tags"]
) )
assert channel.artist.description.text == data["description"]["text"] 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.content_category == data["content_category"]
assert ( assert (
channel.artist.description.content_type == data["description"]["content_type"] channel.artist.description.content_type == data["description"]["content_type"]
) )
assert channel.attributed_to == attributed_to 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.preferred_username == data["username"]
assert channel.actor.name == data["name"] assert channel.actor.name == data["name"]
assert channel.library.privacy_level == "everyone" assert channel.library.privacy_level == "everyone"
@ -150,24 +151,31 @@ def test_channel_serializer_create_podcast(factories):
assert channel.metadata == data["metadata"] assert channel.metadata == data["metadata"]
def test_channel_serializer_update(factories): def test_channel_serializer_update(factories, mocker):
channel = factories["audio.Channel"](artist__set_tags=["rock"]) 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 = { data = {
# TODO: cover
"name": "My channel", "name": "My channel",
"description": {"text": "This is my channel", "content_type": "text/markdown"}, "description": {"text": "This is my channel", "content_type": "text/markdown"},
"tags": ["hello", "world"], "tags": ["hello", "world"],
"content_category": "other", "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 assert serializer.is_valid(raise_exception=True) is True
serializer.save() serializer.save()
channel.refresh_from_db() channel.refresh_from_db()
assert channel.artist.name == data["name"] assert channel.artist.name == data["name"]
assert channel.artist.attachment_cover == attachment
assert channel.artist.content_category == data["content_category"] assert channel.artist.content_category == data["content_category"]
assert ( assert (
sorted(channel.artist.tagged_items.values_list("tag__name", flat=True)) 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")), "url": federation_utils.full_url(upload.get_listen_url("mp3")),
"length": upload.size, "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 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): def test_serialize_full_channel(factories):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
upload1 = factories["music.Upload"](playable=True) upload1 = factories["music.Upload"](playable=True)

View File

@ -1,3 +1,5 @@
import pytest
import urllib.parse import urllib.parse
from django.urls import reverse from django.urls import reverse
@ -7,18 +9,21 @@ from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import serializers 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"]() channel = factories["audio.Channel"]()
factories["music.Upload"](playable=True, library=channel.library) 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) response = client.get(url)
assert response.status_code == 200
expected_metas = [ expected_metas = [
{ {
"tag": "meta", "tag": "meta",
"property": "og:url", "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:title", "content": channel.artist.name},
{"tag": "meta", "property": "og:type", "content": "profile"}, {"tag": "meta", "property": "og:type", "content": "profile"},
@ -47,7 +52,9 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings):
"href": ( "href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?format=json&url={}".format( + "?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)
)
) )
), ),
}, },

View File

@ -3,8 +3,11 @@ import pytest
from django.urls import reverse from django.urls import reverse
from funkwhale_api.audio import categories
from funkwhale_api.audio import serializers from funkwhale_api.audio import serializers
from funkwhale_api.audio import views 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): def test_channel_create(logged_in_api_client):
@ -38,15 +41,25 @@ def test_channel_create(logged_in_api_client):
== data["tags"] == data["tags"]
) )
assert channel.attributed_to == actor 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.actor.preferred_username == data["username"]
assert channel.library.privacy_level == "everyone" assert channel.library.privacy_level == "everyone"
assert channel.library.actor == actor assert channel.library.actor == actor
def test_channel_detail(factories, logged_in_api_client): @pytest.mark.parametrize(
channel = factories["audio.Channel"](artist__description=None) "field", ["uuid", "actor.preferred_username", "actor.full_username"],
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid}) )
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, "_tracks_count", 0)
setattr(channel.artist, "_prefetched_tagged_items", []) setattr(channel.artist, "_prefetched_tagged_items", [])
@ -85,7 +98,7 @@ def test_channel_update(logged_in_api_client, factories):
"name": "new name" "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) response = logged_in_api_client.patch(url, data)
assert response.status_code == 200 assert response.status_code == 200
@ -101,7 +114,7 @@ def test_channel_update_permission(logged_in_api_client, factories):
data = {"name": "new name"} 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) response = logged_in_api_client.patch(url, data)
assert response.status_code == 403 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() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=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") dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
response = logged_in_api_client.delete(url) 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() logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]() 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) response = logged_in_api_client.patch(url)
assert response.status_code == 403 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): def test_channel_subscribe(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](artist__description=None) 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) 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() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor) 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) 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] 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) 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 preferences["common__api_authentication_required"] = False
channel = factories["audio.Channel"]() 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) 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 preferences["common__api_authentication_required"] = True
channel = factories["audio.Channel"](local=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) response = api_client.get(url)
assert response.status_code == 401 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

View File

@ -174,6 +174,17 @@ def test_attach_file_url_fetch(factories, r_mock):
assert new_attachment.mimetype == data["mimetype"] 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): def test_attach_file_content(factories, r_mock):
album = factories["music.Album"]() album = factories["music.Album"]()

View File

@ -78,12 +78,21 @@ def test_actor_get_quota(factories):
audio_file__data=b"aaaa", 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 = { expected = {
"total": 19, "total": 24,
"pending": 1, "pending": 1,
"skipped": 2, "skipped": 2,
"errored": 3, "errored": 3,
"finished": 8, "finished": 13,
"draft": 5, "draft": 5,
} }

View File

@ -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): def test_inbox_follow_library_manual_approve(factories, mocker):
mocked_outbox_dispatch = mocker.patch( mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch" "funkwhale_api.federation.activity.OutboxRouter.dispatch"

View File

@ -657,6 +657,34 @@ def test_serializer_empty_fields(field_name):
assert serializer.validated_data == expected 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(): def test_artist_field_featuring():
data = { data = {
"artist": "Santana feat. Chris Cornell", "artist": "Santana feat. Chris Cornell",

View File

@ -361,6 +361,19 @@ def test_manage_upload_action_relaunch_import(factories, mocker):
assert m.call_count == 3 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): def test_serialize_upload(factories):
upload = factories["music.Upload"]() upload = factories["music.Upload"]()
@ -511,6 +524,18 @@ def test_upload_import_metadata_serializer_full():
assert serializer.validated_data == expected 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): def test_upload_with_channel_keeps_import_metadata(factories, uploaded_audio_file):
channel = factories["audio.Channel"](attributed_to__local=True) channel = factories["audio.Channel"](attributed_to__local=True)
user = channel.attributed_to.user user = channel.attributed_to.user

View File

@ -7,7 +7,9 @@ from funkwhale_api.music import serializers
def test_library_track(spa_html, no_api_auth, client, factories, settings): 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 track = upload.track
url = "/library/tracks/{}".format(track.pk) url = "/library/tracks/{}".format(track.pk)

View File

@ -1014,45 +1014,73 @@ def test_get_track_from_import_metadata_with_forced_values(factories, mocker, fa
) )
def test_get_track_from_import_metadata_with_forced_values_album(
factories, mocker, faker
):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
forced_values = {
"title": "Real title",
"album": album.pk,
}
upload = factories["music.Upload"](
import_metadata=forced_values, library=channel.library, track=None
)
tasks.process_upload(upload_id=upload.pk)
upload.refresh_from_db()
assert upload.import_status == "finished"
assert upload.track.title == forced_values["title"]
assert upload.track.album == album
assert upload.track.artist == channel.artist
def test_process_channel_upload_forces_artist_and_attributed_to( def test_process_channel_upload_forces_artist_and_attributed_to(
factories, mocker, faker factories, mocker, faker
): ):
track = factories["music.Track"]() channel = factories["audio.Channel"](attributed_to__local=True)
channel = factories["audio.Channel"]() attachment = factories["common.Attachment"](actor=channel.attributed_to)
import_metadata = { import_metadata = {
"title": "Real title", "title": "Real title",
"position": 3, "position": 3,
"copyright": "Real copyright", "copyright": "Real copyright",
"tags": ["hello", "world"], "tags": ["hello", "world"],
"description": "my description",
"cover": attachment.uuid,
} }
expected_forced_values = import_metadata.copy() expected_forced_values = import_metadata.copy()
expected_forced_values["artist"] = channel.artist expected_forced_values["artist"] = channel.artist
expected_forced_values["attributed_to"] = channel.attributed_to expected_forced_values["cover"] = attachment
upload = factories["music.Upload"]( upload = factories["music.Upload"](
track=None, import_metadata=import_metadata, library=channel.library track=None, import_metadata=import_metadata, library=channel.library
) )
get_track_from_import_metadata = mocker.patch.object( get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
tasks, "get_track_from_import_metadata", return_value=track
)
tasks.process_upload(upload_id=upload.pk) tasks.process_upload(upload_id=upload.pk)
upload.refresh_from_db() 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( expected_final_metadata = tasks.collections.ChainMap(
{"upload_source": None}, audio_metadata, {"funkwhale": {}}, {"upload_source": None}, expected_forced_values, {"funkwhale": {}},
) )
assert upload.import_status == "finished" assert upload.import_status == "finished"
get_track_from_import_metadata.assert_called_once_with( 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): def test_process_upload_uses_import_metadata_if_valid(factories, mocker):
track = factories["music.Track"]() track = factories["music.Track"]()

View File

@ -723,6 +723,7 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f
"source": "upload://test", "source": "upload://test",
"import_reference": "test", "import_reference": "test",
"library": library.uuid, "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.source == "upload://test"
assert upload.import_reference == "test" assert upload.import_reference == "test"
assert upload.import_status == "pending" 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 assert upload.track is None
m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk) 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) response = logged_in_api_client.get(url)
assert response.data["description"] is None 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()

View File

@ -16,7 +16,7 @@
"axios": "^0.18.0", "axios": "^0.18.0",
"diff": "^4.0.1", "diff": "^4.0.1",
"django-channels": "^1.1.6", "django-channels": "^1.1.6",
"fomantic-ui-css": "^2.7", "fomantic-ui-css": "^2.8.3",
"howler": "^2.0.14", "howler": "^2.0.14",
"js-logger": "^1.4.1", "js-logger": "^1.4.1",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",

View File

@ -1,5 +1,5 @@
<template> <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 --> <!-- here, we display custom stylesheets, if any -->
<link <link
v-for="url in customStylesheets" v-for="url in customStylesheets"
@ -33,6 +33,7 @@
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal" @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
></app-footer> ></app-footer>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> <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> <filter-modal v-if="$store.state.auth.authenticated"></filter-modal>
<report-modal></report-modal> <report-modal></report-modal>
<shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal> <shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
@ -57,6 +58,7 @@ export default {
Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"), Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"),
Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"), Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"),
PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"), PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"),
ChannelUploadModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/channels/UploadModal"),
Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"), Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"),
AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"), AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"),
ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"), ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"),
@ -393,6 +395,13 @@ export default {
top: 0; top: 0;
} }
} }
.dimmed {
.ui.bottom-player {
@include media("<desktop") {
z-index: 0;
}
}
}
#app.queue-focused { #app.queue-focused {
.queue-not-focused { .queue-not-focused {
@include media("<desktop") { @include media("<desktop") {

View File

@ -103,7 +103,7 @@
</td> </td>
<td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></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="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> <div class="ellipsis " v-if="track.album" :title="track.album.title">{{ track.album.title }}</div>
</td> </td>
<td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</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"}) this.fetchTracks({channel: id, playable: true, include_channels: 'true', ordering: "-creation_date"})
} }
if (type === 'artist') { 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') { if (type === 'playlist') {
this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`) this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`)

View File

@ -35,7 +35,7 @@
</form> </form>
</div> </div>
<div class="actions"> <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> </div>
</modal> </modal>
</template> </template>

View File

@ -36,7 +36,7 @@
</div> </div>
</section> </section>
<footer class="actions"> <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> </footer>
</modal> </modal>
</template> </template>

View File

@ -1,13 +1,13 @@
<template> <template>
<div class="card app-card"> <div class="card app-card">
<div <div
@click="$router.push({name: 'channels.detail', params: {id: object.uuid}})" @click="$router.push({name: 'channels.detail', params: {id: urlId}})"
:class="['ui', 'head-image', 'padded image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl"> :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> <play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object.artist"></play-button>
</div> </div>
<div class="content"> <div class="content">
<strong> <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 }} {{ object.artist.name }}
</router-link> </router-link>
</strong> </strong>
@ -49,6 +49,15 @@ export default {
return null return null
} }
return url 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
}
} }
} }
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="channel-entry-card"> <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"> <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"> <div class="content">
<strong> <strong>
@ -39,6 +39,9 @@ export default {
return url return url
}, },
cover () { cover () {
if (this.entry.cover) {
return this.entry.cover
}
if (this.entry.album && this.entry.album.cover) { if (this.entry.album && this.entry.album.cover) {
return this.entry.album.cover return this.entry.album.cover
} }

View File

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

View File

@ -1,5 +1,5 @@
<template> <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 <button
v-if="!dropdownOnly" v-if="!dropdownOnly"
:title="labels.playNow" :title="labels.playNow"
@ -102,7 +102,9 @@ export default {
} }
if (this.track) { if (this.track) {
return this.track.uploads && this.track.uploads.length > 0 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 this.artist.albums.filter((a) => {
return a.is_playable === true return a.is_playable === true
}).length > 0 }).length > 0
@ -189,10 +191,10 @@ export default {
resolve(tracks) resolve(tracks)
}) })
} else if (self.artist) { } 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) self.getTracksPage(1, params, resolve)
} else if (self.album) { } 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) self.getTracksPage(1, params, resolve)
} }
}) })
@ -255,9 +257,27 @@ export default {
// works as expected // works as expected
self.$refs[$el.data('ref')].click() self.$refs[$el.data('ref')].click()
jQuery(self.$el).find('.ui.dropdown').dropdown('hide') 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')
}) })
} }
} }

View File

@ -48,7 +48,9 @@
@input="submitAvatar($event)" @input="submitAvatar($event)"
:initial-value="initialAvatar" :initial-value="initialAvatar"
:required="false" :required="false"
@delete="avatar = {uuid: null}"></attachment-input> @delete="avatar = {uuid: null}">
<translate translate-context="Content/Channel/*" slot="label">Avatar</translate>
</attachment-input>
</div> </div>
</section> </section>
@ -79,8 +81,7 @@
<password-input required v-model="new_password" /> <password-input required v-model="new_password" />
</div> </div>
<dangerous-button <dangerous-button
color="yellow" :class="['ui', {'loading': isLoading}, 'yellow', 'button']"
:class="['ui', {'loading': isLoading}, 'button']"
:action="submitPassword"> :action="submitPassword">
<translate translate-context="Content/Settings/Button.Label">Change password</translate> <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> <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Change your password?</translate></p>
@ -177,7 +178,7 @@
</td> </td>
<td> <td>
<dangerous-button <dangerous-button
class="ui tiny basic button" class="ui tiny basic red button"
@confirm="revokeApp(app.client_id)"> @confirm="revokeApp(app.client_id)">
<translate translate-context="*/*/*/Verb">Revoke</translate> <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> <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> <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link> </router-link>
<dangerous-button <dangerous-button
class="ui tiny basic button" class="ui tiny basic red button"
@confirm="deleteApp(app.client_id)"> @confirm="deleteApp(app.client_id)">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Delete application "%{ application }"?</p> <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" /> <password-input required v-model="password" />
</div> </div>
<dangerous-button <dangerous-button
:class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'button']" :class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'red', 'button']"
:action="deleteAccount"> :action="deleteAccount">
<translate translate-context="*/*/Button.Label">Delete my account</translate> <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> <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Do you want to delete your account?</translate></p>

View File

@ -33,8 +33,7 @@
</div> </div>
<dangerous-button <dangerous-button
v-if="token" v-if="token"
color="grey" :class="['ui', {'loading': isLoading}, 'grey', 'button']"
:class="['ui', {'loading': isLoading}, 'button']"
:action="requestNewToken"> :action="requestNewToken">
<translate translate-context="*/Settings/Button.Label/Verb">Request a new password</translate> <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> <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> @click="requestNewToken"><translate translate-context="Content/Settings/Button.Label/Verb">Request a password</translate></button>
<dangerous-button <dangerous-button
v-if="token" v-if="token"
color="yellow" :class="['ui', {'loading': isLoading}, 'yellow', 'button']"
:class="['ui', {'loading': isLoading}, 'button']"
:action="disable"> :action="disable">
<translate translate-context="Content/Settings/Button.Label/Verb">Disable Subsonic access</translate> <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> <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Disable Subsonic API access?</translate></p>

View File

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

View File

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

View File

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

View File

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

View File

@ -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>&nbsp;
<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>

View File

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

View File

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

View File

@ -31,7 +31,6 @@
<dangerous-button <dangerous-button
v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
:confirm-color="currentAction.confirmColor || 'green'" :confirm-color="currentAction.confirmColor || 'green'"
color=""
@confirm="launchAction"> @confirm="launchAction">
<translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate> <translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate>
<p slot="modal-header"> <p slot="modal-header">

View File

@ -1,25 +1,33 @@
<template> <template>
<div> <div class="ui form">
<div v-if="errors.length > 0" class="ui negative message"> <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> <div class="header"><translate translate-context="Content/*/Error message.Title">Your attachment cannot be saved</translate></div>
<ul class="list"> <ul class="list">
<li v-for="error in errors">{{ error }}</li> <li v-for="error in errors">{{ error }}</li>
</ul> </ul>
</div> </div>
<div class="ui stackable two column grid"> <div class="ui field">
<div class="column" v-if="value && value === initialValue"> <label :for="attachmentId">
<h3 class="ui header"><translate translate-context="Content/*/Title/Noun">Current file</translate></h3> <slot name="label"></slot>
<img class="ui image" v-if="value" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" /> </label>
</div> <div class="ui stackable grid row">
<div class="column" v-else-if="attachment"> <div class="three wide column">
<h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3> <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 image" v-if="attachment && attachment.square_crop" :src="$store.getters['instance/absoluteUrl'](attachment.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> <div :class="['ui', imageClass, 'static', 'large placeholder image']" v-else></div>
<div class="column" v-if="!attachment"> </div>
<div class="ui basic segment"> <div class="eleven wide column">
<h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3> <div class="file-input">
<p><translate translate-context="Content/*/Paragraph">PNG or JPG. At most 5MB. Will be downscaled to 400x400px.</translate></p> <label class="ui basic button" :for="attachmentId">
<input class="ui input" ref="attachment" type="file" accept="image/x-png,image/jpeg" @change="submit" /> <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 v-if="isLoading" class="ui active inverted dimmer">
<div class="ui indeterminate text loader"> <div class="ui indeterminate text loader">
<translate translate-context="Content/*/*/Noun">Uploading file</translate> <translate translate-context="Content/*/*/Noun">Uploading file</translate>
@ -27,7 +35,6 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@ -35,12 +42,17 @@
import axios from 'axios' import axios from 'axios'
export default { export default {
props: ['value', 'initialValue'], props: {
value: {},
imageClass: {default: '', required: false}
},
data () { data () {
return { return {
attachment: null, attachment: null,
isLoading: false, isLoading: false,
errors: [], errors: [],
initialValue: this.value,
attachmentId: Math.random().toString(36).substring(7),
} }
}, },
methods: { methods: {
@ -69,11 +81,11 @@ export default {
} }
) )
}, },
remove() { remove(uuid) {
this.isLoading = true this.isLoading = true
this.errors = [] this.errors = []
let self = this let self = this
axios.delete(`attachments/${this.attachment.uuid}/`) axios.delete(`attachments/${uuid}/`)
.then( .then(
response => { response => {
this.isLoading = false this.isLoading = false
@ -91,7 +103,7 @@ export default {
value (v) { value (v) {
if (this.attachment && v === this.initialValue) { if (this.attachment && v === this.initialValue) {
// we had a reset to initial value // we had a reset to initial value
this.remove() this.remove(this.attachment.uuid)
} }
} }
} }

View File

@ -3,7 +3,7 @@
<p class="message" v-if="copied"> <p class="message" v-if="copied">
<translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate> <translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate>
</p> </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']"> <button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']">
<i class="copy icon"></i> <i class="copy icon"></i>
<translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate> <translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate>
@ -43,5 +43,9 @@ export default {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: -3em; bottom: -3em;
padding: 0.3em;
box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.3);
background-color: white;
z-index: 999;
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <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> <slot></slot>
<modal class="small" :show.sync="showModal"> <modal class="small" :show.sync="showModal">
@ -14,7 +14,7 @@
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<div class="ui cancel button"> <div class="ui basic cancel button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div> </div>
<div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm"> <div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm">
@ -34,8 +34,7 @@ export default {
props: { props: {
action: {type: Function, required: false}, action: {type: Function, required: false},
disabled: {type: Boolean, default: false}, disabled: {type: Boolean, default: false},
color: {type: String, default: 'red'}, confirmColor: {type: String, default: "red", required: false}
confirmColor: {type: String, default: null, required: false}
}, },
components: { components: {
Modal Modal

View File

@ -82,7 +82,7 @@
</div> </div>
</div> </div>
<div class="actions"> <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> <translate translate-context="*/*/Button.Label/Verb">Close</translate>
</div> </div>
<div role="button" @click="showModal = false; $emit('refresh')" class="ui confirm green button" v-if="fetch && fetch.status === 'finished'"> <div role="button" @click="showModal = false; $emit('refresh')" class="ui confirm green button" v-if="fetch && fetch.status === 'finished'">

View File

@ -34,7 +34,7 @@
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<div class="ui deny button"> <div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div> </div>
</div> </div>
@ -73,6 +73,18 @@
<i class="edit icon"></i> <i class="edit icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate> <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link> </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 class="divider"></div>
<div <div
role="button" role="button"
@ -143,6 +155,7 @@ export default {
return { return {
isLoading: true, isLoading: true,
object: null, object: null,
artist: null,
discs: [], discs: [],
libraries: [], libraries: [],
showEmbedModal: false showEmbedModal: false
@ -160,8 +173,23 @@ export default {
axios.get(url, {params: {refresh: 'true'}}).then(response => { axios.get(url, {params: {refresh: 'true'}}).then(response => {
self.object = backend.Album.clean(response.data) self.object = backend.Album.clean(response.data)
self.discs = self.object.tracks.reduce(groupByDisc, []) self.discs = self.object.tracks.reduce(groupByDisc, [])
axios.get(`artists/${response.data.artist.id}/`).then(response => {
self.artist = response.data
})
self.isLoading = false 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: { computed: {

View File

@ -160,6 +160,7 @@ export default {
ordering: this.getOrderingAsString(), ordering: this.getOrderingAsString(),
playable: "true", playable: "true",
tag: this.tags, tag: this.tags,
include_channels: "true",
} }
logger.default.debug("Fetching artists") logger.default.debug("Fetching artists")
axios.get( axios.get(

View File

@ -106,7 +106,7 @@
</button> </button>
<dangerous-button <dangerous-button
v-if="canDelete" v-if="canDelete"
:class="['ui', {loading: isLoading}, 'basic button']" :class="['ui', {loading: isLoading}, 'basic red button']"
:action="remove"> :action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this suggestion?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this suggestion?</translate></p>

View File

@ -82,14 +82,15 @@
<content-form v-model="values[fieldConfig.id].text" :field-id="fieldConfig.id" :rows="3"></content-form> <content-form v-model="values[fieldConfig.id].text" :field-id="fieldConfig.id" :rows="3"></content-form>
</template> </template>
<template v-else-if="fieldConfig.type === 'attachment'"> <template v-else-if="fieldConfig.type === 'attachment'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
<attachment-input <attachment-input
v-model="values[fieldConfig.id]" v-model="values[fieldConfig.id]"
:initial-value="initialValues[fieldConfig.id]" :initial-value="initialValues[fieldConfig.id]"
:required="fieldConfig.required" :required="fieldConfig.required"
:name="fieldConfig.id" :name="fieldConfig.id"
:id="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>
<template v-else-if="fieldConfig.type === 'tags'"> <template v-else-if="fieldConfig.type === 'tags'">

View File

@ -180,7 +180,6 @@ export default {
currentTab: "summary", currentTab: "summary",
uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"), uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
importReference, importReference,
supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
isLoadingQuota: false, isLoadingQuota: false,
quotaStatus: null, quotaStatus: null,
uploads: { uploads: {
@ -283,6 +282,9 @@ export default {
} }
}, },
computed: { computed: {
supportedExtensions () {
return this.$store.state.ui.supportedExtensions
},
labels() { labels() {
let denied = this.$pgettext('Content/Library/Help text', 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" "Upload denied, ensure the file is not too big and that you have not reached your quota"

View File

@ -6,10 +6,19 @@ export default {
methods: { methods: {
uploadHtml5 (file) { uploadHtml5 (file) {
let form = new window.FormData() let form = new window.FormData()
let filename = file.file.filename || file.name
let value let value
for (let key in file.data) { let data = {...file.data}
value = file.data[key] if (data.import_metadata) {
if (value && typeof value === 'object' && typeof value.toString !== 'function') { 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) { if (value instanceof File) {
form.append(key, value, value.name) form.append(key, value, value.name)
} else { } else {
@ -19,7 +28,6 @@ export default {
form.append(key, value) form.append(key, value)
} }
} }
let filename = file.file.filename || file.name
form.append('source', `upload://${filename}`) form.append('source', `upload://${filename}`)
form.append(this.name, file.file, filename) form.append(this.name, file.file, filename)
let xhr = new XMLHttpRequest() let xhr = new XMLHttpRequest()

View File

@ -29,14 +29,30 @@ export default {
return value return value
} }
let settings = { let settings = {
keys : {
delimiter : 32,
},
forceSelection: false,
saveRemoteData: false, saveRemoteData: false,
filterRemoteData: true, filterRemoteData: true,
preserveHTML : false,
apiSettings: { apiSettings: {
url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'), url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'),
beforeXHR: function (xhrObject) { beforeXHR: function (xhrObject) {
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
return xhrObject 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: { fields: {
remoteValues: 'results', remoteValues: 'results',
@ -74,4 +90,3 @@ export default {
} }
</style> </style>

View File

@ -1,6 +1,6 @@
<template> <template>
<main> <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 :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div> </div>
<template v-if="track"> <template v-if="track">
@ -50,7 +50,7 @@
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<div class="ui deny button"> <div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div> </div>
</div> </div>
@ -89,6 +89,18 @@
<i class="edit icon"></i> <i class="edit icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate> <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link> </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 class="divider"></div>
<div <div
role="button" role="button"
@ -151,8 +163,9 @@ export default {
data() { data() {
return { return {
time, time,
isLoadingTrack: true, isLoading: true,
track: null, track: null,
artist: null,
showEmbedModal: false, showEmbedModal: false,
libraries: [] libraries: []
} }
@ -163,14 +176,29 @@ export default {
methods: { methods: {
fetchData() { fetchData() {
var self = this var self = this
this.isLoadingTrack = true this.isLoading = true
let url = FETCH_URL + this.id + "/" let url = FETCH_URL + this.id + "/"
logger.default.debug('Fetching track "' + this.id + '"') logger.default.debug('Fetching track "' + this.id + '"')
axios.get(url, {params: {refresh: 'true'}}).then(response => { axios.get(url, {params: {refresh: 'true'}}).then(response => {
self.track = response.data 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: { computed: {
publicLibraries () { publicLibraries () {
@ -224,7 +252,9 @@ export default {
return u return u
}, },
cover() { cover() {
return null if (this.track.cover) {
return this.track.cover
}
}, },
albumUrl () { albumUrl () {
let route = this.$router.resolve({name: 'library.albums.detail', params: {id: this.track.album.id }}) let route = this.$router.resolve({name: 'library.albums.detail', params: {id: this.track.album.id }})
@ -235,12 +265,12 @@ export default {
return route.href return route.href
}, },
headerStyle() { headerStyle() {
if (!this.cover) { if (!this.cover || !this.cover.original) {
return "" return ""
} }
return ( return (
"background-image: url(" + "background-image: url(" +
this.$store.getters["instance/absoluteUrl"](this.cover) + this.$store.getters["instance/absoluteUrl"](this.cover.original) +
")" ")"
) )
}, },

View File

@ -50,7 +50,7 @@
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<div class="ui black deny button"> <div class="ui basic black deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div> </div>
</div> </div>

View File

@ -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-if="object" key="1">Update</translate>
<translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-else key="2">Create</translate> <translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-else key="2">Create</translate>
</button> </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> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"> <p slot="modal-header">
<translate translate-context="Popup/Moderation/Title">Delete this moderation rule?</translate> <translate translate-context="Popup/Moderation/Title">Delete this moderation rule?</translate>

View File

@ -18,8 +18,7 @@
</div> </div>
<div class="meta"> <div class="meta">
<dangerous-button <dangerous-button
:class="['ui', {loading: isLoading}, 'basic borderless mini button']" :class="['ui', {loading: isLoading}, 'basic borderless mini grey button']"
color="grey"
@confirm="remove(note)"> @confirm="remove(note)">
<i class="trash icon"></i> <i class="trash icon"></i>
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>

View File

@ -229,7 +229,6 @@
<dangerous-button <dangerous-button
v-if="action.dangerous && action.show(obj)" v-if="action.dangerous && action.show(obj)"
:class="['ui', {loading: isLoading}, 'button']" :class="['ui', {loading: isLoading}, 'button']"
color=""
:action="action.handler"> :action="action.handler">
<i :class="[action.iconColor, action.icon, 'icon']"></i>&nbsp; <i :class="[action.iconColor, action.icon, 'icon']"></i>&nbsp;
{{ action.label }} {{ action.label }}

View File

@ -25,6 +25,10 @@ export default {
label: this.$pgettext('Content/Library/*', 'Skipped'), label: this.$pgettext('Content/Library/*', 'Skipped'),
help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries'), 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: { pending: {
label: this.$pgettext('Content/Library/*/Short', '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'), help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet'),

View File

@ -37,7 +37,7 @@
</div> </div>
</div> </div>
<div class="actions"> <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 :class="['ui', 'green', {loading: isLoading}, 'button']" @click="hide"><translate translate-context="Popup/*/Button.Label">Hide content</translate></div>
</div> </div>
</modal> </modal>

View File

@ -57,7 +57,7 @@
</div> </div>
</div> </div>
<div class="actions"> <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 <button
v-if="canSubmit" v-if="canSubmit"
:class="['ui', 'green', {loading: isLoading}, 'button']" :class="['ui', 'green', {loading: isLoading}, 'button']"

View File

@ -47,7 +47,7 @@
</translate> </translate>
</div> </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> <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}"> <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 }"? Do you want to clear the playlist "%{ playlist }"?

View File

@ -25,7 +25,7 @@
:translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"><strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>.</p> :translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"><strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>.</p>
<button <button
@click="duplicateTrackAddConfirm(false)" @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>
<button <button
class="ui small green button" class="ui small green button"
@ -101,7 +101,7 @@
</div> </div>
</div> </div>
<div class="actions"> <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> </div>
</modal> </modal>
</template> </template>

View File

@ -1,6 +1,6 @@
<template> <template>
<div :class="['ui', {'active': show}, 'modal']"> <div :class="['ui', {'active': show}, {'overlay fullscreen': ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']">
<i class="close icon"></i> <i class="close inside icon"></i>
<slot v-if="show"> <slot v-if="show">
</slot> </slot>

View File

@ -15,4 +15,5 @@ export default {
reverse: require('lodash/reverse'), reverse: require('lodash/reverse'),
isEqual: require('lodash/isEqual'), isEqual: require('lodash/isEqual'),
sum: require('lodash/sum'), sum: require('lodash/sum'),
startCase: require('lodash/startCase'),
} }

View File

@ -19,6 +19,7 @@ import { sync } from 'vuex-router-sync'
import locales from '@/locales' import locales from '@/locales'
import filters from '@/filters' // eslint-disable-line import filters from '@/filters' // eslint-disable-line
import {parseAPIErrors} from '@/utils'
import globals from '@/components/globals' // eslint-disable-line import globals from '@/components/globals' // eslint-disable-line
import './registerServiceWorker' import './registerServiceWorker'
@ -67,6 +68,7 @@ Vue.directive('title', function (el, binding) {
Vue.directive('dropdown', function (el, binding) { Vue.directive('dropdown', function (el, binding) {
jQuery(el).dropdown({ jQuery(el).dropdown({
selectOnKeydown: false, selectOnKeydown: false,
...(binding.value || {})
}) })
}) })
axios.interceptors.request.use(function (config) { axios.interceptors.request.use(function (config) {
@ -127,15 +129,8 @@ axios.interceptors.response.use(function (response) {
error.backendErrors.push(error.response.data.detail) error.backendErrors.push(error.response.data.detail)
} else { } else {
error.rawPayload = error.response.data error.rawPayload = error.response.data
for (var field in error.response.data) { let parsedErrors = parseAPIErrors(error.response.data)
// some views (e.g. v1/playlists/{id}/add) have deeper nested data (e.g. data[field] error.backendErrors = [...error.backendErrors, ...parsedErrors]
// 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)
})
}
}
} }
} }
if (error.backendErrors.length === 0) { if (error.backendErrors.length === 0) {
@ -157,6 +152,19 @@ store.dispatch('instance/fetchFrontSettings').finally(() => {
components: { App }, components: { App },
created () { created () {
APP = this 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,
})
}
}, },
}) })

View File

@ -9,6 +9,9 @@ export default new Router({
linkActiveClass: "active", linkActiveClass: "active",
base: process.env.VUE_APP_ROUTER_BASE_URL || "/", base: process.env.VUE_APP_ROUTER_BASE_URL || "/",
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
if (to.meta.preserveScrollPosition) {
return savedPosition
}
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(() => { setTimeout(() => {
if (to.hash) { if (to.hash) {

View File

@ -5,7 +5,12 @@ export default {
namespaced: true, namespaced: true,
state: { state: {
subscriptions: [], subscriptions: [],
count: 0 count: 0,
showUploadModal: false,
latestPublication: null,
uploadModalConfig: {
channel: null,
}
}, },
mutations: { mutations: {
subscriptions: (state, {uuid, value}) => { subscriptions: (state, {uuid, value}) => {
@ -24,6 +29,22 @@ export default {
reset (state) { reset (state) {
state.subscriptions = [] state.subscriptions = []
state.count = 0 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: { getters: {

View File

@ -11,8 +11,13 @@ export default {
lastDate: new Date(), lastDate: new Date(),
maxMessages: 100, maxMessages: 100,
messageDisplayDuration: 10000, messageDisplayDuration: 10000,
supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
messages: [], messages: [],
theme: 'light', theme: 'light',
window: {
height: 0,
width: 0,
},
notifications: { notifications: {
inbox: 0, inbox: 0,
pendingReviewEdits: 0, pendingReviewEdits: 0,
@ -125,6 +130,33 @@ export default {
count += 1 count += 1
} }
return count 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: { mutations: {
@ -193,6 +225,9 @@ export default {
serviceWorker: (state, value) => { serviceWorker: (state, value) => {
state.serviceWorker = {...state.serviceWorker, ...value} state.serviceWorker = {...state.serviceWorker, ...value}
},
window: (state, value) => {
state.window = value
} }
}, },
actions: { actions: {

View File

@ -112,7 +112,6 @@ html {
.toast-container { .toast-container {
bottom: $bottom-player-height + 1rem; bottom: $bottom-player-height + 1rem;
} }
} }
} }
@ -141,6 +140,7 @@ html {
#app { #app {
> .main.pusher, > .main.pusher,
> .footer { > .footer {
position: relative;
@include media(">desktop") { @include media(">desktop") {
margin-left: $desktop-sidebar-width !important; margin-left: $desktop-sidebar-width !important;
} }
@ -263,6 +263,9 @@ a {
.segment.hidden { .segment.hidden {
display: none; display: none;
} }
.hidden:not(.divider) {
display: none !important;
}
.nomargin { .nomargin {
margin: 0 !important; margin: 0 !important;
@ -291,6 +294,9 @@ button.reset {
text-align: inherit; text-align: inherit;
} }
.text.align.left {
text-align: left;
}
.ui.table > caption { .ui.table > caption {
font-weight: bold; font-weight: bold;
padding: 0.5em; padding: 0.5em;
@ -422,6 +428,11 @@ input + .help {
display: none !important; display: none !important;
} }
} }
.mobile-only {
@include media(">tablet") {
display: none !important;
}
}
:not(.menu) > { :not(.menu) > {
a, .link { a, .link {
&:not(.button):not(.list) { &:not(.button):not(.list) {
@ -433,19 +444,36 @@ input + .help {
} }
.ui.cards.app-cards { .ui.cards.app-cards {
$card-width: 14em; $card-width: 14em;
$card-hight: 22em; $card-height: 22em;
$small-card-width: 11em;
$small-card-height: 19em;
.app-card { .app-card {
display: flex; display: flex;
width: $card-width; width: $small-card-width;
height: $card-hight; height: $small-card-height;
font-size: 95%;
@include media(">tablet") {
font-size: 100%;
width: $card-width;
height: $card-height;
}
.content:not(.extra) { .content:not(.extra) {
padding: 0.5em 1em 0; padding: 0.25em 0.5em 0;
@include media(">tablet") {
padding: 0.5em 1em 0;
}
} }
.content.extra { .content.extra {
padding: 0.5em 1em; padding: 0.25em 0.5em;
@include media(">tablet") {
padding: 0.5em 1em;
}
} }
.head-image { .head-image {
height: $card-width; height: $small-card-width;
@include media(">tablet") {
height: $card-width;
}
background-size: cover !important; background-size: cover !important;
background-position: center !important; background-position: center !important;
display: flex !important; display: flex !important;
@ -457,9 +485,14 @@ input + .help {
&.circular { &.circular {
overflow: visible; overflow: visible;
border-radius: 50% !important; border-radius: 50% !important;
height: $card-width - 1em; width: $small-card-width - 0.5em;
width: $card-width - 1em; height: $small-card-width - 0.5em;
margin: 0.5em; margin: 0.25em;
@include media(">tablet") {
width: $card-width - 1em;
height: $card-width - 1em;
margin: 0.5em;
}
} }
&.padded { &.padded {
@ -543,7 +576,8 @@ input + .help {
} }
} }
.channel-image { .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; border-radius: 0.3em;
&.large { &.large {
width: 8em !important; width: 8em !important;
@ -560,5 +594,70 @@ input + .help {
width: 100%; 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/_light.scss";
@import "./themes/_dark.scss"; @import "./themes/_dark.scss";

View File

@ -31,6 +31,9 @@
/// @example scss - Creates a single breakpoint with the label `phone` /// @example scss - Creates a single breakpoint with the label `phone`
/// $breakpoints: ('phone': 320px); /// $breakpoints: ('phone': 320px);
/// ///
// IMPORTANT: if you modify these breakpoints, also modify the values in
// store/ui.js#windowSize
$breakpoints: ( $breakpoints: (
'phone': 320px, 'phone': 320px,
'tablet': 768px, 'tablet': 768px,

View File

@ -6,3 +6,30 @@ export function setUpdate(obj, statuses, value) {
statuses[k] = 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
}

View File

@ -74,7 +74,7 @@
</div> </div>
<div class="ui buttons"> <div class="ui buttons">
<dangerous-button <dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']" :class="['ui', {loading: isLoading}, 'basic red button']"
:action="remove"> :action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p>

View File

@ -73,7 +73,7 @@
</div> </div>
<div class="ui buttons"> <div class="ui buttons">
<dangerous-button <dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']" :class="['ui', {loading: isLoading}, 'basic red button']"
:action="remove"> :action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this artist?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this artist?</translate></p>

View File

@ -54,7 +54,7 @@
</div> </div>
<div class="ui buttons"> <div class="ui buttons">
<dangerous-button <dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']" :class="['ui', {loading: isLoading}, 'basic red button']"
:action="remove"> :action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this library?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this library?</translate></p>

View File

@ -37,7 +37,7 @@
</div> </div>
<div class="ui buttons"> <div class="ui buttons">
<dangerous-button <dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']" :class="['ui', {loading: isLoading}, 'basic red button']"
:action="remove"> :action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this tag?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this tag?</translate></p>

View File

@ -74,7 +74,7 @@
</div> </div>
<div class="ui buttons"> <div class="ui buttons">
<dangerous-button <dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']" :class="['ui', {loading: isLoading}, 'basic red button']"
:action="remove"> :action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p>

View File

@ -61,7 +61,7 @@
</div> </div>
<div class="ui buttons"> <div class="ui buttons">
<dangerous-button <dangerous-button
:class="['ui', {loading: isLoading}, 'basic button']" :class="['ui', {loading: isLoading}, 'basic red button']"
:action="remove"> :action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this upload?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this upload?</translate></p>

View File

@ -1,19 +1,21 @@
<template> <template>
<section class="ui stackable three column grid"> <section>
<div class="column"> <div>
<h2 class="ui header"> <h2 class="ui header">
<translate translate-context="Content/Home/Title">Recently listened</translate> <translate translate-context="Content/Home/Title">Recently listened</translate>
</h2> </h2>
<track-widget :url="'history/listenings/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"> <track-widget :url="'history/listenings/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}">
</track-widget> </track-widget>
</div> </div>
<div class="column"> <div class="ui hidden divider"></div>
<div>
<h2 class="ui header"> <h2 class="ui header">
<translate translate-context="Content/Home/Title">Recently favorited</translate> <translate translate-context="Content/Home/Title">Recently favorited</translate>
</h2> </h2>
<track-widget :url="'favorites/tracks/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"></track-widget> <track-widget :url="'favorites/tracks/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"></track-widget>
</div> </div>
<div class="column"> <div class="ui hidden divider"></div>
<div>
<h2 class="ui header"> <h2 class="ui header">
<translate translate-context="*/*/*">Playlists</translate> <translate translate-context="*/*/*">Playlists</translate>
</h2> </h2>

View File

@ -3,60 +3,77 @@
<div v-if="isLoading" class="ui vertical segment"> <div v-if="isLoading" class="ui vertical segment">
<div class="ui centered active inline loader"></div> <div class="ui centered active inline loader"></div>
</div> </div>
<template v-if="object"> <div class="ui head vertical stripe segment container">
<div class="ui dropdown icon small basic right floated button" ref="dropdown" v-dropdown style="right: 1em; top: 1em; z-index: 5"> <div class="ui stackable grid" v-if="object">
<i class="ellipsis vertical icon"></i> <div class="ui five wide column">
<div class="menu"> <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">
<div <i class="ellipsis vertical icon"></i>
role="button" <div class="menu">
class="basic item" <div
v-for="obj in getReportableObjs({account: object})" role="button"
:key="obj.target.type + obj.target.id" class="basic item"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> v-for="obj in getReportableObjs({account: object})"
<i class="share icon" /> {{ obj.label }} :key="obj.target.type + obj.target.id"
</div> @click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></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}}"> <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> <i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link> </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>
<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> </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>
</div> </div>
<div class="ui hidden divider"></div> <h1 class="ui center aligned icon header">
<keep-alive> <i v-if="!object.icon" class="circular inverted user green icon"></i>
<router-view @updated="fetch" :object="object"></router-view> <img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
</keep-alive> <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 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>
<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>
</div> </div>
</template> </div>
</main> </main>
</template> </template>
@ -81,6 +98,10 @@ export default {
created() { created() {
this.fetch() this.fetch()
}, },
beforeRouteUpdate (to, from, next) {
to.meta.preserveScrollPosition = true
next()
},
methods: { methods: {
fetch () { fetch () {
let self = this let self = this

View File

@ -1,17 +1,23 @@
<template> <template>
<section class="ui stackable grid"> <section>
<div class="six wide column"> <div v-if="$store.getters['ui/layoutVersion'] === 'small'">
<rendered-description <rendered-description
@updated="$emit('updated', $event)" @updated="$emit('updated', $event)"
:content="object.summary" :content="object.summary"
:field-name="'summary'" :field-name="'summary'"
:update-url="`users/users/${$store.state.auth.username}/`" :update-url="`users/users/${$store.state.auth.username}/`"
:can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description> :can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
<div class="ui hidden divider"></div>
</div> </div>
<div class="ten wide column"> <div>
<h2 class="ui header"> <h2 class="ui with-actions header">
<translate translate-context="*/*/*">Channels</translate> <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> </h2>
<channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget> <channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
<h2 class="ui header"> <h2 class="ui header">
@ -21,15 +27,61 @@
<translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate> <translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>
</library-widget> </library-widget>
</div> </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> </section>
</template> </template>
<script> <script>
import Modal from '@/components/semantic/Modal'
import LibraryWidget from "@/components/federation/LibraryWidget" import LibraryWidget from "@/components/federation/LibraryWidget"
import ChannelsWidget from "@/components/audio/ChannelsWidget" import ChannelsWidget from "@/components/audio/ChannelsWidget"
import ChannelForm from "@/components/audio/ChannelForm"
export default { export default {
props: ['object'], props: ['object'],
components: {ChannelsWidget, LibraryWidget}, components: {ChannelsWidget, LibraryWidget, ChannelForm, Modal},
data () {
return {
showCreateModal: false,
isLoading: false,
submittable: false,
step: 1,
category: 'podcast',
}
}
} }
</script> </script>

View File

@ -5,8 +5,8 @@
</div> </div>
<template v-if="object && !isLoading"> <template v-if="object && !isLoading">
<section class="ui head vertical stripe segment container" v-title="object.artist.name"> <section class="ui head vertical stripe segment container" v-title="object.artist.name">
<div class="ui stackable two column grid"> <div class="ui stackable grid">
<div class="column"> <div class="seven wide column">
<div class="ui two column grid"> <div class="ui two column grid">
<div class="column"> <div class="column">
<img class="huge channel-image" v-if="object.artist.cover" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.medium_square_crop)"> <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>
</template> </template>
<div class="ui hidden small divider"></div> <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> <i class="feed icon"></i>
</a> </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> <i class="ellipsis vertical icon"></i>
<div class="menu"> <div class="menu">
<div <div
@ -52,27 +90,55 @@
<i class="share icon" /> {{ obj.label }} <i class="share icon" /> {{ obj.label }}
</div> </div>
<div class="divider"></div> <template v-if="isOwner">
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}"> <div class="divider"></div>
<i class="wrench icon"></i> <div
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> class="item"
</router-link> 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>
</div> </div>
</div> </div>
<h1 class="ui header"> <h1 class="ui header">
<div class="left aligned content ellipsis"> <div class="left aligned" :title="object.artist.name">
{{ object.artist.name }} {{ object.artist.name }}
<div class="ui hidden very small divider"></div> <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 }} {{ object.actor.full_username }}
</div> </div>
</div> </div>
</h1> </h1>
<div class="header-buttons"> <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"> <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> <translate translate-context="Content/Channels/Button.Label/Verb">Play</translate>
</play-button> </play-button>
</div> </div>
@ -90,21 +156,45 @@
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<div class="ui deny button"> <div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div> </div>
</div> </div>
</modal> </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> <div v-if="$store.getters['ui/layoutVersion'] === 'large'">
<rendered-description <rendered-description
@updated="object = $event" @updated="object = $event"
:content="object.artist.description" :content="object.artist.description"
:update-url="`channels/${object.uuid}/`" :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> </div>
<div class="column"> <div class="nine wide column">
<div class="ui secondary pointing center aligned menu"> <div class="ui secondary pointing center aligned menu">
<router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}"> <router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}">
<translate translate-context="Content/Channels/Link">Overview</translate> <translate translate-context="Content/Channels/Link">Overview</translate>
@ -114,9 +204,7 @@
</router-link> </router-link>
</div> </div>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<keep-alive> <router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event"></router-view>
<router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event" ></router-view>
</keep-alive>
</div> </div>
</div> </div>
</section> </section>
@ -135,6 +223,7 @@ import TagsList from "@/components/tags/List"
import ReportMixin from '@/components/mixins/Report' import ReportMixin from '@/components/mixins/Report'
import SubscribeButton from '@/components/channels/SubscribeButton' import SubscribeButton from '@/components/channels/SubscribeButton'
import ChannelForm from "@/components/audio/ChannelForm"
export default { export default {
mixins: [ReportMixin], mixins: [ReportMixin],
@ -146,7 +235,8 @@ export default {
TagsList, TagsList,
ChannelEntries, ChannelEntries,
ChannelSeries, ChannelSeries,
SubscribeButton SubscribeButton,
ChannelForm,
}, },
data() { data() {
return { return {
@ -155,28 +245,54 @@ export default {
totalTracks: 0, totalTracks: 0,
latestTracks: null, latestTracks: null,
showEmbedModal: false, showEmbedModal: false,
showEditModal: false,
showSubscribeModal: false,
edit: {
submittable: false,
loading: false,
}
} }
}, },
beforeRouteUpdate (to, from, next) {
to.meta.preserveScrollPosition = true
next()
},
async created() { async created() {
await this.fetchData() await this.fetchData()
}, },
methods: { methods: {
async fetchData() { async fetchData() {
var self = this var self = this
this.showEditModal = false
this.edit.isLoading = false
this.isLoading = true this.isLoading = true
let channelPromise = axios.get(`channels/${this.id}`).then(response => { let channelPromise = axios.get(`channels/${this.id}`).then(response => {
self.object = response.data self.object = response.data
}) let tracksPromise = axios.get("tracks", {params: {channel: response.data.uuid, page_size: 1, playable: true, include_channels: true}}).then(response => {
let tracksPromise = axios.get("tracks", {params: {channel: this.id, page_size: 1, playable: true, include_channels: true}}).then(response => { self.totalTracks = response.data.count
self.totalTracks = response.data.count self.isLoading = false
})
}) })
await channelPromise 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: { computed: {
labels() { isOwner () {
return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
},
labels () {
return { return {
title: this.$pgettext('*/*/*', 'Channel') title: this.$pgettext('*/*/*', 'Channel')
} }
@ -190,9 +306,6 @@ export default {
isPlayable () { isPlayable () {
return this.totalTracks > 0 return this.totalTracks > 0
}, },
rssUrl () {
return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`)
}
}, },
watch: { watch: {
id() { id() {

View File

@ -1,22 +1,86 @@
<template> <template>
<section> <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"> <h2 class="ui header">
<translate translate-context="Content/Channel/Paragraph">Latest episodes</translate> <translate translate-context="Content/Channel/Paragraph">Latest episodes</translate>
</h2> </h2>
</channel-entries> </channel-entries>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<channel-series :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}"> <channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters">
<h2 class="ui header"> <h2 class="ui with-actions header">
<translate translate-context="Content/Channel/Paragraph">Series</translate> <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> </h2>
</channel-series> </channel-series>
<album-modal
ref="albumModal"
v-if="isOwner"
:channel="object"
@created="$refs.albumModal.show = false; seriesKey = new Date()"></album-modal>
</section> </section>
</template> </template>
<script> <script>
import axios from 'axios'
import qs from 'qs'
import ChannelEntries from "@/components/audio/ChannelEntries" import ChannelEntries from "@/components/audio/ChannelEntries"
import ChannelSeries from "@/components/audio/ChannelSeries" import ChannelSeries from "@/components/audio/ChannelSeries"
import AlbumModal from "@/components/channels/AlbumModal"
export default { export default {
@ -24,6 +88,107 @@ export default {
components: { components: {
ChannelEntries, ChannelEntries,
ChannelSeries, 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> </script>

View File

@ -2,21 +2,39 @@
<section class="ui vertical aligned stripe segment" v-title="labels.title"> <section class="ui vertical aligned stripe segment" v-title="labels.title">
<div class="ui text container"> <div class="ui text container">
<h1>{{ labels.title }}</h1> <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"> <div class="ui segment">
<h2><translate translate-context="Content/Library/Title/Verb">Upload audio content</translate></h2> <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> <i class="feed icon"></i>&nbsp;
<translate translate-context="Content/Library/Title/Verb">Publish your work in a channel</translate>
</h2>
<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> <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> </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> <translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
</router-link> </router-link>
</div> </div>
<div class="ui segment"> <div class="ui segment">
<h2><translate translate-context="Content/Library/Title/Verb">Follow remote libraries</translate></h2> <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> <i class="cloud icon"></i>&nbsp;
<router-link :to="{name: 'content.remote.index'}" class="ui green button"> <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>&nbsp;
<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> <translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
</router-link> </router-link>
</div> </div>

View File

@ -28,6 +28,9 @@
<option value> <option value>
<translate translate-context="Content/*/Dropdown">All</translate> <translate translate-context="Content/*/Dropdown">All</translate>
</option> </option>
<option value="draft">
<translate translate-context="Content/Library/*/Short">Draft</translate>
</option>
<option value="pending"> <option value="pending">
<translate translate-context="Content/Library/*/Short">Pending</translate> <translate translate-context="Content/Library/*/Short">Pending</translate>
</option> </option>
@ -258,7 +261,8 @@ export default {
page: this.page, page: this.page,
page_size: this.paginateBy, page_size: this.paginateBy,
ordering: this.getOrderingAsString(), ordering: this.getOrderingAsString(),
q: this.search.query q: this.search.query,
include_channels: 'true',
}, },
this.filters || {} this.filters || {}
); );
@ -288,7 +292,8 @@ export default {
}, },
actionFilters() { actionFilters() {
var currentFilters = { var currentFilters = {
q: this.search.query q: this.search.query,
include_channels: 'true',
}; };
if (this.filters) { if (this.filters) {
return _.merge(currentFilters, this.filters); return _.merge(currentFilters, this.filters);

View File

@ -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-if="library">Update library</translate>
<translate translate-context="Content/Library/Button.Label/Verb" v-else>Create library</translate> <translate translate-context="Content/Library/Button.Label/Verb" v-else>Create library</translate>
</button> </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> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"> <p slot="modal-header">
<translate translate-context="Popup/Library/Title">Delete this library?</translate> <translate translate-context="Popup/Library/Title">Delete this library?</translate>

View File

@ -31,8 +31,7 @@
</router-link> </router-link>
<dangerous-button <dangerous-button
color="grey" class="ui basic tiny grey button"
class="basic tiny"
:action="purgePendingFiles"> :action="purgePendingFiles">
<translate translate-context="*/*/*/Verb">Purge</translate> <translate translate-context="*/*/*/Verb">Purge</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge pending files?</translate></p> <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> <translate translate-context="Content/Library/Link/Verb">View files</translate>
</router-link> </router-link>
<dangerous-button <dangerous-button
color="grey" class="ui basic tiny grey button"
class="basic tiny"
:action="purgeSkippedFiles"> :action="purgeSkippedFiles">
<translate translate-context="*/*/*/Verb">Purge</translate> <translate translate-context="*/*/*/Verb">Purge</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge skipped files?</translate></p> <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> <translate translate-context="Content/Library/Link/Verb">View files</translate>
</router-link> </router-link>
<dangerous-button <dangerous-button
color="grey" class="ui basic tiny grey button"
class="basic tiny"
:action="purgeErroredFiles"> :action="purgeErroredFiles">
<translate translate-context="*/*/*/Verb">Purge</translate> <translate translate-context="*/*/*/Verb">Purge</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge errored files?</translate></p> <p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge errored files?</translate></p>
@ -154,4 +151,4 @@ export default {
} }
} }
} }
</script> </script>

View File

@ -115,7 +115,6 @@
</template> </template>
<template v-else-if="library.follow.approved"> <template v-else-if="library.follow.approved">
<dangerous-button <dangerous-button
color=""
:class="['ui', 'button']" :class="['ui', 'button']"
:action="unfollow"> :action="unfollow">
<translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate> <translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate>

View File

@ -39,7 +39,7 @@
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate> <translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
</button> </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> <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}"> <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 }"? Do you want to delete the playlist "%{ playlist }"?
@ -58,7 +58,7 @@
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<div class="ui deny button"> <div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate> <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More