First round of improvements to channel management:
- use modals - less proeminent button - field styling/labels
This commit is contained in:
parent
f8675c6080
commit
e59cc33378
|
@ -960,3 +960,5 @@ MIN_DELAY_BETWEEN_DOWNLOADS_COUNT = env.int(
|
|||
"MIN_DELAY_BETWEEN_DOWNLOADS_COUNT", default=60 * 60 * 6
|
||||
)
|
||||
MARKDOWN_EXTENSIONS = env.list("MARKDOWN_EXTENSIONS", default=["nl2br", "extra"])
|
||||
|
||||
LINKIFIER_SUPPORTED_TLDS = ["audio"] + env.list("LINKINFIER_SUPPORTED_TLDS", default=[])
|
||||
|
|
|
@ -23,7 +23,12 @@ urlpatterns = [
|
|||
),
|
||||
urls.re_path(
|
||||
r"^channels/(?P<uuid>[0-9a-f-]+)/?$",
|
||||
audio_spa_views.channel_detail,
|
||||
audio_spa_views.channel_detail_uuid,
|
||||
name="channel_detail",
|
||||
),
|
||||
urls.re_path(
|
||||
r"^channels/(?P<username>[^/]+)/?$",
|
||||
audio_spa_views.channel_detail_username,
|
||||
name="channel_detail",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -6,6 +6,8 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from funkwhale_api.federation import keys
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
|
@ -44,14 +46,22 @@ class Channel(models.Model):
|
|||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return federation_utils.full_url("/channels/{}".format(self.uuid))
|
||||
suffix = self.uuid
|
||||
if self.actor.is_local:
|
||||
suffix = self.actor.preferred_username
|
||||
else:
|
||||
suffix = self.actor.full_username
|
||||
return federation_utils.full_url("/channels/{}".format(suffix))
|
||||
|
||||
def get_rss_url(self):
|
||||
if not self.artist.is_local:
|
||||
return self.rss_url
|
||||
|
||||
return federation_utils.full_url(
|
||||
reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid})
|
||||
reverse(
|
||||
"api:v1:channels-rss",
|
||||
kwargs={"composite": self.actor.preferred_username},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
@ -62,3 +72,10 @@ def generate_actor(username, **kwargs):
|
|||
actor_data["public_key"] = public.decode("utf-8")
|
||||
|
||||
return federation_models.Actor.objects.create(**actor_data)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Channel)
|
||||
def delete_channel_related_objs(instance, **kwargs):
|
||||
instance.library.delete()
|
||||
instance.actor.delete()
|
||||
instance.artist.delete()
|
||||
|
|
|
@ -3,6 +3,8 @@ from django.db import transaction
|
|||
|
||||
from rest_framework import serializers
|
||||
|
||||
from django.contrib.staticfiles.templatetags.staticfiles import static
|
||||
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.common import locales
|
||||
|
@ -24,7 +26,7 @@ class ChannelMetadataSerializer(serializers.Serializer):
|
|||
itunes_category = serializers.ChoiceField(
|
||||
choices=categories.ITUNES_CATEGORIES, required=True
|
||||
)
|
||||
itunes_subcategory = serializers.CharField(required=False)
|
||||
itunes_subcategory = serializers.CharField(required=False, allow_null=True)
|
||||
language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES)
|
||||
copyright = serializers.CharField(required=False, allow_null=True, max_length=255)
|
||||
owner_name = serializers.CharField(required=False, allow_null=True, max_length=255)
|
||||
|
@ -64,6 +66,7 @@ class ChannelCreateSerializer(serializers.Serializer):
|
|||
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
|
||||
)
|
||||
metadata = serializers.DictField(required=False)
|
||||
cover = music_serializers.COVER_WRITE_FIELD
|
||||
|
||||
def validate(self, validated_data):
|
||||
existing_channels = self.context["actor"].owned_channels.count()
|
||||
|
@ -95,15 +98,15 @@ class ChannelCreateSerializer(serializers.Serializer):
|
|||
def create(self, validated_data):
|
||||
from . import views
|
||||
|
||||
cover = validated_data.pop("cover", None)
|
||||
description = validated_data.get("description")
|
||||
artist = music_models.Artist.objects.create(
|
||||
attributed_to=validated_data["attributed_to"],
|
||||
name=validated_data["name"],
|
||||
content_category=validated_data["content_category"],
|
||||
attachment_cover=cover,
|
||||
)
|
||||
description_obj = common_utils.attach_content(
|
||||
artist, "description", description
|
||||
)
|
||||
common_utils.attach_content(artist, "description", description)
|
||||
|
||||
if validated_data.get("tags", []):
|
||||
tags_models.set_tags(artist, *validated_data["tags"])
|
||||
|
@ -113,9 +116,8 @@ class ChannelCreateSerializer(serializers.Serializer):
|
|||
attributed_to=validated_data["attributed_to"],
|
||||
metadata=validated_data["metadata"],
|
||||
)
|
||||
summary = description_obj.rendered if description_obj else None
|
||||
channel.actor = models.generate_actor(
|
||||
validated_data["username"], summary=summary, name=validated_data["name"],
|
||||
validated_data["username"], name=validated_data["name"],
|
||||
)
|
||||
|
||||
channel.library = music_models.Library.objects.create(
|
||||
|
@ -142,6 +144,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
|
|||
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
|
||||
)
|
||||
metadata = serializers.DictField(required=False)
|
||||
cover = music_serializers.COVER_WRITE_FIELD
|
||||
|
||||
def validate(self, validated_data):
|
||||
validated_data = super().validate(validated_data)
|
||||
|
@ -194,6 +197,9 @@ class ChannelUpdateSerializer(serializers.Serializer):
|
|||
("content_category", validated_data["content_category"])
|
||||
)
|
||||
|
||||
if "cover" in validated_data:
|
||||
artist_update_fields.append(("attachment_cover", validated_data["cover"]))
|
||||
|
||||
if actor_update_fields:
|
||||
for field, value in actor_update_fields:
|
||||
setattr(obj.actor, field, value)
|
||||
|
@ -292,7 +298,7 @@ def rss_serialize_item(upload):
|
|||
# we enforce MP3, since it's the only format supported everywhere
|
||||
"url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
|
||||
"length": upload.size or 0,
|
||||
"type": upload.mimetype or "audio/mpeg",
|
||||
"type": "audio/mpeg",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
@ -362,6 +368,11 @@ def rss_serialize_channel(channel):
|
|||
data["itunes:image"] = [
|
||||
{"href": channel.artist.attachment_cover.download_url_original}
|
||||
]
|
||||
else:
|
||||
placeholder_url = federation_utils.full_url(
|
||||
static("images/podcasts-cover-placeholder.png")
|
||||
)
|
||||
data["itunes:image"] = [{"href": placeholder_url}]
|
||||
|
||||
tagged_items = getattr(channel.artist, "_prefetched_tagged_items", [])
|
||||
|
||||
|
|
|
@ -1,26 +1,33 @@
|
|||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import utils
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.music import spa_views
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def channel_detail(request, uuid):
|
||||
queryset = models.Channel.objects.filter(uuid=uuid).select_related(
|
||||
def channel_detail(query):
|
||||
queryset = models.Channel.objects.filter(query).select_related(
|
||||
"artist__attachment_cover", "actor", "library"
|
||||
)
|
||||
try:
|
||||
obj = queryset.get()
|
||||
except models.Channel.DoesNotExist:
|
||||
return []
|
||||
|
||||
obj_url = utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("channel_detail", kwargs={"uuid": obj.uuid}),
|
||||
utils.spa_reverse(
|
||||
"channel_detail", kwargs={"username": obj.actor.full_username}
|
||||
),
|
||||
)
|
||||
metas = [
|
||||
{"tag": "meta", "property": "og:url", "content": obj_url},
|
||||
|
@ -72,3 +79,25 @@ def channel_detail(request, uuid):
|
|||
# twitter player is also supported in various software
|
||||
metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid)
|
||||
return metas
|
||||
|
||||
|
||||
def channel_detail_uuid(request, uuid):
|
||||
validator = serializers.UUIDField().to_internal_value
|
||||
try:
|
||||
uuid = validator(uuid)
|
||||
except serializers.ValidationError:
|
||||
return []
|
||||
return channel_detail(Q(uuid=uuid))
|
||||
|
||||
|
||||
def channel_detail_username(request, username):
|
||||
validator = federation_utils.get_actor_data_from_username
|
||||
try:
|
||||
username_data = validator(username)
|
||||
except serializers.ValidationError:
|
||||
return []
|
||||
query = Q(
|
||||
actor__domain=username_data["domain"],
|
||||
actor__preferred_username__iexact=username_data["username"],
|
||||
)
|
||||
return channel_detail(query)
|
||||
|
|
|
@ -7,18 +7,21 @@ from rest_framework import viewsets
|
|||
|
||||
from django import http
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
from funkwhale_api.common import locales
|
||||
from funkwhale_api.common import permissions
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import routes
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import views as music_views
|
||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||
|
||||
from . import filters, models, renderers, serializers
|
||||
from . import categories, filters, models, renderers, serializers
|
||||
|
||||
ARTIST_PREFETCH_QS = (
|
||||
music_models.Artist.objects.select_related("description", "attachment_cover",)
|
||||
|
@ -36,6 +39,7 @@ class ChannelsMixin(object):
|
|||
|
||||
class ChannelViewSet(
|
||||
ChannelsMixin,
|
||||
MultipleLookupDetailMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
|
@ -43,7 +47,20 @@ class ChannelViewSet(
|
|||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
lookup_field = "uuid"
|
||||
url_lookups = [
|
||||
{
|
||||
"lookup_field": "uuid",
|
||||
"validator": serializers.serializers.UUIDField().to_internal_value,
|
||||
},
|
||||
{
|
||||
"lookup_field": "username",
|
||||
"validator": federation_utils.get_actor_data_from_username,
|
||||
"get_query": lambda v: Q(
|
||||
actor__domain=v["domain"],
|
||||
actor__preferred_username__iexact=v["username"],
|
||||
),
|
||||
},
|
||||
]
|
||||
filterset_class = filters.ChannelFilter
|
||||
serializer_class = serializers.ChannelSerializer
|
||||
queryset = (
|
||||
|
@ -134,6 +151,25 @@ class ChannelViewSet(
|
|||
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
|
||||
return response.Response(data, status=200)
|
||||
|
||||
@decorators.action(
|
||||
methods=["get"],
|
||||
detail=False,
|
||||
url_path="metadata-choices",
|
||||
url_name="metadata_choices",
|
||||
permission_classes=[],
|
||||
)
|
||||
def metedata_choices(self, request, *args, **kwargs):
|
||||
data = {
|
||||
"language": [
|
||||
{"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
|
||||
],
|
||||
"itunes_category": [
|
||||
{"value": code, "label": code, "children": children}
|
||||
for code, children in categories.ITUNES_CATEGORIES.items()
|
||||
],
|
||||
}
|
||||
return response.Response(data)
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["subscriptions_count"] = self.action in [
|
||||
|
@ -152,7 +188,7 @@ class ChannelViewSet(
|
|||
{"type": "Delete", "object": {"type": instance.actor.type}},
|
||||
context={"actor": instance.actor},
|
||||
)
|
||||
instance.delete()
|
||||
instance.__class__.objects.filter(pk=instance.pk).delete()
|
||||
|
||||
|
||||
class SubscriptionsViewSet(
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import html
|
||||
import logging
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
@ -20,6 +21,8 @@ from . import utils
|
|||
|
||||
EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def should_fallback_to_spa(path):
|
||||
if path == "/":
|
||||
|
@ -270,6 +273,17 @@ class ThrottleStatusMiddleware:
|
|||
return response
|
||||
|
||||
|
||||
class VerboseBadRequestsMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if response.status_code == 400:
|
||||
logger.warning("Bad request: %s", response.content)
|
||||
return response
|
||||
|
||||
|
||||
class ProfilerMiddleware:
|
||||
"""
|
||||
from https://github.com/omarish/django-cprofile-middleware/blob/master/django_cprofile_middleware/middleware.py
|
||||
|
|
|
@ -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
|
|
@ -359,4 +359,7 @@ def remove_attached_content(sender, instance, **kwargs):
|
|||
fk_fields = CONTENT_FKS.get(instance._meta.label, [])
|
||||
for field in fk_fields:
|
||||
if getattr(instance, "{}_id".format(field)):
|
||||
getattr(instance, field).delete()
|
||||
try:
|
||||
getattr(instance, field).delete()
|
||||
except Content.DoesNotExist:
|
||||
pass
|
||||
|
|
|
@ -279,7 +279,11 @@ HTML_PERMISSIVE_CLEANER = bleach.sanitizer.Cleaner(
|
|||
attributes=["class", "rel", "alt", "title"],
|
||||
)
|
||||
|
||||
HTML_LINKER = bleach.linkifier.Linker()
|
||||
# support for additional tlds
|
||||
# cf https://github.com/mozilla/bleach/issues/367#issuecomment-384631867
|
||||
ALL_TLDS = set(settings.LINKIFIER_SUPPORTED_TLDS + bleach.linkifier.TLDS)
|
||||
URL_RE = bleach.linkifier.build_url_re(tlds=sorted(ALL_TLDS, reverse=True))
|
||||
HTML_LINKER = bleach.linkifier.Linker(url_re=URL_RE)
|
||||
|
||||
|
||||
def clean_html(html, permissive=False):
|
||||
|
@ -338,29 +342,34 @@ def attach_file(obj, field, file_data, fetch=False):
|
|||
if not file_data:
|
||||
return
|
||||
|
||||
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
|
||||
extension = extensions.get(file_data["mimetype"], "jpg")
|
||||
attachment = models.Attachment(mimetype=file_data["mimetype"])
|
||||
name_fields = ["uuid", "full_username", "pk"]
|
||||
name = [getattr(obj, field) for field in name_fields if getattr(obj, field, None)][
|
||||
0
|
||||
]
|
||||
filename = "{}-{}.{}".format(field, name, extension)
|
||||
if "url" in file_data:
|
||||
attachment.url = file_data["url"]
|
||||
if isinstance(file_data, models.Attachment):
|
||||
attachment = file_data
|
||||
else:
|
||||
f = ContentFile(file_data["content"])
|
||||
attachment.file.save(filename, f, save=False)
|
||||
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
|
||||
extension = extensions.get(file_data["mimetype"], "jpg")
|
||||
attachment = models.Attachment(mimetype=file_data["mimetype"])
|
||||
name_fields = ["uuid", "full_username", "pk"]
|
||||
name = [
|
||||
getattr(obj, field) for field in name_fields if getattr(obj, field, None)
|
||||
][0]
|
||||
filename = "{}-{}.{}".format(field, name, extension)
|
||||
if "url" in file_data:
|
||||
attachment.url = file_data["url"]
|
||||
else:
|
||||
f = ContentFile(file_data["content"])
|
||||
attachment.file.save(filename, f, save=False)
|
||||
|
||||
if not attachment.file and fetch:
|
||||
try:
|
||||
tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
|
||||
except Exception as e:
|
||||
logger.warn("Cannot download attachment at url %s: %s", attachment.url, e)
|
||||
attachment = None
|
||||
if not attachment.file and fetch:
|
||||
try:
|
||||
tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
|
||||
except Exception as e:
|
||||
logger.warn(
|
||||
"Cannot download attachment at url %s: %s", attachment.url, e
|
||||
)
|
||||
attachment = None
|
||||
|
||||
if attachment:
|
||||
attachment.save()
|
||||
if attachment:
|
||||
attachment.save()
|
||||
|
||||
setattr(obj, field, attachment)
|
||||
obj.save(update_fields=[field])
|
||||
|
|
|
@ -246,6 +246,8 @@ class Actor(models.Model):
|
|||
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
|
||||
|
||||
def should_autoapprove_follow(self, actor):
|
||||
if self.get_channel():
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_user(self):
|
||||
|
|
|
@ -134,7 +134,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
|||
)
|
||||
preferredUsername = serializers.CharField()
|
||||
manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
|
||||
name = serializers.CharField(required=False, max_length=200)
|
||||
name = serializers.CharField(
|
||||
required=False, max_length=200, allow_blank=True, allow_null=True
|
||||
)
|
||||
summary = TruncatedCharField(
|
||||
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
|
||||
required=False,
|
||||
|
@ -209,6 +211,8 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
|||
},
|
||||
]
|
||||
include_image(ret, channel.artist.attachment_cover, "icon")
|
||||
if channel.artist.description_id:
|
||||
ret["summary"] = channel.artist.description.rendered
|
||||
else:
|
||||
ret["url"] = [
|
||||
{
|
||||
|
|
|
@ -71,7 +71,7 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
|
|||
@action(methods=["get", "post"], detail=True)
|
||||
def outbox(self, request, *args, **kwargs):
|
||||
actor = self.get_object()
|
||||
channel = actor.channel
|
||||
channel = actor.get_channel()
|
||||
if channel:
|
||||
return self.get_channel_outbox_response(request, channel)
|
||||
return response.Response({}, status=200)
|
||||
|
|
|
@ -107,12 +107,14 @@ class TrackFilter(
|
|||
|
||||
class UploadFilter(audio_filters.IncludeChannelsFilterSet):
|
||||
library = filters.CharFilter("library__uuid")
|
||||
channel = filters.CharFilter("library__channel__uuid")
|
||||
track = filters.UUIDFilter("track__uuid")
|
||||
track_artist = filters.UUIDFilter("track__artist__uuid")
|
||||
album_artist = filters.UUIDFilter("track__album__artist__uuid")
|
||||
library = filters.UUIDFilter("library__uuid")
|
||||
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
|
||||
scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True)
|
||||
import_status = common_filters.MultipleQueryFilter(coerce=str)
|
||||
q = fields.SmartSearchFilter(
|
||||
config=search.SearchConfig(
|
||||
search_fields={
|
||||
|
@ -143,6 +145,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
|
|||
"library",
|
||||
"import_reference",
|
||||
"scope",
|
||||
"channel",
|
||||
]
|
||||
include_channels_field = "track__artist__channel"
|
||||
|
||||
|
|
|
@ -30,12 +30,12 @@ def load(data):
|
|||
try:
|
||||
license = existing_by_code[row["code"]]
|
||||
except KeyError:
|
||||
logger.info("Loading new license: {}".format(row["code"]))
|
||||
logger.debug("Loading new license: {}".format(row["code"]))
|
||||
to_create.append(
|
||||
models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS})
|
||||
)
|
||||
else:
|
||||
logger.info("Updating license: {}".format(row["code"]))
|
||||
logger.debug("Updating license: {}".format(row["code"]))
|
||||
stored = [getattr(license, f) for f in MODEL_FIELDS]
|
||||
wanted = [row[f] for f in MODEL_FIELDS]
|
||||
if wanted == stored:
|
||||
|
|
|
@ -512,9 +512,10 @@ class ArtistField(serializers.Field):
|
|||
mbid = None
|
||||
artist = {"name": name, "mbid": mbid}
|
||||
final.append(artist)
|
||||
|
||||
field = serializers.ListField(child=ArtistSerializer(), min_length=1)
|
||||
|
||||
field = serializers.ListField(
|
||||
child=ArtistSerializer(strict=self.context.get("strict", True)),
|
||||
min_length=1,
|
||||
)
|
||||
return field.to_internal_value(final)
|
||||
|
||||
|
||||
|
@ -647,15 +648,29 @@ class MBIDField(serializers.UUIDField):
|
|||
|
||||
|
||||
class ArtistSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
name = serializers.CharField(required=False, allow_null=True)
|
||||
mbid = MBIDField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.strict = kwargs.pop("strict", True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate_name(self, v):
|
||||
if self.strict and not v:
|
||||
raise serializers.ValidationError("This field is required.")
|
||||
return v
|
||||
|
||||
|
||||
class AlbumSerializer(serializers.Serializer):
|
||||
title = serializers.CharField()
|
||||
title = serializers.CharField(required=False, allow_null=True)
|
||||
mbid = MBIDField()
|
||||
release_date = PermissiveDateField(required=False, allow_null=True)
|
||||
|
||||
def validate_title(self, v):
|
||||
if self.context.get("strict", True) and not v:
|
||||
raise serializers.ValidationError("This field is required.")
|
||||
return v
|
||||
|
||||
|
||||
class PositionField(serializers.CharField):
|
||||
def to_internal_value(self, v):
|
||||
|
@ -691,7 +706,7 @@ class DescriptionField(serializers.CharField):
|
|||
|
||||
|
||||
class TrackMetadataSerializer(serializers.Serializer):
|
||||
title = serializers.CharField()
|
||||
title = serializers.CharField(required=False, allow_null=True)
|
||||
position = PositionField(allow_blank=True, allow_null=True, required=False)
|
||||
disc_number = PositionField(allow_blank=True, allow_null=True, required=False)
|
||||
copyright = serializers.CharField(allow_blank=True, allow_null=True, required=False)
|
||||
|
@ -714,6 +729,11 @@ class TrackMetadataSerializer(serializers.Serializer):
|
|||
"tags",
|
||||
]
|
||||
|
||||
def validate_title(self, v):
|
||||
if self.context.get("strict", True) and not v:
|
||||
raise serializers.ValidationError("This field is required.")
|
||||
return v
|
||||
|
||||
def validate(self, validated_data):
|
||||
validated_data = super().validate(validated_data)
|
||||
for field in self.remove_blank_null_fields:
|
||||
|
|
|
@ -950,7 +950,11 @@ class Upload(models.Model):
|
|||
|
||||
def get_all_tagged_items(self):
|
||||
track_tags = self.track.tagged_items.all()
|
||||
album_tags = self.track.album.tagged_items.all()
|
||||
album_tags = (
|
||||
self.track.album.tagged_items.all()
|
||||
if self.track.album
|
||||
else tags_models.TaggedItem.objects.none()
|
||||
)
|
||||
artist_tags = self.track.artist.tagged_items.all()
|
||||
|
||||
items = (track_tags | album_tags | artist_tags).order_by("tag__name")
|
||||
|
|
|
@ -6,16 +6,30 @@ from django.conf import settings
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.audio import serializers as audio_serializers
|
||||
from funkwhale_api.common import models as common_models
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.federation import routes
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.playlists import models as playlists_models
|
||||
from funkwhale_api.tags.models import Tag
|
||||
from funkwhale_api.tags import models as tag_models
|
||||
from funkwhale_api.tags import serializers as tags_serializers
|
||||
|
||||
from . import filters, models, tasks
|
||||
from . import filters, models, tasks, utils
|
||||
|
||||
NOOP = object()
|
||||
|
||||
COVER_WRITE_FIELD = common_serializers.RelatedField(
|
||||
"uuid",
|
||||
queryset=common_models.Attachment.objects.all().local(),
|
||||
serializer=None,
|
||||
allow_null=True,
|
||||
required=False,
|
||||
queryset_filter=lambda qs, context: qs.filter(actor=context["request"].user.actor),
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
from funkwhale_api.audio import serializers as audio_serializers # NOQA
|
||||
|
||||
|
||||
class CoverField(
|
||||
|
@ -381,9 +395,30 @@ class UploadSerializer(serializers.ModelSerializer):
|
|||
"import_date",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
validated_data = super().validate(data)
|
||||
if "audio_file" in validated_data:
|
||||
audio_data = utils.get_audio_file_data(validated_data["audio_file"])
|
||||
if audio_data:
|
||||
validated_data["duration"] = audio_data["length"]
|
||||
validated_data["bitrate"] = audio_data["bitrate"]
|
||||
return validated_data
|
||||
|
||||
|
||||
def filter_album(qs, context):
|
||||
if "channel" in context:
|
||||
return qs.filter(artist__channel=context["channel"])
|
||||
if "actor" in context:
|
||||
return qs.filter(artist__attributed_to=context["actor"])
|
||||
|
||||
return qs.none()
|
||||
|
||||
|
||||
class ImportMetadataSerializer(serializers.Serializer):
|
||||
title = serializers.CharField(max_length=500, required=True)
|
||||
description = serializers.CharField(
|
||||
max_length=5000, required=False, allow_null=True
|
||||
)
|
||||
mbid = serializers.UUIDField(required=False, allow_null=True)
|
||||
copyright = serializers.CharField(max_length=500, required=False, allow_null=True)
|
||||
position = serializers.IntegerField(min_value=1, required=False, allow_null=True)
|
||||
|
@ -391,12 +426,32 @@ class ImportMetadataSerializer(serializers.Serializer):
|
|||
license = common_serializers.RelatedField(
|
||||
"code", LicenseSerializer(), required=False, allow_null=True
|
||||
)
|
||||
cover = common_serializers.RelatedField(
|
||||
"uuid",
|
||||
queryset=common_models.Attachment.objects.all().local(),
|
||||
serializer=None,
|
||||
queryset_filter=lambda qs, context: qs.filter(actor=context["actor"]),
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
album = common_serializers.RelatedField(
|
||||
"id",
|
||||
queryset=models.Album.objects.all(),
|
||||
serializer=None,
|
||||
queryset_filter=filter_album,
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
|
||||
class ImportMetadataField(serializers.JSONField):
|
||||
def to_internal_value(self, v):
|
||||
v = super().to_internal_value(v)
|
||||
s = ImportMetadataSerializer(data=v)
|
||||
s = ImportMetadataSerializer(
|
||||
data=v, context={"actor": self.context["user"].actor}
|
||||
)
|
||||
s.is_valid(raise_exception=True)
|
||||
return v
|
||||
|
||||
|
@ -464,6 +519,7 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
|
|||
actions = [
|
||||
common_serializers.Action("delete", allow_all=True),
|
||||
common_serializers.Action("relaunch_import", allow_all=True),
|
||||
common_serializers.Action("publish", allow_all=False),
|
||||
]
|
||||
filterset_class = filters.UploadFilter
|
||||
pk_field = "uuid"
|
||||
|
@ -490,10 +546,18 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
|
|||
for pk in pks:
|
||||
common_utils.on_commit(tasks.process_upload.delay, upload_id=pk)
|
||||
|
||||
@transaction.atomic
|
||||
def handle_publish(self, objects):
|
||||
qs = objects.filter(import_status="draft")
|
||||
pks = list(qs.values_list("id", flat=True))
|
||||
qs.update(import_status="pending")
|
||||
for pk in pks:
|
||||
common_utils.on_commit(tasks.process_upload.delay, upload_id=pk)
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Tag
|
||||
model = tag_models.Tag
|
||||
fields = ("id", "name", "creation_date")
|
||||
|
||||
|
||||
|
@ -509,7 +573,7 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
|||
type = serializers.SerializerMethodField()
|
||||
name = serializers.CharField(source="title")
|
||||
artist = serializers.CharField(source="artist.name")
|
||||
album = serializers.CharField(source="album.title")
|
||||
album = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Track
|
||||
|
@ -518,6 +582,10 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
|||
def get_type(self, obj):
|
||||
return "Audio"
|
||||
|
||||
def get_album(self, o):
|
||||
if o.album:
|
||||
return o.album.title
|
||||
|
||||
|
||||
def get_embed_url(type, id):
|
||||
return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id)
|
||||
|
@ -561,7 +629,13 @@ class OembedSerializer(serializers.Serializer):
|
|||
embed_type = "track"
|
||||
embed_id = track.pk
|
||||
data["title"] = "{} by {}".format(track.title, track.artist.name)
|
||||
if track.album.attachment_cover:
|
||||
if track.attachment_cover:
|
||||
data[
|
||||
"thumbnail_url"
|
||||
] = track.album.attachment_cover.download_url_medium_square_crop
|
||||
data["thumbnail_width"] = 200
|
||||
data["thumbnail_height"] = 200
|
||||
elif track.album and track.album.attachment_cover:
|
||||
data[
|
||||
"thumbnail_url"
|
||||
] = track.album.attachment_cover.download_url_medium_square_crop
|
||||
|
@ -630,7 +704,16 @@ class OembedSerializer(serializers.Serializer):
|
|||
elif match.url_name == "channel_detail":
|
||||
from funkwhale_api.audio.models import Channel
|
||||
|
||||
qs = Channel.objects.filter(uuid=match.kwargs["uuid"]).select_related(
|
||||
kwargs = {}
|
||||
if "uuid" in match.kwargs:
|
||||
kwargs["uuid"] = match.kwargs["uuid"]
|
||||
else:
|
||||
username_data = federation_utils.get_actor_data_from_username(
|
||||
match.kwargs["username"]
|
||||
)
|
||||
kwargs["actor__domain"] = username_data["domain"]
|
||||
kwargs["actor__preferred_username__iexact"] = username_data["username"]
|
||||
qs = Channel.objects.filter(**kwargs).select_related(
|
||||
"artist__attachment_cover"
|
||||
)
|
||||
try:
|
||||
|
@ -705,3 +788,46 @@ class OembedSerializer(serializers.Serializer):
|
|||
|
||||
def create(self, data):
|
||||
return data
|
||||
|
||||
|
||||
class AlbumCreateSerializer(serializers.Serializer):
|
||||
title = serializers.CharField(required=True, max_length=255)
|
||||
cover = COVER_WRITE_FIELD
|
||||
release_date = serializers.DateField(required=False, allow_null=True)
|
||||
tags = tags_serializers.TagsListField(required=False)
|
||||
description = common_serializers.ContentSerializer(allow_null=True, required=False)
|
||||
|
||||
artist = common_serializers.RelatedField(
|
||||
"id",
|
||||
queryset=models.Artist.objects.exclude(channel__isnull=True),
|
||||
required=True,
|
||||
serializer=None,
|
||||
filters=lambda context: {"attributed_to": context["user"].actor},
|
||||
)
|
||||
|
||||
def validate(self, validated_data):
|
||||
duplicates = validated_data["artist"].albums.filter(
|
||||
title__iexact=validated_data["title"]
|
||||
)
|
||||
if duplicates.exists():
|
||||
raise serializers.ValidationError("An album with this title already exist")
|
||||
|
||||
return super().validate(validated_data)
|
||||
|
||||
def to_representation(self, obj):
|
||||
obj.artist.attachment_cover
|
||||
return AlbumSerializer(obj, context=self.context).data
|
||||
|
||||
def create(self, validated_data):
|
||||
instance = models.Album.objects.create(
|
||||
attributed_to=self.context["user"].actor,
|
||||
artist=validated_data["artist"],
|
||||
release_date=validated_data.get("release_date"),
|
||||
title=validated_data["title"],
|
||||
attachment_cover=validated_data.get("cover"),
|
||||
)
|
||||
common_utils.attach_content(
|
||||
instance, "description", validated_data.get("description")
|
||||
)
|
||||
tag_models.set_tags(instance, *(validated_data.get("tags", []) or []))
|
||||
return instance
|
||||
|
|
|
@ -49,16 +49,29 @@ def library_track(request, pk):
|
|||
utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
|
||||
),
|
||||
},
|
||||
{
|
||||
"tag": "meta",
|
||||
"property": "music:album",
|
||||
"content": utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
|
||||
),
|
||||
},
|
||||
]
|
||||
if obj.album.attachment_cover:
|
||||
|
||||
if obj.album:
|
||||
metas.append(
|
||||
{
|
||||
"tag": "meta",
|
||||
"property": "music:album",
|
||||
"content": utils.join_url(
|
||||
settings.FUNKWHALE_URL,
|
||||
utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if obj.attachment_cover:
|
||||
metas.append(
|
||||
{
|
||||
"tag": "meta",
|
||||
"property": "og:image",
|
||||
"content": obj.attachment_cover.download_url_medium_square_crop,
|
||||
}
|
||||
)
|
||||
elif obj.album and obj.album.attachment_cover:
|
||||
metas.append(
|
||||
{
|
||||
"tag": "meta",
|
||||
|
|
|
@ -175,46 +175,69 @@ def fail_import(upload, error_code, detail=None, **fields):
|
|||
"upload",
|
||||
)
|
||||
def process_upload(upload, update_denormalization=True):
|
||||
"""
|
||||
Main handler to process uploads submitted by user and create the corresponding
|
||||
metadata (tracks/artists/albums) in our DB.
|
||||
"""
|
||||
from . import serializers
|
||||
|
||||
channel = upload.library.get_channel()
|
||||
# When upload is linked to a channel instead of a library
|
||||
# we willingly ignore the metadata embedded in the file itself
|
||||
# and rely on user metadata only
|
||||
use_file_metadata = channel is None
|
||||
|
||||
import_metadata = upload.import_metadata or {}
|
||||
internal_config = {"funkwhale": import_metadata.get("funkwhale", {})}
|
||||
forced_values_serializer = serializers.ImportMetadataSerializer(
|
||||
data=import_metadata
|
||||
data=import_metadata,
|
||||
context={"actor": upload.library.actor, "channel": channel},
|
||||
)
|
||||
if forced_values_serializer.is_valid():
|
||||
forced_values = forced_values_serializer.validated_data
|
||||
else:
|
||||
forced_values = {}
|
||||
if not use_file_metadata:
|
||||
detail = forced_values_serializer.errors
|
||||
metadata_dump = import_metadata
|
||||
return fail_import(
|
||||
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
|
||||
)
|
||||
|
||||
if upload.library.get_channel():
|
||||
if channel:
|
||||
# ensure the upload is associated with the channel artist
|
||||
forced_values["artist"] = upload.library.channel.artist
|
||||
|
||||
old_status = upload.import_status
|
||||
audio_file = upload.get_audio_file()
|
||||
additional_data = {}
|
||||
additional_data = {"upload_source": upload.source}
|
||||
|
||||
m = metadata.Metadata(audio_file)
|
||||
try:
|
||||
serializer = metadata.TrackMetadataSerializer(data=m)
|
||||
serializer.is_valid()
|
||||
except Exception:
|
||||
fail_import(upload, "unknown_error")
|
||||
raise
|
||||
if not serializer.is_valid():
|
||||
detail = serializer.errors
|
||||
if use_file_metadata:
|
||||
audio_file = upload.get_audio_file()
|
||||
|
||||
m = metadata.Metadata(audio_file)
|
||||
try:
|
||||
metadata_dump = m.all()
|
||||
except Exception as e:
|
||||
logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
|
||||
return fail_import(
|
||||
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
|
||||
)
|
||||
serializer = metadata.TrackMetadataSerializer(data=m)
|
||||
serializer.is_valid()
|
||||
except Exception:
|
||||
fail_import(upload, "unknown_error")
|
||||
raise
|
||||
if not serializer.is_valid():
|
||||
detail = serializer.errors
|
||||
try:
|
||||
metadata_dump = m.all()
|
||||
except Exception as e:
|
||||
logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
|
||||
return fail_import(
|
||||
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
|
||||
)
|
||||
|
||||
final_metadata = collections.ChainMap(
|
||||
additional_data, serializer.validated_data, internal_config
|
||||
)
|
||||
additional_data["upload_source"] = upload.source
|
||||
final_metadata = collections.ChainMap(
|
||||
additional_data, serializer.validated_data, internal_config
|
||||
)
|
||||
else:
|
||||
final_metadata = collections.ChainMap(
|
||||
additional_data, forced_values, internal_config,
|
||||
)
|
||||
try:
|
||||
track = get_track_from_import_metadata(
|
||||
final_metadata, attributed_to=upload.library.actor, **forced_values
|
||||
|
@ -275,7 +298,7 @@ def process_upload(upload, update_denormalization=True):
|
|||
)
|
||||
|
||||
# update album cover, if needed
|
||||
if not track.album.attachment_cover:
|
||||
if track.album and not track.album.attachment_cover:
|
||||
populate_album_cover(
|
||||
track.album, source=final_metadata.get("upload_source"),
|
||||
)
|
||||
|
@ -466,7 +489,11 @@ def _get_track(data, attributed_to=None, **forced_values):
|
|||
track_mbid = (
|
||||
forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None)
|
||||
)
|
||||
album_mbid = getter(data, "album", "mbid")
|
||||
try:
|
||||
album_mbid = getter(data, "album", "mbid")
|
||||
except TypeError:
|
||||
# album is forced
|
||||
album_mbid = None
|
||||
track_fid = getter(data, "fid")
|
||||
|
||||
query = None
|
||||
|
@ -528,81 +555,94 @@ def _get_track(data, attributed_to=None, **forced_values):
|
|||
if "album" in forced_values:
|
||||
album = forced_values["album"]
|
||||
else:
|
||||
album_artists = getter(data, "album", "artists", default=artists) or artists
|
||||
album_artist_data = album_artists[0]
|
||||
album_artist_name = truncate(
|
||||
album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
|
||||
)
|
||||
if album_artist_name == artist_name:
|
||||
album_artist = artist
|
||||
if "artist" in forced_values:
|
||||
album_artist = forced_values["artist"]
|
||||
else:
|
||||
query = Q(name__iexact=album_artist_name)
|
||||
album_artist_mbid = album_artist_data.get("mbid", None)
|
||||
album_artist_fid = album_artist_data.get("fid", None)
|
||||
if album_artist_mbid:
|
||||
query |= Q(mbid=album_artist_mbid)
|
||||
if album_artist_fid:
|
||||
query |= Q(fid=album_artist_fid)
|
||||
defaults = {
|
||||
"name": album_artist_name,
|
||||
"mbid": album_artist_mbid,
|
||||
"fid": album_artist_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": album_artist_data.get("attributed_to", attributed_to),
|
||||
}
|
||||
if album_artist_data.get("fdate"):
|
||||
defaults["creation_date"] = album_artist_data.get("fdate")
|
||||
|
||||
album_artist, created = get_best_candidate_or_create(
|
||||
models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
||||
album_artists = getter(data, "album", "artists", default=artists) or artists
|
||||
album_artist_data = album_artists[0]
|
||||
album_artist_name = truncate(
|
||||
album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
|
||||
)
|
||||
if created:
|
||||
tags_models.add_tags(album_artist, *album_artist_data.get("tags", []))
|
||||
common_utils.attach_content(
|
||||
album_artist, "description", album_artist_data.get("description")
|
||||
)
|
||||
common_utils.attach_file(
|
||||
album_artist,
|
||||
"attachment_cover",
|
||||
album_artist_data.get("cover_data"),
|
||||
if album_artist_name == artist_name:
|
||||
album_artist = artist
|
||||
else:
|
||||
query = Q(name__iexact=album_artist_name)
|
||||
album_artist_mbid = album_artist_data.get("mbid", None)
|
||||
album_artist_fid = album_artist_data.get("fid", None)
|
||||
if album_artist_mbid:
|
||||
query |= Q(mbid=album_artist_mbid)
|
||||
if album_artist_fid:
|
||||
query |= Q(fid=album_artist_fid)
|
||||
defaults = {
|
||||
"name": album_artist_name,
|
||||
"mbid": album_artist_mbid,
|
||||
"fid": album_artist_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": album_artist_data.get(
|
||||
"attributed_to", attributed_to
|
||||
),
|
||||
}
|
||||
if album_artist_data.get("fdate"):
|
||||
defaults["creation_date"] = album_artist_data.get("fdate")
|
||||
|
||||
album_artist, created = get_best_candidate_or_create(
|
||||
models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
||||
)
|
||||
if created:
|
||||
tags_models.add_tags(
|
||||
album_artist, *album_artist_data.get("tags", [])
|
||||
)
|
||||
common_utils.attach_content(
|
||||
album_artist,
|
||||
"description",
|
||||
album_artist_data.get("description"),
|
||||
)
|
||||
common_utils.attach_file(
|
||||
album_artist,
|
||||
"attachment_cover",
|
||||
album_artist_data.get("cover_data"),
|
||||
)
|
||||
|
||||
# get / create album
|
||||
album_data = data["album"]
|
||||
album_title = truncate(album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"])
|
||||
album_fid = album_data.get("fid", None)
|
||||
if "album" in data:
|
||||
album_data = data["album"]
|
||||
album_title = truncate(
|
||||
album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"]
|
||||
)
|
||||
album_fid = album_data.get("fid", None)
|
||||
|
||||
if album_mbid:
|
||||
query = Q(mbid=album_mbid)
|
||||
if album_mbid:
|
||||
query = Q(mbid=album_mbid)
|
||||
else:
|
||||
query = Q(title__iexact=album_title, artist=album_artist)
|
||||
|
||||
if album_fid:
|
||||
query |= Q(fid=album_fid)
|
||||
defaults = {
|
||||
"title": album_title,
|
||||
"artist": album_artist,
|
||||
"mbid": album_mbid,
|
||||
"release_date": album_data.get("release_date"),
|
||||
"fid": album_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": album_data.get("attributed_to", attributed_to),
|
||||
}
|
||||
if album_data.get("fdate"):
|
||||
defaults["creation_date"] = album_data.get("fdate")
|
||||
|
||||
album, created = get_best_candidate_or_create(
|
||||
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
||||
)
|
||||
if created:
|
||||
tags_models.add_tags(album, *album_data.get("tags", []))
|
||||
common_utils.attach_content(
|
||||
album, "description", album_data.get("description")
|
||||
)
|
||||
common_utils.attach_file(
|
||||
album, "attachment_cover", album_data.get("cover_data")
|
||||
)
|
||||
else:
|
||||
query = Q(title__iexact=album_title, artist=album_artist)
|
||||
|
||||
if album_fid:
|
||||
query |= Q(fid=album_fid)
|
||||
defaults = {
|
||||
"title": album_title,
|
||||
"artist": album_artist,
|
||||
"mbid": album_mbid,
|
||||
"release_date": album_data.get("release_date"),
|
||||
"fid": album_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": album_data.get("attributed_to", attributed_to),
|
||||
}
|
||||
if album_data.get("fdate"):
|
||||
defaults["creation_date"] = album_data.get("fdate")
|
||||
|
||||
album, created = get_best_candidate_or_create(
|
||||
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
|
||||
)
|
||||
if created:
|
||||
tags_models.add_tags(album, *album_data.get("tags", []))
|
||||
common_utils.attach_content(
|
||||
album, "description", album_data.get("description")
|
||||
)
|
||||
common_utils.attach_file(
|
||||
album, "attachment_cover", album_data.get("cover_data")
|
||||
)
|
||||
|
||||
album = None
|
||||
# get / create track
|
||||
track_title = (
|
||||
forced_values["title"]
|
||||
|
@ -629,6 +669,14 @@ def _get_track(data, attributed_to=None, **forced_values):
|
|||
if "copyright" in forced_values
|
||||
else truncate(data.get("copyright"), models.MAX_LENGTHS["COPYRIGHT"])
|
||||
)
|
||||
description = (
|
||||
{"text": forced_values["description"], "content_type": "text/markdown"}
|
||||
if "description" in forced_values
|
||||
else data.get("description")
|
||||
)
|
||||
cover_data = (
|
||||
forced_values["cover"] if "cover" in forced_values else data.get("cover_data")
|
||||
)
|
||||
|
||||
query = Q(
|
||||
title__iexact=track_title,
|
||||
|
@ -670,8 +718,8 @@ def _get_track(data, attributed_to=None, **forced_values):
|
|||
forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
|
||||
)
|
||||
tags_models.add_tags(track, *tags)
|
||||
common_utils.attach_content(track, "description", data.get("description"))
|
||||
common_utils.attach_file(track, "attachment_cover", data.get("cover_data"))
|
||||
common_utils.attach_content(track, "description", description)
|
||||
common_utils.attach_file(track, "attachment_cover", cover_data)
|
||||
|
||||
return track
|
||||
|
||||
|
|
|
@ -173,6 +173,8 @@ class ArtistViewSet(
|
|||
class AlbumViewSet(
|
||||
HandleInvalidSearch,
|
||||
common_views.SkipFilterForGetObject,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.ReadOnlyModelViewSet,
|
||||
):
|
||||
queryset = (
|
||||
|
@ -202,11 +204,19 @@ class AlbumViewSet(
|
|||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["description"] = self.action in ["retrieve", "create", "update"]
|
||||
context["description"] = self.action in [
|
||||
"retrieve",
|
||||
"create",
|
||||
]
|
||||
context["user"] = self.request.user
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.action in ["destroy"]:
|
||||
queryset = queryset.exclude(artist__channel=None).filter(
|
||||
artist__attributed_to=self.request.user.actor
|
||||
)
|
||||
tracks = (
|
||||
models.Track.objects.prefetch_related("artist")
|
||||
.with_playable_uploads(utils.get_actor_from_request(self.request))
|
||||
|
@ -221,6 +231,11 @@ class AlbumViewSet(
|
|||
get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o))
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ["create"]:
|
||||
return serializers.AlbumCreateSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
class LibraryViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
|
@ -288,6 +303,7 @@ class LibraryViewSet(
|
|||
class TrackViewSet(
|
||||
HandleInvalidSearch,
|
||||
common_views.SkipFilterForGetObject,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.ReadOnlyModelViewSet,
|
||||
):
|
||||
"""
|
||||
|
@ -330,6 +346,10 @@ class TrackViewSet(
|
|||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.action in ["destroy"]:
|
||||
queryset = queryset.exclude(artist__channel=None).filter(
|
||||
artist__attributed_to=self.request.user.actor
|
||||
)
|
||||
filter_favorites = self.request.GET.get("favorites", None)
|
||||
user = self.request.user
|
||||
if user.is_authenticated and filter_favorites == "true":
|
||||
|
@ -617,18 +637,17 @@ class UploadViewSet(
|
|||
m = tasks.metadata.Metadata(upload.get_audio_file())
|
||||
except FileNotFoundError:
|
||||
return Response({"detail": "File not found"}, status=500)
|
||||
serializer = tasks.metadata.TrackMetadataSerializer(data=m)
|
||||
serializer = tasks.metadata.TrackMetadataSerializer(
|
||||
data=m, context={"strict": False}
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=500)
|
||||
payload = serializer.validated_data
|
||||
if (
|
||||
"cover_data" in payload
|
||||
and payload["cover_data"]
|
||||
and "content" in payload["cover_data"]
|
||||
):
|
||||
payload["cover_data"]["content"] = base64.b64encode(
|
||||
payload["cover_data"]["content"]
|
||||
)
|
||||
cover_data = payload.get(
|
||||
"cover_data", payload.get("album", {}).get("cover_data", {})
|
||||
)
|
||||
if cover_data and "content" in cover_data:
|
||||
cover_data["content"] = base64.b64encode(cover_data["content"])
|
||||
return Response(payload, status=200)
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
|
@ -1,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
|
@ -15,7 +17,10 @@ def test_channel(factories, now):
|
|||
def test_channel_get_rss_url_local(factories):
|
||||
channel = factories["audio.Channel"](artist__local=True)
|
||||
expected = federation_utils.full_url(
|
||||
reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
|
||||
reverse(
|
||||
"api:v1:channels-rss",
|
||||
kwargs={"composite": channel.actor.preferred_username},
|
||||
)
|
||||
)
|
||||
assert channel.get_rss_url() == expected
|
||||
|
||||
|
@ -23,3 +28,15 @@ def test_channel_get_rss_url_local(factories):
|
|||
def test_channel_get_rss_url_remote(factories):
|
||||
channel = factories["audio.Channel"]()
|
||||
assert channel.get_rss_url() == channel.rss_url
|
||||
|
||||
|
||||
def test_channel_delete(factories):
|
||||
channel = factories["audio.Channel"]()
|
||||
library = channel.library
|
||||
actor = channel.library
|
||||
artist = channel.artist
|
||||
channel.delete()
|
||||
|
||||
for obj in [library, actor, artist]:
|
||||
with pytest.raises(obj.DoesNotExist):
|
||||
obj.refresh_from_db()
|
||||
|
|
|
@ -3,6 +3,8 @@ import datetime
|
|||
import pytest
|
||||
import pytz
|
||||
|
||||
from django.contrib.staticfiles.templatetags.staticfiles import static
|
||||
|
||||
from funkwhale_api.audio import serializers
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
|
@ -11,20 +13,21 @@ from funkwhale_api.federation import utils as federation_utils
|
|||
from funkwhale_api.music import serializers as music_serializers
|
||||
|
||||
|
||||
def test_channel_serializer_create(factories):
|
||||
def test_channel_serializer_create(factories, mocker):
|
||||
attributed_to = factories["federation.Actor"](local=True)
|
||||
|
||||
attachment = factories["common.Attachment"](actor=attributed_to)
|
||||
request = mocker.Mock(user=mocker.Mock(actor=attributed_to))
|
||||
data = {
|
||||
# TODO: cover
|
||||
"name": "My channel",
|
||||
"username": "mychannel",
|
||||
"description": {"text": "This is my channel", "content_type": "text/markdown"},
|
||||
"tags": ["hello", "world"],
|
||||
"content_category": "other",
|
||||
"cover": attachment.uuid,
|
||||
}
|
||||
|
||||
serializer = serializers.ChannelCreateSerializer(
|
||||
data=data, context={"actor": attributed_to}
|
||||
data=data, context={"actor": attributed_to, "request": request}
|
||||
)
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
|
||||
|
@ -37,14 +40,12 @@ def test_channel_serializer_create(factories):
|
|||
== data["tags"]
|
||||
)
|
||||
assert channel.artist.description.text == data["description"]["text"]
|
||||
assert channel.artist.attachment_cover == attachment
|
||||
assert channel.artist.content_category == data["content_category"]
|
||||
assert (
|
||||
channel.artist.description.content_type == data["description"]["content_type"]
|
||||
)
|
||||
assert channel.attributed_to == attributed_to
|
||||
assert channel.actor.summary == common_utils.render_html(
|
||||
data["description"]["text"], "text/markdown"
|
||||
)
|
||||
assert channel.actor.preferred_username == data["username"]
|
||||
assert channel.actor.name == data["name"]
|
||||
assert channel.library.privacy_level == "everyone"
|
||||
|
@ -150,24 +151,31 @@ def test_channel_serializer_create_podcast(factories):
|
|||
assert channel.metadata == data["metadata"]
|
||||
|
||||
|
||||
def test_channel_serializer_update(factories):
|
||||
channel = factories["audio.Channel"](artist__set_tags=["rock"])
|
||||
|
||||
def test_channel_serializer_update(factories, mocker):
|
||||
channel = factories["audio.Channel"](
|
||||
artist__set_tags=["rock"], attributed_to__local=True
|
||||
)
|
||||
attributed_to = channel.attributed_to
|
||||
attachment = factories["common.Attachment"](actor=attributed_to)
|
||||
request = mocker.Mock(user=mocker.Mock(actor=attributed_to))
|
||||
data = {
|
||||
# TODO: cover
|
||||
"name": "My channel",
|
||||
"description": {"text": "This is my channel", "content_type": "text/markdown"},
|
||||
"tags": ["hello", "world"],
|
||||
"content_category": "other",
|
||||
"cover": attachment.uuid,
|
||||
}
|
||||
|
||||
serializer = serializers.ChannelUpdateSerializer(channel, data=data)
|
||||
serializer = serializers.ChannelUpdateSerializer(
|
||||
channel, data=data, context={"request": request}
|
||||
)
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
|
||||
serializer.save()
|
||||
channel.refresh_from_db()
|
||||
|
||||
assert channel.artist.name == data["name"]
|
||||
assert channel.artist.attachment_cover == attachment
|
||||
assert channel.artist.content_category == data["content_category"]
|
||||
assert (
|
||||
sorted(channel.artist.tagged_items.values_list("tag__name", flat=True))
|
||||
|
@ -281,7 +289,7 @@ def test_rss_item_serializer(factories):
|
|||
{
|
||||
"url": federation_utils.full_url(upload.get_listen_url("mp3")),
|
||||
"length": upload.size,
|
||||
"type": upload.mimetype,
|
||||
"type": "audio/mpeg",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
@ -350,6 +358,30 @@ def test_rss_channel_serializer(factories):
|
|||
assert serializers.rss_serialize_channel(channel) == expected
|
||||
|
||||
|
||||
def test_rss_channel_serializer_placeholder_image(factories):
|
||||
description = factories["common.Content"]()
|
||||
channel = factories["audio.Channel"](
|
||||
artist__set_tags=["pop", "rock"],
|
||||
artist__description=description,
|
||||
artist__attachment_cover=None,
|
||||
)
|
||||
setattr(
|
||||
channel.artist,
|
||||
"_prefetched_tagged_items",
|
||||
channel.artist.tagged_items.order_by("tag__name"),
|
||||
)
|
||||
|
||||
expected = [
|
||||
{
|
||||
"href": federation_utils.full_url(
|
||||
static("images/podcasts-cover-placeholder.png")
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert serializers.rss_serialize_channel(channel)["itunes:image"] == expected
|
||||
|
||||
|
||||
def test_serialize_full_channel(factories):
|
||||
channel = factories["audio.Channel"]()
|
||||
upload1 = factories["music.Upload"](playable=True)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
import urllib.parse
|
||||
|
||||
from django.urls import reverse
|
||||
|
@ -7,18 +9,21 @@ from funkwhale_api.federation import utils as federation_utils
|
|||
from funkwhale_api.music import serializers
|
||||
|
||||
|
||||
def test_library_artist(spa_html, no_api_auth, client, factories, settings):
|
||||
@pytest.mark.parametrize("attribute", ["uuid", "actor.full_username"])
|
||||
def test_channel_detail(attribute, spa_html, no_api_auth, client, factories, settings):
|
||||
channel = factories["audio.Channel"]()
|
||||
factories["music.Upload"](playable=True, library=channel.library)
|
||||
url = "/channels/{}".format(channel.uuid)
|
||||
url = "/channels/{}".format(utils.recursive_getattr(channel, attribute))
|
||||
detail_url = "/channels/{}".format(channel.actor.full_username)
|
||||
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
expected_metas = [
|
||||
{
|
||||
"tag": "meta",
|
||||
"property": "og:url",
|
||||
"content": utils.join_url(settings.FUNKWHALE_URL, url),
|
||||
"content": utils.join_url(settings.FUNKWHALE_URL, detail_url),
|
||||
},
|
||||
{"tag": "meta", "property": "og:title", "content": channel.artist.name},
|
||||
{"tag": "meta", "property": "og:type", "content": "profile"},
|
||||
|
@ -47,7 +52,9 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings):
|
|||
"href": (
|
||||
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
|
||||
+ "?format=json&url={}".format(
|
||||
urllib.parse.quote_plus(utils.join_url(settings.FUNKWHALE_URL, url))
|
||||
urllib.parse.quote_plus(
|
||||
utils.join_url(settings.FUNKWHALE_URL, detail_url)
|
||||
)
|
||||
)
|
||||
),
|
||||
},
|
||||
|
|
|
@ -3,8 +3,11 @@ import pytest
|
|||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.audio import categories
|
||||
from funkwhale_api.audio import serializers
|
||||
from funkwhale_api.audio import views
|
||||
from funkwhale_api.common import locales
|
||||
from funkwhale_api.common import utils
|
||||
|
||||
|
||||
def test_channel_create(logged_in_api_client):
|
||||
|
@ -38,15 +41,25 @@ def test_channel_create(logged_in_api_client):
|
|||
== data["tags"]
|
||||
)
|
||||
assert channel.attributed_to == actor
|
||||
assert channel.actor.summary == channel.artist.description.rendered
|
||||
assert channel.artist.description.text == data["description"]["text"]
|
||||
assert (
|
||||
channel.artist.description.content_type == data["description"]["content_type"]
|
||||
)
|
||||
assert channel.actor.preferred_username == data["username"]
|
||||
assert channel.library.privacy_level == "everyone"
|
||||
assert channel.library.actor == actor
|
||||
|
||||
|
||||
def test_channel_detail(factories, logged_in_api_client):
|
||||
channel = factories["audio.Channel"](artist__description=None)
|
||||
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
|
||||
@pytest.mark.parametrize(
|
||||
"field", ["uuid", "actor.preferred_username", "actor.full_username"],
|
||||
)
|
||||
def test_channel_detail(field, factories, logged_in_api_client):
|
||||
channel = factories["audio.Channel"](artist__description=None, local=True)
|
||||
|
||||
url = reverse(
|
||||
"api:v1:channels-detail",
|
||||
kwargs={"composite": utils.recursive_getattr(channel, field)},
|
||||
)
|
||||
setattr(channel.artist, "_tracks_count", 0)
|
||||
setattr(channel.artist, "_prefetched_tagged_items", [])
|
||||
|
||||
|
@ -85,7 +98,7 @@ def test_channel_update(logged_in_api_client, factories):
|
|||
"name": "new name"
|
||||
}
|
||||
|
||||
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
|
||||
response = logged_in_api_client.patch(url, data)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
@ -101,7 +114,7 @@ def test_channel_update_permission(logged_in_api_client, factories):
|
|||
|
||||
data = {"name": "new name"}
|
||||
|
||||
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
|
||||
response = logged_in_api_client.patch(url, data)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
@ -112,7 +125,7 @@ def test_channel_delete(logged_in_api_client, factories, mocker):
|
|||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"](attributed_to=actor)
|
||||
|
||||
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
|
||||
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
|
||||
response = logged_in_api_client.delete(url)
|
||||
|
||||
|
@ -131,7 +144,7 @@ def test_channel_delete_permission(logged_in_api_client, factories):
|
|||
logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"]()
|
||||
|
||||
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
|
||||
response = logged_in_api_client.patch(url)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
@ -151,7 +164,7 @@ def test_channel_views_disabled_via_feature_flag(
|
|||
def test_channel_subscribe(factories, logged_in_api_client):
|
||||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"](artist__description=None)
|
||||
url = reverse("api:v1:channels-subscribe", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-subscribe", kwargs={"composite": channel.uuid})
|
||||
|
||||
response = logged_in_api_client.post(url)
|
||||
|
||||
|
@ -173,7 +186,7 @@ def test_channel_unsubscribe(factories, logged_in_api_client):
|
|||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"]()
|
||||
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
|
||||
url = reverse("api:v1:channels-unsubscribe", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-unsubscribe", kwargs={"composite": channel.uuid})
|
||||
|
||||
response = logged_in_api_client.post(url)
|
||||
|
||||
|
@ -229,7 +242,7 @@ def test_channel_rss_feed(factories, api_client, preferences):
|
|||
channel=channel, uploads=[upload2, upload1]
|
||||
)
|
||||
|
||||
url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
|
||||
|
||||
response = api_client.get(url)
|
||||
|
||||
|
@ -242,7 +255,7 @@ def test_channel_rss_feed_remote(factories, api_client, preferences):
|
|||
preferences["common__api_authentication_required"] = False
|
||||
channel = factories["audio.Channel"]()
|
||||
|
||||
url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
|
||||
|
||||
response = api_client.get(url)
|
||||
|
||||
|
@ -253,8 +266,28 @@ def test_channel_rss_feed_authentication_required(factories, api_client, prefere
|
|||
preferences["common__api_authentication_required"] = True
|
||||
channel = factories["audio.Channel"](local=True)
|
||||
|
||||
url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
|
||||
url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
|
||||
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_channel_metadata_choices(factories, api_client):
|
||||
|
||||
expected = {
|
||||
"language": [
|
||||
{"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
|
||||
],
|
||||
"itunes_category": [
|
||||
{"value": code, "label": code, "children": children}
|
||||
for code, children in categories.ITUNES_CATEGORIES.items()
|
||||
],
|
||||
}
|
||||
|
||||
url = reverse("api:v1:channels-metadata_choices")
|
||||
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected
|
||||
|
|
|
@ -174,6 +174,17 @@ def test_attach_file_url_fetch(factories, r_mock):
|
|||
assert new_attachment.mimetype == data["mimetype"]
|
||||
|
||||
|
||||
def test_attach_file_attachment(factories, r_mock):
|
||||
album = factories["music.Album"]()
|
||||
|
||||
data = factories["common.Attachment"]()
|
||||
utils.attach_file(album, "attachment_cover", data)
|
||||
|
||||
album.refresh_from_db()
|
||||
|
||||
assert album.attachment_cover == data
|
||||
|
||||
|
||||
def test_attach_file_content(factories, r_mock):
|
||||
album = factories["music.Album"]()
|
||||
|
||||
|
|
|
@ -78,12 +78,21 @@ def test_actor_get_quota(factories):
|
|||
audio_file__data=b"aaaa",
|
||||
)
|
||||
|
||||
# this one is in a channel
|
||||
channel = factories["audio.Channel"](attributed_to=library.actor)
|
||||
factories["music.Upload"](
|
||||
library=channel.library,
|
||||
import_status="finished",
|
||||
audio_file__from_path=None,
|
||||
audio_file__data=b"aaaaa",
|
||||
)
|
||||
|
||||
expected = {
|
||||
"total": 19,
|
||||
"total": 24,
|
||||
"pending": 1,
|
||||
"skipped": 2,
|
||||
"errored": 3,
|
||||
"finished": 8,
|
||||
"finished": 13,
|
||||
"draft": 5,
|
||||
}
|
||||
|
||||
|
|
|
@ -117,6 +117,41 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
|
|||
)
|
||||
|
||||
|
||||
def test_inbox_follow_channel_autoapprove(factories, mocker):
|
||||
mocked_outbox_dispatch = mocker.patch(
|
||||
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
|
||||
)
|
||||
|
||||
local_actor = factories["users.User"]().create_actor()
|
||||
remote_actor = factories["federation.Actor"]()
|
||||
channel = factories["audio.Channel"](attributed_to=local_actor)
|
||||
ii = factories["federation.InboxItem"](actor=channel.actor)
|
||||
|
||||
payload = {
|
||||
"type": "Follow",
|
||||
"id": "https://test.follow",
|
||||
"actor": remote_actor.fid,
|
||||
"object": channel.actor.fid,
|
||||
}
|
||||
|
||||
result = routes.inbox_follow(
|
||||
payload,
|
||||
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
|
||||
)
|
||||
follow = channel.actor.received_follows.latest("id")
|
||||
|
||||
assert result["object"] == channel.actor
|
||||
assert result["related_object"] == follow
|
||||
|
||||
assert follow.fid == payload["id"]
|
||||
assert follow.actor == remote_actor
|
||||
assert follow.approved is True
|
||||
|
||||
mocked_outbox_dispatch.assert_called_once_with(
|
||||
{"type": "Accept"}, context={"follow": follow}
|
||||
)
|
||||
|
||||
|
||||
def test_inbox_follow_library_manual_approve(factories, mocker):
|
||||
mocked_outbox_dispatch = mocker.patch(
|
||||
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
|
||||
|
|
|
@ -657,6 +657,34 @@ def test_serializer_empty_fields(field_name):
|
|||
assert serializer.validated_data == expected
|
||||
|
||||
|
||||
def test_serializer_strict_mode_false():
|
||||
data = {}
|
||||
expected = {
|
||||
"artists": [{"name": None, "mbid": None}],
|
||||
"album": {
|
||||
"title": "[Unknown Album]",
|
||||
"mbid": None,
|
||||
"release_date": None,
|
||||
"artists": [],
|
||||
"cover_data": None,
|
||||
},
|
||||
}
|
||||
serializer = metadata.TrackMetadataSerializer(
|
||||
data=metadata.FakeMetadata(data), context={"strict": False}
|
||||
)
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
assert serializer.validated_data == expected
|
||||
|
||||
|
||||
def test_serializer_strict_mode_true():
|
||||
data = {}
|
||||
serializer = metadata.TrackMetadataSerializer(
|
||||
data=metadata.FakeMetadata(data), context={"strict": True}
|
||||
)
|
||||
with pytest.raises(metadata.serializers.ValidationError):
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
def test_artist_field_featuring():
|
||||
data = {
|
||||
"artist": "Santana feat. Chris Cornell",
|
||||
|
|
|
@ -361,6 +361,19 @@ def test_manage_upload_action_relaunch_import(factories, mocker):
|
|||
assert m.call_count == 3
|
||||
|
||||
|
||||
def test_manage_upload_action_publish(factories, mocker):
|
||||
m = mocker.patch("funkwhale_api.common.utils.on_commit")
|
||||
|
||||
draft = factories["music.Upload"](import_status="draft")
|
||||
s = serializers.UploadActionSerializer(queryset=None)
|
||||
|
||||
s.handle_publish(models.Upload.objects.all())
|
||||
|
||||
draft.refresh_from_db()
|
||||
assert draft.import_status == "pending"
|
||||
m.assert_any_call(tasks.process_upload.delay, upload_id=draft.pk)
|
||||
|
||||
|
||||
def test_serialize_upload(factories):
|
||||
upload = factories["music.Upload"]()
|
||||
|
||||
|
@ -511,6 +524,18 @@ def test_upload_import_metadata_serializer_full():
|
|||
assert serializer.validated_data == expected
|
||||
|
||||
|
||||
def test_upload_import_metadata_serializer_channel_checks_owned_album(factories):
|
||||
channel = factories["audio.Channel"]()
|
||||
album = factories["music.Album"]()
|
||||
data = {"title": "hello", "album": album.pk}
|
||||
serializer = serializers.ImportMetadataSerializer(
|
||||
data=data, context={"channel": channel}
|
||||
)
|
||||
|
||||
with pytest.raises(serializers.serializers.ValidationError):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
def test_upload_with_channel_keeps_import_metadata(factories, uploaded_audio_file):
|
||||
channel = factories["audio.Channel"](attributed_to__local=True)
|
||||
user = channel.attributed_to.user
|
||||
|
|
|
@ -7,7 +7,9 @@ from funkwhale_api.music import serializers
|
|||
|
||||
|
||||
def test_library_track(spa_html, no_api_auth, client, factories, settings):
|
||||
upload = factories["music.Upload"](playable=True, track__disc_number=1)
|
||||
upload = factories["music.Upload"](
|
||||
playable=True, track__disc_number=1, track__attachment_cover=None
|
||||
)
|
||||
track = upload.track
|
||||
url = "/library/tracks/{}".format(track.pk)
|
||||
|
||||
|
|
|
@ -1014,45 +1014,73 @@ def test_get_track_from_import_metadata_with_forced_values(factories, mocker, fa
|
|||
)
|
||||
|
||||
|
||||
def test_get_track_from_import_metadata_with_forced_values_album(
|
||||
factories, mocker, faker
|
||||
):
|
||||
channel = factories["audio.Channel"]()
|
||||
album = factories["music.Album"](artist=channel.artist)
|
||||
|
||||
forced_values = {
|
||||
"title": "Real title",
|
||||
"album": album.pk,
|
||||
}
|
||||
upload = factories["music.Upload"](
|
||||
import_metadata=forced_values, library=channel.library, track=None
|
||||
)
|
||||
tasks.process_upload(upload_id=upload.pk)
|
||||
upload.refresh_from_db()
|
||||
assert upload.import_status == "finished"
|
||||
|
||||
assert upload.track.title == forced_values["title"]
|
||||
assert upload.track.album == album
|
||||
assert upload.track.artist == channel.artist
|
||||
|
||||
|
||||
def test_process_channel_upload_forces_artist_and_attributed_to(
|
||||
factories, mocker, faker
|
||||
):
|
||||
track = factories["music.Track"]()
|
||||
channel = factories["audio.Channel"]()
|
||||
channel = factories["audio.Channel"](attributed_to__local=True)
|
||||
attachment = factories["common.Attachment"](actor=channel.attributed_to)
|
||||
import_metadata = {
|
||||
"title": "Real title",
|
||||
"position": 3,
|
||||
"copyright": "Real copyright",
|
||||
"tags": ["hello", "world"],
|
||||
"description": "my description",
|
||||
"cover": attachment.uuid,
|
||||
}
|
||||
|
||||
expected_forced_values = import_metadata.copy()
|
||||
expected_forced_values["artist"] = channel.artist
|
||||
expected_forced_values["attributed_to"] = channel.attributed_to
|
||||
expected_forced_values["cover"] = attachment
|
||||
upload = factories["music.Upload"](
|
||||
track=None, import_metadata=import_metadata, library=channel.library
|
||||
)
|
||||
get_track_from_import_metadata = mocker.patch.object(
|
||||
tasks, "get_track_from_import_metadata", return_value=track
|
||||
)
|
||||
get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
|
||||
|
||||
tasks.process_upload(upload_id=upload.pk)
|
||||
|
||||
upload.refresh_from_db()
|
||||
serializer = tasks.metadata.TrackMetadataSerializer(
|
||||
data=tasks.metadata.Metadata(upload.get_audio_file())
|
||||
)
|
||||
assert serializer.is_valid() is True
|
||||
audio_metadata = serializer.validated_data
|
||||
|
||||
expected_final_metadata = tasks.collections.ChainMap(
|
||||
{"upload_source": None}, audio_metadata, {"funkwhale": {}},
|
||||
{"upload_source": None}, expected_forced_values, {"funkwhale": {}},
|
||||
)
|
||||
assert upload.import_status == "finished"
|
||||
get_track_from_import_metadata.assert_called_once_with(
|
||||
expected_final_metadata, **expected_forced_values
|
||||
expected_final_metadata,
|
||||
attributed_to=channel.attributed_to,
|
||||
**expected_forced_values
|
||||
)
|
||||
|
||||
assert upload.track.description.content_type == "text/markdown"
|
||||
assert upload.track.description.text == import_metadata["description"]
|
||||
assert upload.track.title == import_metadata["title"]
|
||||
assert upload.track.position == import_metadata["position"]
|
||||
assert upload.track.copyright == import_metadata["copyright"]
|
||||
assert upload.track.get_tags() == import_metadata["tags"]
|
||||
assert upload.track.artist == channel.artist
|
||||
assert upload.track.attributed_to == channel.attributed_to
|
||||
assert upload.track.attachment_cover == attachment
|
||||
|
||||
|
||||
def test_process_upload_uses_import_metadata_if_valid(factories, mocker):
|
||||
track = factories["music.Track"]()
|
||||
|
|
|
@ -723,6 +723,7 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f
|
|||
"source": "upload://test",
|
||||
"import_reference": "test",
|
||||
"library": library.uuid,
|
||||
"import_metadata": '{"title": "foo"}',
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -735,6 +736,38 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f
|
|||
assert upload.source == "upload://test"
|
||||
assert upload.import_reference == "test"
|
||||
assert upload.import_status == "pending"
|
||||
assert upload.import_metadata == {"title": "foo"}
|
||||
assert upload.track is None
|
||||
m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk)
|
||||
|
||||
|
||||
def test_user_can_create_upload_in_channel(
|
||||
logged_in_api_client, factories, mocker, audio_file
|
||||
):
|
||||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"](attributed_to=actor)
|
||||
url = reverse("api:v1:uploads-list")
|
||||
m = mocker.patch("funkwhale_api.common.utils.on_commit")
|
||||
album = factories["music.Album"](artist=channel.artist)
|
||||
response = logged_in_api_client.post(
|
||||
url,
|
||||
{
|
||||
"audio_file": audio_file,
|
||||
"source": "upload://test",
|
||||
"import_reference": "test",
|
||||
"channel": channel.uuid,
|
||||
"import_metadata": '{"title": "foo", "album": ' + str(album.pk) + "}",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
upload = channel.library.uploads.latest("id")
|
||||
|
||||
assert upload.source == "upload://test"
|
||||
assert upload.import_reference == "test"
|
||||
assert upload.import_status == "pending"
|
||||
assert upload.import_metadata == {"title": "foo", "album": album.pk}
|
||||
assert upload.track is None
|
||||
m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk)
|
||||
|
||||
|
@ -1318,3 +1351,106 @@ def test_detail_includes_description_key(
|
|||
response = logged_in_api_client.get(url)
|
||||
|
||||
assert response.data["description"] is None
|
||||
|
||||
|
||||
def test_channel_owner_can_create_album(factories, logged_in_api_client):
|
||||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"](attributed_to=actor)
|
||||
attachment = factories["common.Attachment"](actor=actor)
|
||||
url = reverse("api:v1:albums-list")
|
||||
|
||||
data = {
|
||||
"artist": channel.artist.pk,
|
||||
"cover": attachment.uuid,
|
||||
"title": "Hello world",
|
||||
"release_date": "2019-01-02",
|
||||
"tags": ["Hello", "World"],
|
||||
"description": {"content_type": "text/markdown", "text": "hello world"},
|
||||
}
|
||||
|
||||
response = logged_in_api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
album = channel.artist.albums.get(title=data["title"])
|
||||
|
||||
assert (
|
||||
response.data
|
||||
== serializers.AlbumSerializer(album, context={"description": True}).data
|
||||
)
|
||||
assert album.attachment_cover == attachment
|
||||
assert album.attributed_to == actor
|
||||
assert album.release_date == datetime.date(2019, 1, 2)
|
||||
assert album.get_tags() == ["Hello", "World"]
|
||||
assert album.description.content_type == "text/markdown"
|
||||
assert album.description.text == "hello world"
|
||||
|
||||
|
||||
def test_channel_owner_can_delete_album(factories, logged_in_api_client):
|
||||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"](attributed_to=actor)
|
||||
album = factories["music.Album"](artist=channel.artist)
|
||||
url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
|
||||
|
||||
response = logged_in_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 204
|
||||
with pytest.raises(album.DoesNotExist):
|
||||
album.refresh_from_db()
|
||||
|
||||
|
||||
def test_other_user_cannot_create_album(factories, logged_in_api_client):
|
||||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"]()
|
||||
attachment = factories["common.Attachment"](actor=actor)
|
||||
url = reverse("api:v1:albums-list")
|
||||
|
||||
data = {
|
||||
"artist": channel.artist.pk,
|
||||
"cover": attachment.uuid,
|
||||
"title": "Hello world",
|
||||
"release_date": "2019-01-02",
|
||||
"tags": ["Hello", "World"],
|
||||
"description": {"content_type": "text/markdown", "text": "hello world"},
|
||||
}
|
||||
|
||||
response = logged_in_api_client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_other_user_cannot_delete_album(factories, logged_in_api_client):
|
||||
logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"]()
|
||||
album = factories["music.Album"](artist=channel.artist)
|
||||
url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
|
||||
|
||||
response = logged_in_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
album.refresh_from_db()
|
||||
|
||||
|
||||
def test_channel_owner_can_delete_track(factories, logged_in_api_client):
|
||||
actor = logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"](attributed_to=actor)
|
||||
track = factories["music.Track"](artist=channel.artist)
|
||||
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
|
||||
|
||||
response = logged_in_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 204
|
||||
with pytest.raises(track.DoesNotExist):
|
||||
track.refresh_from_db()
|
||||
|
||||
|
||||
def test_other_user_cannot_delete_track(factories, logged_in_api_client):
|
||||
logged_in_api_client.user.create_actor()
|
||||
channel = factories["audio.Channel"]()
|
||||
track = factories["music.Track"](artist=channel.artist)
|
||||
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
|
||||
|
||||
response = logged_in_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
track.refresh_from_db()
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
"axios": "^0.18.0",
|
||||
"diff": "^4.0.1",
|
||||
"django-channels": "^1.1.6",
|
||||
"fomantic-ui-css": "^2.7",
|
||||
"fomantic-ui-css": "^2.8.3",
|
||||
"howler": "^2.0.14",
|
||||
"js-logger": "^1.4.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}]">
|
||||
<div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}, `is-${ $store.getters['ui/windowSize']}`]">
|
||||
<!-- here, we display custom stylesheets, if any -->
|
||||
<link
|
||||
v-for="url in customStylesheets"
|
||||
|
@ -33,6 +33,7 @@
|
|||
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
|
||||
></app-footer>
|
||||
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
|
||||
<channel-upload-modal v-if="$store.state.auth.authenticated"></channel-upload-modal>
|
||||
<filter-modal v-if="$store.state.auth.authenticated"></filter-modal>
|
||||
<report-modal></report-modal>
|
||||
<shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
|
||||
|
@ -57,6 +58,7 @@ export default {
|
|||
Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"),
|
||||
Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"),
|
||||
PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"),
|
||||
ChannelUploadModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/channels/UploadModal"),
|
||||
Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"),
|
||||
AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"),
|
||||
ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"),
|
||||
|
@ -393,6 +395,13 @@ export default {
|
|||
top: 0;
|
||||
}
|
||||
}
|
||||
.dimmed {
|
||||
.ui.bottom-player {
|
||||
@include media("<desktop") {
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
#app.queue-focused {
|
||||
.queue-not-focused {
|
||||
@include media("<desktop") {
|
||||
|
|
|
@ -103,7 +103,7 @@
|
|||
</td>
|
||||
<td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></td>
|
||||
<td class="artist" :title="track.artist.name" ><div class="ellipsis">{{ track.artist.name }}</div></td>
|
||||
<td class="album">
|
||||
<td class="album" v-if="track.album">
|
||||
<div class="ellipsis " v-if="track.album" :title="track.album.title">{{ track.album.title }}</div>
|
||||
</td>
|
||||
<td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</td>
|
||||
|
@ -236,7 +236,7 @@ export default {
|
|||
this.fetchTracks({channel: id, playable: true, include_channels: 'true', ordering: "-creation_date"})
|
||||
}
|
||||
if (type === 'artist') {
|
||||
this.fetchTracks({artist: id, playable: true, ordering: "-release_date,disc_number,position"})
|
||||
this.fetchTracks({artist: id, playable: true, include_channels: 'true', ordering: "-release_date,disc_number,position"})
|
||||
}
|
||||
if (type === 'playlist') {
|
||||
this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`)
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
</form>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
</div>
|
||||
</section>
|
||||
<footer class="actions">
|
||||
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div>
|
||||
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div>
|
||||
</footer>
|
||||
</modal>
|
||||
</template>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<div class="card app-card">
|
||||
<div
|
||||
@click="$router.push({name: 'channels.detail', params: {id: object.uuid}})"
|
||||
:class="['ui', 'head-image', 'padded image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
|
||||
@click="$router.push({name: 'channels.detail', params: {id: urlId}})"
|
||||
:class="['ui', 'head-image', {'circular': object.artist.content_category != 'podcast'}, {'padded': object.artist.content_category === 'podcast'}, 'image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
|
||||
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object.artist"></play-button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<strong>
|
||||
<router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: object.uuid}}">
|
||||
<router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: urlId}}">
|
||||
{{ object.artist.name }}
|
||||
</router-link>
|
||||
</strong>
|
||||
|
@ -49,6 +49,15 @@ export default {
|
|||
return null
|
||||
}
|
||||
return url
|
||||
},
|
||||
urlId () {
|
||||
if (this.object.actor && this.object.actor.is_local) {
|
||||
return this.object.actor.preferred_username
|
||||
} else if (this.object.actor) {
|
||||
return this.object.actor.full_username
|
||||
} else {
|
||||
return this.object.uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="channel-entry-card">
|
||||
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
|
||||
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover && cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
|
||||
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png">
|
||||
<div class="content">
|
||||
<strong>
|
||||
|
@ -39,6 +39,9 @@ export default {
|
|||
return url
|
||||
},
|
||||
cover () {
|
||||
if (this.entry.cover) {
|
||||
return this.entry.cover
|
||||
}
|
||||
if (this.entry.album && this.entry.album.cover) {
|
||||
return this.entry.album.cover
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}]">
|
||||
<span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button']">
|
||||
<button
|
||||
v-if="!dropdownOnly"
|
||||
:title="labels.playNow"
|
||||
|
@ -102,7 +102,9 @@ export default {
|
|||
}
|
||||
if (this.track) {
|
||||
return this.track.uploads && this.track.uploads.length > 0
|
||||
} else if (this.artist) {
|
||||
} else if (this.artist && this.artist.tracks_count) {
|
||||
return this.artist.tracks_count > 0
|
||||
} else if (this.artist && this.artist.albums) {
|
||||
return this.artist.albums.filter((a) => {
|
||||
return a.is_playable === true
|
||||
}).length > 0
|
||||
|
@ -189,10 +191,10 @@ export default {
|
|||
resolve(tracks)
|
||||
})
|
||||
} else if (self.artist) {
|
||||
let params = {'artist': self.artist.id, 'ordering': 'album__release_date,disc_number,position'}
|
||||
let params = {'artist': self.artist.id, include_channels: 'true', 'ordering': 'album__release_date,disc_number,position'}
|
||||
self.getTracksPage(1, params, resolve)
|
||||
} else if (self.album) {
|
||||
let params = {'album': self.album.id, 'ordering': 'disc_number,position'}
|
||||
let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'}
|
||||
self.getTracksPage(1, params, resolve)
|
||||
}
|
||||
})
|
||||
|
@ -255,9 +257,27 @@ export default {
|
|||
// works as expected
|
||||
self.$refs[$el.data('ref')].click()
|
||||
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
|
||||
},
|
||||
})
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown('show', function () {
|
||||
// little magic to ensure the menu is always visible in the viewport
|
||||
// By default, try to diplay it on the right if there is enough room
|
||||
let menu = jQuery(self.$el).find('.ui.dropdown').find(".menu")
|
||||
let viewportOffset = menu.get(0).getBoundingClientRect();
|
||||
let left = viewportOffset.left;
|
||||
let viewportWidth = document.documentElement.clientWidth
|
||||
let rightOverflow = viewportOffset.right - viewportWidth
|
||||
let leftOverflow = -viewportOffset.left
|
||||
let offset = 0
|
||||
if (rightOverflow > 0) {
|
||||
offset = -rightOverflow - 5
|
||||
menu.css({cssText: `left: ${offset}px !important;`});
|
||||
}
|
||||
else if (leftOverflow > 0) {
|
||||
offset = leftOverflow + 5
|
||||
menu.css({cssText: `right: -${offset}px !important;`});
|
||||
}
|
||||
})
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown('show')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,7 +48,9 @@
|
|||
@input="submitAvatar($event)"
|
||||
:initial-value="initialAvatar"
|
||||
:required="false"
|
||||
@delete="avatar = {uuid: null}"></attachment-input>
|
||||
@delete="avatar = {uuid: null}">
|
||||
<translate translate-context="Content/Channel/*" slot="label">Avatar</translate>
|
||||
</attachment-input>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
@ -79,8 +81,7 @@
|
|||
<password-input required v-model="new_password" />
|
||||
</div>
|
||||
<dangerous-button
|
||||
color="yellow"
|
||||
:class="['ui', {'loading': isLoading}, 'button']"
|
||||
:class="['ui', {'loading': isLoading}, 'yellow', 'button']"
|
||||
:action="submitPassword">
|
||||
<translate translate-context="Content/Settings/Button.Label">Change password</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Settings/Title">Change your password?</translate></p>
|
||||
|
@ -177,7 +178,7 @@
|
|||
</td>
|
||||
<td>
|
||||
<dangerous-button
|
||||
class="ui tiny basic button"
|
||||
class="ui tiny basic red button"
|
||||
@confirm="revokeApp(app.client_id)">
|
||||
<translate translate-context="*/*/*/Verb">Revoke</translate>
|
||||
<p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Revoke access for application "%{ application }"?</p>
|
||||
|
@ -236,7 +237,7 @@
|
|||
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
|
||||
</router-link>
|
||||
<dangerous-button
|
||||
class="ui tiny basic button"
|
||||
class="ui tiny basic red button"
|
||||
@confirm="deleteApp(app.client_id)">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Delete application "%{ application }"?</p>
|
||||
|
@ -282,7 +283,7 @@
|
|||
<password-input required v-model="password" />
|
||||
</div>
|
||||
<dangerous-button
|
||||
:class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'button']"
|
||||
:class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'red', 'button']"
|
||||
:action="deleteAccount">
|
||||
<translate translate-context="*/*/Button.Label">Delete my account…</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Settings/Title">Do you want to delete your account?</translate></p>
|
||||
|
|
|
@ -33,8 +33,7 @@
|
|||
</div>
|
||||
<dangerous-button
|
||||
v-if="token"
|
||||
color="grey"
|
||||
:class="['ui', {'loading': isLoading}, 'button']"
|
||||
:class="['ui', {'loading': isLoading}, 'grey', 'button']"
|
||||
:action="requestNewToken">
|
||||
<translate translate-context="*/Settings/Button.Label/Verb">Request a new password</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Settings/Title">Request a new Subsonic API password?</translate></p>
|
||||
|
@ -48,8 +47,7 @@
|
|||
@click="requestNewToken"><translate translate-context="Content/Settings/Button.Label/Verb">Request a password</translate></button>
|
||||
<dangerous-button
|
||||
v-if="token"
|
||||
color="yellow"
|
||||
:class="['ui', {'loading': isLoading}, 'button']"
|
||||
:class="['ui', {'loading': isLoading}, 'yellow', 'button']"
|
||||
:action="disable">
|
||||
<translate translate-context="Content/Settings/Button.Label/Verb">Disable Subsonic access</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Settings/Title">Disable Subsonic API access?</translate></p>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,528 @@
|
|||
<template>
|
||||
<form @submit.stop.prevent :class="['ui', {loading: isLoadingStep1}, 'form']">
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while publishing</translate></div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div :class="['ui', 'required', {hidden: step > 1}, 'field']">
|
||||
<label for="channel-dropdown">
|
||||
<translate translate-context="*/*/*">Channel</translate>
|
||||
</label>
|
||||
<div id="channel-dropdown" class="ui search normal selection dropdown">
|
||||
<div class="text"></div>
|
||||
<i class="dropdown icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<album-select v-model.number="values.album" :channel="selectedChannel" :class="['ui', {hidden: step > 1}, 'field']"></album-select>
|
||||
<license-select v-model="values.license" :class="['ui', {hidden: step > 1}, 'field']"></license-select>
|
||||
<div :class="['ui', {hidden: step > 1}, 'message']">
|
||||
<div class="content">
|
||||
<p>
|
||||
<i class="copyright icon"></i>
|
||||
<translate translate-context="Content/Channels/Popup.Paragraph">Add a license to your upload to ensure some freedoms to your public.</translate>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="step >= 2 && step < 4">
|
||||
<div class="ui warning message" v-if="remainingSpace === 0">
|
||||
<div class="content">
|
||||
<p>
|
||||
<i class="warning icon"></i>
|
||||
<translate translate-context="Content/Library/Paragraph">You don't have any space left to upload your files. Please contact the moderators.</translate>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="ui visible info message" v-if="step === 2 && draftUploads && draftUploads.length > 0 && includeDraftUploads === null">
|
||||
<p>
|
||||
<i class="redo icon"></i>
|
||||
<translate translate-context="Popup/Channels/Paragraph">You have some draft uploads pending publication.</translate>
|
||||
</p>
|
||||
<button @click.stop.prevent="includeDraftUploads = false" class="ui basic button">
|
||||
<translate translate-context="*/*/*">Ignore</translate>
|
||||
</button>
|
||||
<button @click.stop.prevent="includeDraftUploads = true" class="ui basic button">
|
||||
<translate translate-context="*/*/*">Resume</translate>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="uploadedFiles.length > 0" :class="[{hidden: step === 3}]">
|
||||
<div class="channel-file" v-for="(file, idx) in uploadedFiles">
|
||||
<div class="content">
|
||||
<div role="button"
|
||||
v-if="file.response.uuid"
|
||||
@click.stop.prevent="selectedUploadId = file.response.uuid"
|
||||
class="ui basic icon button"
|
||||
:title="labels.editTitle">
|
||||
<i class="pencil icon"></i>
|
||||
</div>
|
||||
<div
|
||||
v-if="file.error"
|
||||
@click.stop.prevent="selectedUploadId = file.response.uuid"
|
||||
class="ui basic red icon label"
|
||||
:title="file.error">
|
||||
<i class="warning sign icon"></i>
|
||||
</div>
|
||||
<div v-else-if="file.active" class="ui active slow inline loader"></div>
|
||||
</div>
|
||||
<h4 class="ui header">
|
||||
<template v-if="file.metadata.title">
|
||||
{{ file.metadata.title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ file.name }}
|
||||
</template>
|
||||
<div class="sub header">
|
||||
<template v-if="file.response.uuid">
|
||||
{{ file.size | humanSize }}
|
||||
<template v-if="file.response.duration">
|
||||
· <human-duration :duration="file.response.duration"></human-duration>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<translate key="1" v-if="file.active" translate-context="Channels/*/*">Uploading</translate>
|
||||
<translate key="2" v-else-if="file.error" translate-context="Channels/*/*">Errored</translate>
|
||||
<translate key="3" v-else translate-context="Channels/*/*">Pending</translate>
|
||||
· {{ file.size | humanSize }}
|
||||
· {{ parseInt(file.progress) }}%
|
||||
</template>
|
||||
· <a @click.stop.prevent="remove(file)">
|
||||
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
|
||||
</a>
|
||||
<template v-if="file.error"> ·
|
||||
<a @click.stop.prevent="retry(file)">
|
||||
<translate translate-context="*/*/*">Retry</translate>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<upload-metadata-form
|
||||
:key="selectedUploadId"
|
||||
v-if="selectedUpload"
|
||||
:upload="selectedUpload"
|
||||
:values="uploadImportData[selectedUploadId]"
|
||||
@values="setDynamic('uploadImportData', selectedUploadId, $event)"></upload-metadata-form>
|
||||
<div class="ui message" v-if="step === 2">
|
||||
<div class="content">
|
||||
<p>
|
||||
<i class="info icon"></i>
|
||||
<translate translate-context="Content/Library/Paragraph" :translate-params="{extensions: $store.state.ui.supportedExtensions.join(', ')}">Supported extensions: %{ extensions }</translate>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<file-upload-widget
|
||||
:class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]"
|
||||
:post-action="uploadUrl"
|
||||
:multiple="true"
|
||||
:data="baseImportMetadata"
|
||||
:drop="true"
|
||||
:extensions="$store.state.ui.supportedExtensions"
|
||||
:value="files"
|
||||
@input="updateFiles"
|
||||
name="audio_file"
|
||||
:thread="1"
|
||||
@input-file="inputFile"
|
||||
ref="upload">
|
||||
<div>
|
||||
<i class="upload icon"></i>
|
||||
<translate translate-context="Content/Channels/Paragraph">Drag and drop your files here or open the browser to upload your files</translate>
|
||||
</div>
|
||||
<div class="ui very small divider"></div>
|
||||
<div>
|
||||
<translate translate-context="*/*/*">Browse…</translate>
|
||||
</div>
|
||||
</file-upload-widget>
|
||||
<div class="ui hidden divider"></div>
|
||||
</template>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import $ from 'jquery'
|
||||
|
||||
import LicenseSelect from '@/components/channels/LicenseSelect'
|
||||
import AlbumSelect from '@/components/channels/AlbumSelect'
|
||||
import FileUploadWidget from "@/components/library/FileUploadWidget";
|
||||
import UploadMetadataForm from '@/components/channels/UploadMetadataForm'
|
||||
|
||||
function setIfEmpty (obj, k, v) {
|
||||
if (obj[k] != undefined) {
|
||||
return
|
||||
}
|
||||
obj[k] = v
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
channel: {type: Object, default: null, required: false},
|
||||
},
|
||||
components: {
|
||||
AlbumSelect,
|
||||
LicenseSelect,
|
||||
FileUploadWidget,
|
||||
UploadMetadataForm,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
availableChannels: {
|
||||
results: [],
|
||||
count: 0,
|
||||
},
|
||||
audioMetadata: {},
|
||||
uploadData: {},
|
||||
uploadImportData: {},
|
||||
draftUploads: null,
|
||||
files: [],
|
||||
errors: [],
|
||||
removed: [],
|
||||
includeDraftUploads: null,
|
||||
uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
|
||||
quotaStatus: null,
|
||||
isLoadingStep1: true,
|
||||
step: 1,
|
||||
values: {
|
||||
channel: (this.channel || {}).uuid,
|
||||
license: null,
|
||||
album: null,
|
||||
},
|
||||
selectedUploadId: null,
|
||||
}
|
||||
},
|
||||
async created () {
|
||||
this.isLoadingStep1 = true
|
||||
let p1 = this.fetchChannels()
|
||||
await p1
|
||||
this.isLoadingStep1 = false
|
||||
this.fetchQuota()
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return {
|
||||
editTitle: this.$pgettext('Content/*/Button.Label/Verb', 'Edit'),
|
||||
|
||||
}
|
||||
},
|
||||
baseImportMetadata () {
|
||||
return {
|
||||
channel: this.values.channel,
|
||||
import_status: 'draft',
|
||||
import_metadata: {license: this.values.license, album: this.values.album || null}
|
||||
}
|
||||
},
|
||||
remainingSpace () {
|
||||
if (!this.quotaStatus) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000)))
|
||||
},
|
||||
selectedChannel () {
|
||||
let self = this
|
||||
return this.availableChannels.results.filter((c) => {
|
||||
return c.uuid === self.values.channel
|
||||
})[0]
|
||||
},
|
||||
selectedUpload () {
|
||||
let self = this
|
||||
if (!this.selectedUploadId) {
|
||||
return null
|
||||
}
|
||||
let selected = this.uploadedFiles.filter((f) => {
|
||||
return f.response && f.response.uuid == self.selectedUploadId
|
||||
})[0]
|
||||
return {
|
||||
...selected.response,
|
||||
_fileObj: selected._fileObj
|
||||
}
|
||||
},
|
||||
uploadedFilesById () {
|
||||
let data = {}
|
||||
this.uploadedFiles.forEach((u) => {
|
||||
data[u.response.uuid] = u
|
||||
})
|
||||
return data
|
||||
},
|
||||
uploadedFiles () {
|
||||
let self = this
|
||||
self.uploadData
|
||||
self.audioMetadata
|
||||
let files = this.files.map((f) => {
|
||||
let data = {
|
||||
...f,
|
||||
_fileObj: f,
|
||||
metadata: {}
|
||||
}
|
||||
let metadata = {}
|
||||
if (f.response && f.response.uuid) {
|
||||
let uploadImportMetadata = self.uploadImportData[f.response.uuid] || self.uploadData[f.response.uuid].import_metadata
|
||||
data.metadata = {
|
||||
...uploadImportMetadata,
|
||||
}
|
||||
data.removed = self.removed.indexOf(f.response.uuid) >= 0
|
||||
}
|
||||
return data
|
||||
})
|
||||
let final = []
|
||||
if (this.includeDraftUploads) {
|
||||
// we have two different objects: draft uploads (so already uploaded in a previous)
|
||||
// session, and files uploaded in the current session
|
||||
// so we ensure we have a similar structure for both.
|
||||
|
||||
final = [
|
||||
...this.draftUploads.map((u) => {
|
||||
return {
|
||||
response: u,
|
||||
_fileObj: null,
|
||||
size: u.size,
|
||||
progress: 100,
|
||||
name: u.source.replace('upload://', ''),
|
||||
active: false,
|
||||
removed: self.removed.indexOf(u.uuid) >= 0,
|
||||
metadata: self.uploadImportData[u.uuid] || self.audioMetadata[u.uuid] || u.import_metadata
|
||||
}
|
||||
}),
|
||||
...files
|
||||
]
|
||||
} else {
|
||||
final = files
|
||||
}
|
||||
return final.filter((f) => {
|
||||
return !f.removed
|
||||
})
|
||||
},
|
||||
summaryData () {
|
||||
let speed = null
|
||||
let remaining = null
|
||||
if (this.activeFile) {
|
||||
speed = this.activeFile.speed
|
||||
remaining = parseInt(this.totalSize / speed)
|
||||
}
|
||||
return {
|
||||
totalFiles: this.uploadedFiles.length,
|
||||
totalSize: this.totalSize,
|
||||
uploadedSize: this.uploadedSize,
|
||||
progress: parseInt(this.uploadedSize * 100 / this.totalSize),
|
||||
canSubmit: !this.activeFile && this.uploadedFiles.length > 0,
|
||||
speed,
|
||||
remaining,
|
||||
quotaStatus: this.quotaStatus,
|
||||
}
|
||||
},
|
||||
totalSize () {
|
||||
let total = 0
|
||||
this.uploadedFiles.forEach((f) => {
|
||||
if (!f.error) {
|
||||
total += f.size
|
||||
}
|
||||
})
|
||||
return total
|
||||
},
|
||||
uploadedSize () {
|
||||
let uploaded = 0
|
||||
this.uploadedFiles.forEach((f) => {
|
||||
if (f._fileObj && !f.error) {
|
||||
uploaded += f.size * (f.progress / 100)
|
||||
}
|
||||
})
|
||||
return uploaded
|
||||
},
|
||||
activeFile () {
|
||||
return this.files.filter((f) => {
|
||||
return f.active
|
||||
})[0]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchChannels () {
|
||||
let response = await axios.get('channels/', {params: {scope: 'me'}})
|
||||
this.availableChannels = response.data
|
||||
},
|
||||
async patchUpload (id, data) {
|
||||
let response = await axios.patch(`uploads/${id}/`, data)
|
||||
this.uploadData[id] = response.data
|
||||
this.uploadImportData[id] = response.data.import_metadata
|
||||
},
|
||||
fetchQuota () {
|
||||
let self = this
|
||||
axios.get('users/users/me/').then((response) => {
|
||||
self.quotaStatus = response.data.quota_status
|
||||
})
|
||||
},
|
||||
publish () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.errors = []
|
||||
let ids = this.uploadedFiles.map((f) => {
|
||||
return f.response.uuid
|
||||
})
|
||||
let payload = {
|
||||
action: 'publish',
|
||||
objects: ids,
|
||||
}
|
||||
return axios.post('uploads/action/', payload).then(
|
||||
response => {
|
||||
self.isLoading = false
|
||||
self.$emit("published", {
|
||||
uploads: self.uploadedFiles.map((u) => {
|
||||
return {
|
||||
...u.response,
|
||||
import_status: 'pending',
|
||||
}
|
||||
}),
|
||||
channel: self.selectedChannel})
|
||||
},
|
||||
error => {
|
||||
self.errors = error.backendErrors
|
||||
}
|
||||
)
|
||||
},
|
||||
setupChannelsDropdown () {
|
||||
let self = this
|
||||
$(this.$el).find('#channel-dropdown').dropdown({
|
||||
onChange (value, text, $choice) {
|
||||
self.values.channel = value
|
||||
},
|
||||
values: this.availableChannels.results.map((c) => {
|
||||
let d = {
|
||||
name: c.artist.name,
|
||||
value: c.uuid,
|
||||
selected: self.channel && self.channel.uuid === c.uuid,
|
||||
}
|
||||
if (c.artist.cover && c.artist.cover.small_square_crop) {
|
||||
let coverUrl = self.$store.getters['instance/absoluteUrl'](c.artist.cover.small_square_crop)
|
||||
d.image = coverUrl
|
||||
if (c.artist.content_category === 'podcast') {
|
||||
d.imageClass = 'ui image'
|
||||
} else {
|
||||
d.imageClass = "ui avatar image"
|
||||
}
|
||||
} else {
|
||||
d.icon = "user"
|
||||
if (c.artist.content_category === 'podcast') {
|
||||
d.iconClass = "bordered grey icon"
|
||||
} else {
|
||||
d.iconClass = "circular grey icon"
|
||||
|
||||
}
|
||||
}
|
||||
return d
|
||||
})
|
||||
})
|
||||
$(this.$el).find('#channel-dropdown').dropdown('hide')
|
||||
},
|
||||
inputFile(newFile, oldFile) {
|
||||
if (!newFile) {
|
||||
return
|
||||
}
|
||||
if (this.remainingSpace < newFile.size / (1000 * 1000)) {
|
||||
newFile.error = 'denied'
|
||||
} else {
|
||||
this.$refs.upload.active = true;
|
||||
}
|
||||
},
|
||||
fetchAudioMetadata (uuid) {
|
||||
let self = this
|
||||
self.audioMetadata[uuid] = null
|
||||
axios.get(`uploads/${uuid}/audio-file-metadata/`).then((response) => {
|
||||
self.setDynamic('audioMetadata', uuid, response.data)
|
||||
let uploadedFile = self.uploadedFilesById[uuid]
|
||||
if (uploadedFile._fileObj && uploadedFile.response.import_metadata.title === uploadedFile._fileObj.name.replace(/\.[^/.]+$/, "") && response.data.title) {
|
||||
// replace existing title deduced from file by the one in audio file metadat, if any
|
||||
self.uploadImportData[uuid].title = response.data.title
|
||||
} else {
|
||||
setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title)
|
||||
}
|
||||
setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title)
|
||||
setIfEmpty(self.uploadImportData[uuid], 'position', response.data.position)
|
||||
setIfEmpty(self.uploadImportData[uuid], 'tags', response.data.tags)
|
||||
setIfEmpty(self.uploadImportData[uuid], 'description', (response.data.description || {}).text)
|
||||
self.patchUpload(uuid, {import_metadata: self.uploadImportData[uuid]})
|
||||
})
|
||||
},
|
||||
setDynamic (objName, key, data) {
|
||||
// cf https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats
|
||||
let newData = {}
|
||||
newData[key] = data
|
||||
this[objName] = Object.assign({}, this[objName], newData)
|
||||
},
|
||||
updateFiles (value) {
|
||||
let self = this
|
||||
this.files = value
|
||||
this.files.forEach((f) => {
|
||||
if (f.response && f.response.uuid && self.audioMetadata[f.response.uuid] === undefined) {
|
||||
self.uploadData[f.response.uuid] = f.response
|
||||
self.setDynamic('uploadImportData', f.response.uuid, {
|
||||
...f.response.import_metadata
|
||||
})
|
||||
self.fetchAudioMetadata(f.response.uuid)
|
||||
}
|
||||
})
|
||||
},
|
||||
async fetchDraftUploads (channel) {
|
||||
let self = this
|
||||
this.draftUploads = null
|
||||
let response = await axios.get('uploads', {params: {import_status: 'draft', channel: channel}})
|
||||
this.draftUploads = response.data.results
|
||||
this.draftUploads.forEach((u) => {
|
||||
self.uploadImportData[u.uuid] = u.import_metadata
|
||||
})
|
||||
},
|
||||
remove (file) {
|
||||
if (file.response && file.response.uuid) {
|
||||
axios.delete(`uploads/${file.response.uuid}/`)
|
||||
this.removed.push(file.response.uuid)
|
||||
} else {
|
||||
this.$refs.upload.remove(file)
|
||||
}
|
||||
},
|
||||
retry (file) {
|
||||
this.$refs.upload.update(file, {error: '', progress: '0.00'})
|
||||
this.$refs.upload.active = true;
|
||||
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
"availableChannels.results" () {
|
||||
this.setupChannelsDropdown()
|
||||
},
|
||||
"values.channel": {
|
||||
async handler (v) {
|
||||
this.files = []
|
||||
if (v) {
|
||||
await this.fetchDraftUploads(v)
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
step: {
|
||||
handler (value) {
|
||||
this.$emit('step', value)
|
||||
if (value === 2) {
|
||||
this.selectedUploadId = null
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
async selectedUploadId (v, o) {
|
||||
if (v) {
|
||||
this.step = 3
|
||||
} else {
|
||||
this.step = 2
|
||||
}
|
||||
if (o) {
|
||||
await this.patchUpload(o, {import_metadata: this.uploadImportData[o]})
|
||||
}
|
||||
},
|
||||
summaryData: {
|
||||
handler (v) {
|
||||
this.$emit('status', v)
|
||||
},
|
||||
immediate: true,
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -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>
|
|
@ -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>
|
|
@ -31,7 +31,6 @@
|
|||
<dangerous-button
|
||||
v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
|
||||
:confirm-color="currentAction.confirmColor || 'green'"
|
||||
color=""
|
||||
@confirm="launchAction">
|
||||
<translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate>
|
||||
<p slot="modal-header">
|
||||
|
|
|
@ -1,25 +1,33 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui form">
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header"><translate translate-context="Content/*/Error message.Title">Your attachment cannot be saved</translate></div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ui stackable two column grid">
|
||||
<div class="column" v-if="value && value === initialValue">
|
||||
<h3 class="ui header"><translate translate-context="Content/*/Title/Noun">Current file</translate></h3>
|
||||
<img class="ui image" v-if="value" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" />
|
||||
</div>
|
||||
<div class="column" v-else-if="attachment">
|
||||
<h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3>
|
||||
<img class="ui image" v-if="attachment && attachment.square_crop" :src="$store.getters['instance/absoluteUrl'](attachment.medium_square_crop)" />
|
||||
</div>
|
||||
<div class="column" v-if="!attachment">
|
||||
<div class="ui basic segment">
|
||||
<h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3>
|
||||
<p><translate translate-context="Content/*/Paragraph">PNG or JPG. At most 5MB. Will be downscaled to 400x400px.</translate></p>
|
||||
<input class="ui input" ref="attachment" type="file" accept="image/x-png,image/jpeg" @change="submit" />
|
||||
<div class="ui field">
|
||||
<label :for="attachmentId">
|
||||
<slot name="label"></slot>
|
||||
</label>
|
||||
<div class="ui stackable grid row">
|
||||
<div class="three wide column">
|
||||
<img :class="['ui', imageClass, 'image']" v-if="value && value === initialValue" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" />
|
||||
<img :class="['ui', imageClass, 'image']" v-else-if="attachment" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${attachment.uuid}/proxy?next=medium_square_crop`)" />
|
||||
<div :class="['ui', imageClass, 'static', 'large placeholder image']" v-else></div>
|
||||
</div>
|
||||
<div class="eleven wide column">
|
||||
<div class="file-input">
|
||||
<label class="ui basic button" :for="attachmentId">
|
||||
<translate translate-context="*/*/*">Upload New Picture…</translate>
|
||||
</label>
|
||||
<input class="ui hidden input" ref="attachment" type="file" :id="attachmentId" accept="image/x-png,image/jpeg" @change="submit" />
|
||||
</div>
|
||||
<div class="ui very small hidden divider"></div>
|
||||
<p><translate translate-context="Content/*/Paragraph">PNG or JPG. Dimensions should be between 1400x1400px and 3000x3000px. Maximum file size allowed is 5MB.</translate></p>
|
||||
<div class="ui basic tiny button" v-if="value" @click.stop.prevent="remove(value)">
|
||||
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
|
||||
</div>
|
||||
<div v-if="isLoading" class="ui active inverted dimmer">
|
||||
<div class="ui indeterminate text loader">
|
||||
<translate translate-context="Content/*/*/Noun">Uploading file…</translate>
|
||||
|
@ -27,7 +35,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -35,12 +42,17 @@
|
|||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: ['value', 'initialValue'],
|
||||
props: {
|
||||
value: {},
|
||||
imageClass: {default: '', required: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
attachment: null,
|
||||
isLoading: false,
|
||||
errors: [],
|
||||
initialValue: this.value,
|
||||
attachmentId: Math.random().toString(36).substring(7),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -69,11 +81,11 @@ export default {
|
|||
}
|
||||
)
|
||||
},
|
||||
remove() {
|
||||
remove(uuid) {
|
||||
this.isLoading = true
|
||||
this.errors = []
|
||||
let self = this
|
||||
axios.delete(`attachments/${this.attachment.uuid}/`)
|
||||
axios.delete(`attachments/${uuid}/`)
|
||||
.then(
|
||||
response => {
|
||||
this.isLoading = false
|
||||
|
@ -91,7 +103,7 @@ export default {
|
|||
value (v) {
|
||||
if (this.attachment && v === this.initialValue) {
|
||||
// we had a reset to initial value
|
||||
this.remove()
|
||||
this.remove(this.attachment.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<p class="message" v-if="copied">
|
||||
<translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate>
|
||||
</p>
|
||||
<input ref="input" :value="value" type="text">
|
||||
<input ref="input" :value="value" type="text" readonly>
|
||||
<button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']">
|
||||
<i class="copy icon"></i>
|
||||
<translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate>
|
||||
|
@ -43,5 +43,9 @@ export default {
|
|||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3em;
|
||||
padding: 0.3em;
|
||||
box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.3);
|
||||
background-color: white;
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div @click="showModal = true" :class="['ui', color, {disabled: disabled}, 'button']" :disabled="disabled">
|
||||
<div @click="showModal = true" :class="[{disabled: disabled}]" role="button" :disabled="disabled">
|
||||
<slot></slot>
|
||||
|
||||
<modal class="small" :show.sync="showModal">
|
||||
|
@ -14,7 +14,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button">
|
||||
<div class="ui basic cancel button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
<div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm">
|
||||
|
@ -34,8 +34,7 @@ export default {
|
|||
props: {
|
||||
action: {type: Function, required: false},
|
||||
disabled: {type: Boolean, default: false},
|
||||
color: {type: String, default: 'red'},
|
||||
confirmColor: {type: String, default: null, required: false}
|
||||
confirmColor: {type: String, default: "red", required: false}
|
||||
},
|
||||
components: {
|
||||
Modal
|
||||
|
|
|
@ -82,7 +82,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div role="button" class="ui cancel button">
|
||||
<div role="button" class="ui basic cancel button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Close</translate>
|
||||
</div>
|
||||
<div role="button" @click="showModal = false; $emit('refresh')" class="ui confirm green button" v-if="fetch && fetch.status === 'finished'">
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui deny button">
|
||||
<div class="ui basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -73,6 +73,18 @@
|
|||
<i class="edit icon"></i>
|
||||
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
|
||||
</router-link>
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'item']"
|
||||
v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
|
||||
@confirm="remove()">
|
||||
<i class="ui trash icon"></i>
|
||||
<translate translate-context="*/*/*/Verb">Delete…</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p>
|
||||
<div slot="modal-content">
|
||||
<p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
|
||||
</div>
|
||||
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
|
||||
</dangerous-button>
|
||||
<div class="divider"></div>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -143,6 +155,7 @@ export default {
|
|||
return {
|
||||
isLoading: true,
|
||||
object: null,
|
||||
artist: null,
|
||||
discs: [],
|
||||
libraries: [],
|
||||
showEmbedModal: false
|
||||
|
@ -160,8 +173,23 @@ export default {
|
|||
axios.get(url, {params: {refresh: 'true'}}).then(response => {
|
||||
self.object = backend.Album.clean(response.data)
|
||||
self.discs = self.object.tracks.reduce(groupByDisc, [])
|
||||
axios.get(`artists/${response.data.artist.id}/`).then(response => {
|
||||
self.artist = response.data
|
||||
})
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
remove () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
axios.delete(`albums/${this.object.id}`).then((response) => {
|
||||
self.isLoading = false
|
||||
self.$emit('deleted')
|
||||
self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}})
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -160,6 +160,7 @@ export default {
|
|||
ordering: this.getOrderingAsString(),
|
||||
playable: "true",
|
||||
tag: this.tags,
|
||||
include_channels: "true",
|
||||
}
|
||||
logger.default.debug("Fetching artists")
|
||||
axios.get(
|
||||
|
|
|
@ -106,7 +106,7 @@
|
|||
</button>
|
||||
<dangerous-button
|
||||
v-if="canDelete"
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this suggestion?</translate></p>
|
||||
|
|
|
@ -82,14 +82,15 @@
|
|||
<content-form v-model="values[fieldConfig.id].text" :field-id="fieldConfig.id" :rows="3"></content-form>
|
||||
</template>
|
||||
<template v-else-if="fieldConfig.type === 'attachment'">
|
||||
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
|
||||
<attachment-input
|
||||
v-model="values[fieldConfig.id]"
|
||||
:initial-value="initialValues[fieldConfig.id]"
|
||||
:required="fieldConfig.required"
|
||||
:name="fieldConfig.id"
|
||||
:id="fieldConfig.id"
|
||||
@delete="values[fieldConfig.id] = initialValues[fieldConfig.id]"></attachment-input>
|
||||
@delete="values[fieldConfig.id] = initialValues[fieldConfig.id]">
|
||||
<span slot="label">{{ fieldConfig.label }}</span>
|
||||
</attachment-input>
|
||||
|
||||
</template>
|
||||
<template v-else-if="fieldConfig.type === 'tags'">
|
||||
|
|
|
@ -180,7 +180,6 @@ export default {
|
|||
currentTab: "summary",
|
||||
uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
|
||||
importReference,
|
||||
supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
|
||||
isLoadingQuota: false,
|
||||
quotaStatus: null,
|
||||
uploads: {
|
||||
|
@ -283,6 +282,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
supportedExtensions () {
|
||||
return this.$store.state.ui.supportedExtensions
|
||||
},
|
||||
labels() {
|
||||
let denied = this.$pgettext('Content/Library/Help text',
|
||||
"Upload denied, ensure the file is not too big and that you have not reached your quota"
|
||||
|
|
|
@ -6,10 +6,19 @@ export default {
|
|||
methods: {
|
||||
uploadHtml5 (file) {
|
||||
let form = new window.FormData()
|
||||
let filename = file.file.filename || file.name
|
||||
let value
|
||||
for (let key in file.data) {
|
||||
value = file.data[key]
|
||||
if (value && typeof value === 'object' && typeof value.toString !== 'function') {
|
||||
let data = {...file.data}
|
||||
if (data.import_metadata) {
|
||||
data.import_metadata = {...(data.import_metadata || {})}
|
||||
if (data.channel && !data.import_metadata.title) {
|
||||
data.import_metadata.title = filename.replace(/\.[^/.]+$/, "")
|
||||
}
|
||||
data.import_metadata = JSON.stringify(data.import_metadata)
|
||||
}
|
||||
for (let key in data) {
|
||||
value = data[key]
|
||||
if (value && typeof value === 'object' && typeof value.toString !== 'function') {
|
||||
if (value instanceof File) {
|
||||
form.append(key, value, value.name)
|
||||
} else {
|
||||
|
@ -19,7 +28,6 @@ export default {
|
|||
form.append(key, value)
|
||||
}
|
||||
}
|
||||
let filename = file.file.filename || file.name
|
||||
form.append('source', `upload://${filename}`)
|
||||
form.append(this.name, file.file, filename)
|
||||
let xhr = new XMLHttpRequest()
|
||||
|
|
|
@ -29,14 +29,30 @@ export default {
|
|||
return value
|
||||
}
|
||||
let settings = {
|
||||
keys : {
|
||||
delimiter : 32,
|
||||
},
|
||||
forceSelection: false,
|
||||
saveRemoteData: false,
|
||||
filterRemoteData: true,
|
||||
preserveHTML : false,
|
||||
apiSettings: {
|
||||
url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'),
|
||||
beforeXHR: function (xhrObject) {
|
||||
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
|
||||
return xhrObject
|
||||
},
|
||||
onResponse(response) {
|
||||
let currentSearch = $(self.$refs.dropdown).dropdown('get query')
|
||||
response = {
|
||||
results: [],
|
||||
...response,
|
||||
}
|
||||
if (currentSearch) {
|
||||
response.results = [{name: currentSearch}, ...response.results]
|
||||
}
|
||||
return response
|
||||
}
|
||||
},
|
||||
fields: {
|
||||
remoteValues: 'results',
|
||||
|
@ -74,4 +90,3 @@ export default {
|
|||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<main>
|
||||
<div v-if="isLoadingTrack" class="ui vertical segment" v-title="labels.title">
|
||||
<div v-if="isLoading" class="ui vertical segment" v-title="labels.title">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="track">
|
||||
|
@ -50,7 +50,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui deny button">
|
||||
<div class="ui basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -89,6 +89,18 @@
|
|||
<i class="edit icon"></i>
|
||||
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
|
||||
</router-link>
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'item']"
|
||||
v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
|
||||
@confirm="remove()">
|
||||
<i class="ui trash icon"></i>
|
||||
<translate translate-context="*/*/*/Verb">Delete…</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this track?</translate></p>
|
||||
<div slot="modal-content">
|
||||
<p><translate translate-context="Content/Moderation/Paragraph">The track will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
|
||||
</div>
|
||||
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
|
||||
</dangerous-button>
|
||||
<div class="divider"></div>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -151,8 +163,9 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
time,
|
||||
isLoadingTrack: true,
|
||||
isLoading: true,
|
||||
track: null,
|
||||
artist: null,
|
||||
showEmbedModal: false,
|
||||
libraries: []
|
||||
}
|
||||
|
@ -163,14 +176,29 @@ export default {
|
|||
methods: {
|
||||
fetchData() {
|
||||
var self = this
|
||||
this.isLoadingTrack = true
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL + this.id + "/"
|
||||
logger.default.debug('Fetching track "' + this.id + '"')
|
||||
axios.get(url, {params: {refresh: 'true'}}).then(response => {
|
||||
self.track = response.data
|
||||
self.isLoadingTrack = false
|
||||
axios.get(`artists/${response.data.artist.id}/`).then(response => {
|
||||
self.artist = response.data
|
||||
})
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
remove () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
axios.delete(`tracks/${this.track.id}`).then((response) => {
|
||||
self.isLoading = false
|
||||
self.$emit('deleted')
|
||||
self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}})
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
publicLibraries () {
|
||||
|
@ -224,7 +252,9 @@ export default {
|
|||
return u
|
||||
},
|
||||
cover() {
|
||||
return null
|
||||
if (this.track.cover) {
|
||||
return this.track.cover
|
||||
}
|
||||
},
|
||||
albumUrl () {
|
||||
let route = this.$router.resolve({name: 'library.albums.detail', params: {id: this.track.album.id }})
|
||||
|
@ -235,12 +265,12 @@ export default {
|
|||
return route.href
|
||||
},
|
||||
headerStyle() {
|
||||
if (!this.cover) {
|
||||
if (!this.cover || !this.cover.original) {
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
"background-image: url(" +
|
||||
this.$store.getters["instance/absoluteUrl"](this.cover) +
|
||||
this.$store.getters["instance/absoluteUrl"](this.cover.original) +
|
||||
")"
|
||||
)
|
||||
},
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui black deny button">
|
||||
<div class="ui basic black deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -58,7 +58,7 @@
|
|||
<translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-if="object" key="1">Update</translate>
|
||||
<translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-else key="2">Create</translate>
|
||||
</button>
|
||||
<dangerous-button v-if="object" class="right floated basic button" color='red' @confirm="remove">
|
||||
<dangerous-button v-if="object" class="ui right floated basic red button" @confirm="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header">
|
||||
<translate translate-context="Popup/Moderation/Title">Delete this moderation rule?</translate>
|
||||
|
|
|
@ -18,8 +18,7 @@
|
|||
</div>
|
||||
<div class="meta">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic borderless mini button']"
|
||||
color="grey"
|
||||
:class="['ui', {loading: isLoading}, 'basic borderless mini grey button']"
|
||||
@confirm="remove(note)">
|
||||
<i class="trash icon"></i>
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
|
|
|
@ -229,7 +229,6 @@
|
|||
<dangerous-button
|
||||
v-if="action.dangerous && action.show(obj)"
|
||||
:class="['ui', {loading: isLoading}, 'button']"
|
||||
color=""
|
||||
:action="action.handler">
|
||||
<i :class="[action.iconColor, action.icon, 'icon']"></i>
|
||||
{{ action.label }}
|
||||
|
|
|
@ -25,6 +25,10 @@ export default {
|
|||
label: this.$pgettext('Content/Library/*', 'Skipped'),
|
||||
help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries'),
|
||||
},
|
||||
draft: {
|
||||
label: this.$pgettext('Content/Library/*/Short', 'Draft'),
|
||||
help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been scheduled for processing yet'),
|
||||
},
|
||||
pending: {
|
||||
label: this.$pgettext('Content/Library/*/Short', 'Pending'),
|
||||
help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet'),
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
<div :class="['ui', 'green', {loading: isLoading}, 'button']" @click="hide"><translate translate-context="Popup/*/Button.Label">Hide content</translate></div>
|
||||
</div>
|
||||
</modal>
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
<button
|
||||
v-if="canSubmit"
|
||||
:class="['ui', 'green', {loading: isLoading}, 'button']"
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
</translate>
|
||||
</div>
|
||||
|
||||
<dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
|
||||
<dangerous-button :disabled="plts.length === 0" class="ui labeled right floated yellow icon button" :action="clearPlaylist">
|
||||
<i class="eraser icon"></i> <translate translate-context="*/Playlist/Button.Label/Verb">Clear playlist</translate>
|
||||
<p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title" :translate-params="{playlist: playlist.name}">
|
||||
Do you want to clear the playlist "%{ playlist }"?
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
:translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"><strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>.</p>
|
||||
<button
|
||||
@click="duplicateTrackAddConfirm(false)"
|
||||
class="ui small cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
class="ui small basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</button>
|
||||
<button
|
||||
class="ui small green button"
|
||||
|
@ -101,7 +101,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div :class="['ui', {'active': show}, 'modal']">
|
||||
<i class="close icon"></i>
|
||||
<div :class="['ui', {'active': show}, {'overlay fullscreen': ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']">
|
||||
<i class="close inside icon"></i>
|
||||
<slot v-if="show">
|
||||
|
||||
</slot>
|
||||
|
|
|
@ -15,4 +15,5 @@ export default {
|
|||
reverse: require('lodash/reverse'),
|
||||
isEqual: require('lodash/isEqual'),
|
||||
sum: require('lodash/sum'),
|
||||
startCase: require('lodash/startCase'),
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import { sync } from 'vuex-router-sync'
|
|||
import locales from '@/locales'
|
||||
|
||||
import filters from '@/filters' // eslint-disable-line
|
||||
import {parseAPIErrors} from '@/utils'
|
||||
import globals from '@/components/globals' // eslint-disable-line
|
||||
import './registerServiceWorker'
|
||||
|
||||
|
@ -67,6 +68,7 @@ Vue.directive('title', function (el, binding) {
|
|||
Vue.directive('dropdown', function (el, binding) {
|
||||
jQuery(el).dropdown({
|
||||
selectOnKeydown: false,
|
||||
...(binding.value || {})
|
||||
})
|
||||
})
|
||||
axios.interceptors.request.use(function (config) {
|
||||
|
@ -127,15 +129,8 @@ axios.interceptors.response.use(function (response) {
|
|||
error.backendErrors.push(error.response.data.detail)
|
||||
} else {
|
||||
error.rawPayload = error.response.data
|
||||
for (var field in error.response.data) {
|
||||
// some views (e.g. v1/playlists/{id}/add) have deeper nested data (e.g. data[field]
|
||||
// is another object), so don't try to unpack non-array fields
|
||||
if (error.response.data.hasOwnProperty(field) && error.response.data[field].forEach) {
|
||||
error.response.data[field].forEach(e => {
|
||||
error.backendErrors.push(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
let parsedErrors = parseAPIErrors(error.response.data)
|
||||
error.backendErrors = [...error.backendErrors, ...parsedErrors]
|
||||
}
|
||||
}
|
||||
if (error.backendErrors.length === 0) {
|
||||
|
@ -157,6 +152,19 @@ store.dispatch('instance/fetchFrontSettings').finally(() => {
|
|||
components: { App },
|
||||
created () {
|
||||
APP = this
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
this.handleResize();
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
},
|
||||
methods: {
|
||||
handleResize() {
|
||||
this.$store.commit('ui/window', {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -9,6 +9,9 @@ export default new Router({
|
|||
linkActiveClass: "active",
|
||||
base: process.env.VUE_APP_ROUTER_BASE_URL || "/",
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (to.meta.preserveScrollPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
if (to.hash) {
|
||||
|
|
|
@ -5,7 +5,12 @@ export default {
|
|||
namespaced: true,
|
||||
state: {
|
||||
subscriptions: [],
|
||||
count: 0
|
||||
count: 0,
|
||||
showUploadModal: false,
|
||||
latestPublication: null,
|
||||
uploadModalConfig: {
|
||||
channel: null,
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
subscriptions: (state, {uuid, value}) => {
|
||||
|
@ -24,6 +29,22 @@ export default {
|
|||
reset (state) {
|
||||
state.subscriptions = []
|
||||
state.count = 0
|
||||
},
|
||||
showUploadModal (state, value) {
|
||||
state.showUploadModal = value.show
|
||||
if (value.config) {
|
||||
state.uploadModalConfig = {
|
||||
...value.config
|
||||
}
|
||||
}
|
||||
},
|
||||
publish (state, {uploads, channel}) {
|
||||
state.latestPublication = {
|
||||
date: new Date(),
|
||||
uploads,
|
||||
channel,
|
||||
}
|
||||
state.showUploadModal = false
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
|
|
|
@ -11,8 +11,13 @@ export default {
|
|||
lastDate: new Date(),
|
||||
maxMessages: 100,
|
||||
messageDisplayDuration: 10000,
|
||||
supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
|
||||
messages: [],
|
||||
theme: 'light',
|
||||
window: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
notifications: {
|
||||
inbox: 0,
|
||||
pendingReviewEdits: 0,
|
||||
|
@ -125,6 +130,33 @@ export default {
|
|||
count += 1
|
||||
}
|
||||
return count
|
||||
},
|
||||
|
||||
windowSize: (state, getters) => {
|
||||
// IMPORTANT: if you modify these breakpoints, also modify the values in
|
||||
// style/vendor/_media.scss
|
||||
let width = state.window.width
|
||||
let breakpoints = [
|
||||
{name: 'widedesktop', width: 1200},
|
||||
{name: 'desktop', width: 1024},
|
||||
{name: 'tablet', width: 768},
|
||||
{name: 'phone', width: 320},
|
||||
]
|
||||
for (let index = 0; index < breakpoints.length; index++) {
|
||||
const element = breakpoints[index];
|
||||
if (width >= element.width) {
|
||||
return element.name
|
||||
}
|
||||
}
|
||||
return 'phone'
|
||||
|
||||
},
|
||||
layoutVersion: (state, getters) => {
|
||||
if (['tablet', 'phone'].indexOf(getters.windowSize) > -1) {
|
||||
return 'small'
|
||||
} else {
|
||||
return 'large'
|
||||
}
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
|
@ -193,6 +225,9 @@ export default {
|
|||
|
||||
serviceWorker: (state, value) => {
|
||||
state.serviceWorker = {...state.serviceWorker, ...value}
|
||||
},
|
||||
window: (state, value) => {
|
||||
state.window = value
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
|
|
@ -112,7 +112,6 @@ html {
|
|||
.toast-container {
|
||||
bottom: $bottom-player-height + 1rem;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -141,6 +140,7 @@ html {
|
|||
#app {
|
||||
> .main.pusher,
|
||||
> .footer {
|
||||
position: relative;
|
||||
@include media(">desktop") {
|
||||
margin-left: $desktop-sidebar-width !important;
|
||||
}
|
||||
|
@ -263,6 +263,9 @@ a {
|
|||
.segment.hidden {
|
||||
display: none;
|
||||
}
|
||||
.hidden:not(.divider) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nomargin {
|
||||
margin: 0 !important;
|
||||
|
@ -291,6 +294,9 @@ button.reset {
|
|||
text-align: inherit;
|
||||
}
|
||||
|
||||
.text.align.left {
|
||||
text-align: left;
|
||||
}
|
||||
.ui.table > caption {
|
||||
font-weight: bold;
|
||||
padding: 0.5em;
|
||||
|
@ -422,6 +428,11 @@ input + .help {
|
|||
display: none !important;
|
||||
}
|
||||
}
|
||||
.mobile-only {
|
||||
@include media(">tablet") {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
:not(.menu) > {
|
||||
a, .link {
|
||||
&:not(.button):not(.list) {
|
||||
|
@ -433,19 +444,36 @@ input + .help {
|
|||
}
|
||||
.ui.cards.app-cards {
|
||||
$card-width: 14em;
|
||||
$card-hight: 22em;
|
||||
$card-height: 22em;
|
||||
$small-card-width: 11em;
|
||||
$small-card-height: 19em;
|
||||
.app-card {
|
||||
display: flex;
|
||||
width: $card-width;
|
||||
height: $card-hight;
|
||||
width: $small-card-width;
|
||||
height: $small-card-height;
|
||||
font-size: 95%;
|
||||
@include media(">tablet") {
|
||||
font-size: 100%;
|
||||
width: $card-width;
|
||||
height: $card-height;
|
||||
}
|
||||
.content:not(.extra) {
|
||||
padding: 0.5em 1em 0;
|
||||
padding: 0.25em 0.5em 0;
|
||||
@include media(">tablet") {
|
||||
padding: 0.5em 1em 0;
|
||||
}
|
||||
}
|
||||
.content.extra {
|
||||
padding: 0.5em 1em;
|
||||
padding: 0.25em 0.5em;
|
||||
@include media(">tablet") {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
}
|
||||
.head-image {
|
||||
height: $card-width;
|
||||
height: $small-card-width;
|
||||
@include media(">tablet") {
|
||||
height: $card-width;
|
||||
}
|
||||
background-size: cover !important;
|
||||
background-position: center !important;
|
||||
display: flex !important;
|
||||
|
@ -457,9 +485,14 @@ input + .help {
|
|||
&.circular {
|
||||
overflow: visible;
|
||||
border-radius: 50% !important;
|
||||
height: $card-width - 1em;
|
||||
width: $card-width - 1em;
|
||||
margin: 0.5em;
|
||||
width: $small-card-width - 0.5em;
|
||||
height: $small-card-width - 0.5em;
|
||||
margin: 0.25em;
|
||||
@include media(">tablet") {
|
||||
width: $card-width - 1em;
|
||||
height: $card-width - 1em;
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
}
|
||||
&.padded {
|
||||
|
@ -543,7 +576,8 @@ input + .help {
|
|||
}
|
||||
}
|
||||
.channel-image {
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
background-color: white;
|
||||
border-radius: 0.3em;
|
||||
&.large {
|
||||
width: 8em !important;
|
||||
|
@ -560,5 +594,70 @@ input + .help {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
.placeholder.image {
|
||||
background-color: rgba(0,0,0,.08);
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
&.large {
|
||||
width: 8em;
|
||||
height: 8em;
|
||||
}
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
&.circular {
|
||||
border-radius: 50%;
|
||||
}
|
||||
&.static {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.channel-type.field .radio {
|
||||
display: block;
|
||||
padding: 1.5em;
|
||||
&.selected {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
.header.with-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.actions {
|
||||
font-weight: normal;
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
}
|
||||
.file-uploads.channels.ui.button {
|
||||
display: block;
|
||||
padding: 2em 1em;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
border-style: dashed !important;
|
||||
border: 2px solid rgba(50, 50, 50, 0.5);
|
||||
font-size: 1.2em;
|
||||
padding: 0;
|
||||
> div:not(.divider) {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-file {
|
||||
display: flex;
|
||||
align-items: top;
|
||||
margin-bottom: 1em;
|
||||
> :first-child {
|
||||
width: 3em;
|
||||
}
|
||||
.header {
|
||||
margin: 0 1em;
|
||||
.sub.header {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal > .header {
|
||||
text-align: center;
|
||||
}
|
||||
@import "./themes/_light.scss";
|
||||
@import "./themes/_dark.scss";
|
||||
|
|
|
@ -31,6 +31,9 @@
|
|||
/// @example scss - Creates a single breakpoint with the label `phone`
|
||||
/// $breakpoints: ('phone': 320px);
|
||||
///
|
||||
|
||||
// IMPORTANT: if you modify these breakpoints, also modify the values in
|
||||
// store/ui.js#windowSize
|
||||
$breakpoints: (
|
||||
'phone': 320px,
|
||||
'tablet': 768px,
|
||||
|
|
|
@ -6,3 +6,30 @@ export function setUpdate(obj, statuses, value) {
|
|||
statuses[k] = value
|
||||
})
|
||||
}
|
||||
|
||||
export function parseAPIErrors(responseData, parentField) {
|
||||
let errors = []
|
||||
for (var field in responseData) {
|
||||
if (responseData.hasOwnProperty(field)) {
|
||||
let value = responseData[field]
|
||||
let fieldName = lodash.startCase(field.replace('_', ' '))
|
||||
if (parentField) {
|
||||
fieldName = `${parentField} - ${fieldName}`
|
||||
}
|
||||
if (value.forEach) {
|
||||
value.forEach(e => {
|
||||
if (e.toLocaleLowerCase().includes('this field ')) {
|
||||
errors.push(`${fieldName}: ${e}`)
|
||||
} else {
|
||||
errors.push(e)
|
||||
}
|
||||
})
|
||||
} else if (typeof value === 'object') {
|
||||
// nested errors
|
||||
let nestedErrors = parseAPIErrors(value, fieldName)
|
||||
errors = [...errors, ...nestedErrors]
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
|
|
@ -74,7 +74,7 @@
|
|||
</div>
|
||||
<div class="ui buttons">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p>
|
||||
|
|
|
@ -73,7 +73,7 @@
|
|||
</div>
|
||||
<div class="ui buttons">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this artist?</translate></p>
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
</div>
|
||||
<div class="ui buttons">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this library?</translate></p>
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
<div class="ui buttons">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this tag?</translate></p>
|
||||
|
|
|
@ -74,7 +74,7 @@
|
|||
</div>
|
||||
<div class="ui buttons">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p>
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
</div>
|
||||
<div class="ui buttons">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this upload?</translate></p>
|
||||
|
|
|
@ -1,19 +1,21 @@
|
|||
<template>
|
||||
<section class="ui stackable three column grid">
|
||||
<div class="column">
|
||||
<section>
|
||||
<div>
|
||||
<h2 class="ui header">
|
||||
<translate translate-context="Content/Home/Title">Recently listened</translate>
|
||||
</h2>
|
||||
<track-widget :url="'history/listenings/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}">
|
||||
</track-widget>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ui hidden divider"></div>
|
||||
<div>
|
||||
<h2 class="ui header">
|
||||
<translate translate-context="Content/Home/Title">Recently favorited</translate>
|
||||
</h2>
|
||||
<track-widget :url="'favorites/tracks/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"></track-widget>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="ui hidden divider"></div>
|
||||
<div>
|
||||
<h2 class="ui header">
|
||||
<translate translate-context="*/*/*">Playlists</translate>
|
||||
</h2>
|
||||
|
|
|
@ -3,60 +3,77 @@
|
|||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div class="ui centered active inline loader"></div>
|
||||
</div>
|
||||
<template v-if="object">
|
||||
<div class="ui dropdown icon small basic right floated button" ref="dropdown" v-dropdown style="right: 1em; top: 1em; z-index: 5">
|
||||
<i class="ellipsis vertical icon"></i>
|
||||
<div class="menu">
|
||||
<div
|
||||
role="button"
|
||||
class="basic item"
|
||||
v-for="obj in getReportableObjs({account: object})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
<div class="ui head vertical stripe segment container">
|
||||
<div class="ui stackable grid" v-if="object">
|
||||
<div class="ui five wide column">
|
||||
<div class="ui pointing dropdown icon small basic right floated button" ref="dropdown" v-dropdown="{direction: 'downward'}" style="position: absolute; right: 1em; top: 1em; z-index: 5">
|
||||
<i class="ellipsis vertical icon"></i>
|
||||
<div class="menu">
|
||||
<div
|
||||
role="button"
|
||||
class="basic item"
|
||||
v-for="obj in getReportableObjs({account: object})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
|
||||
<i class="wrench icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui head vertical stripe segment">
|
||||
<h1 class="ui center aligned icon header">
|
||||
<i v-if="!object.icon" class="circular inverted user green icon"></i>
|
||||
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
|
||||
<div class="ellispsis content">
|
||||
<div class="ui very small hidden divider"></div>
|
||||
<span :title="displayName">{{ displayName }}</span>
|
||||
<div class="ui very small hidden divider"></div>
|
||||
<span class="ui grey tiny text" :title="object.full_username">{{ object.full_username }}</span>
|
||||
</div>
|
||||
<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 class="divider"></div>
|
||||
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
|
||||
<i class="wrench icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
</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 class="ui hidden divider"></div>
|
||||
<keep-alive>
|
||||
<router-view @updated="fetch" :object="object"></router-view>
|
||||
</keep-alive>
|
||||
<h1 class="ui center aligned icon header">
|
||||
<i v-if="!object.icon" class="circular inverted user green icon"></i>
|
||||
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
|
||||
<div class="ellispsis content">
|
||||
<div class="ui very small hidden divider"></div>
|
||||
<span :title="displayName">{{ displayName }}</span>
|
||||
<div class="ui very small hidden divider"></div>
|
||||
<div class="sub header ellipsis" :title="object.full_username">
|
||||
{{ object.full_username }}
|
||||
</div>
|
||||
</div>
|
||||
<template 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>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
@ -81,6 +98,10 @@ export default {
|
|||
created() {
|
||||
this.fetch()
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
to.meta.preserveScrollPosition = true
|
||||
next()
|
||||
},
|
||||
methods: {
|
||||
fetch () {
|
||||
let self = this
|
||||
|
|
|
@ -1,17 +1,23 @@
|
|||
<template>
|
||||
<section class="ui stackable grid">
|
||||
<div class="six wide column">
|
||||
<section>
|
||||
<div v-if="$store.getters['ui/layoutVersion'] === 'small'">
|
||||
<rendered-description
|
||||
@updated="$emit('updated', $event)"
|
||||
:content="object.summary"
|
||||
:field-name="'summary'"
|
||||
:update-url="`users/users/${$store.state.auth.username}/`"
|
||||
:can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
|
||||
|
||||
<div class="ui hidden divider"></div>
|
||||
</div>
|
||||
<div class="ten wide column">
|
||||
<h2 class="ui header">
|
||||
<div>
|
||||
<h2 class="ui with-actions header">
|
||||
<translate translate-context="*/*/*">Channels</translate>
|
||||
<div class="actions" v-if="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername">
|
||||
<a @click.stop.prevent="showCreateModal = true">
|
||||
<i class="plus icon"></i>
|
||||
<translate translate-context="Content/Profile/Button">Add new</translate>
|
||||
</a>
|
||||
</div>
|
||||
</h2>
|
||||
<channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
|
||||
<h2 class="ui header">
|
||||
|
@ -21,15 +27,61 @@
|
|||
<translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>
|
||||
</library-widget>
|
||||
</div>
|
||||
|
||||
<modal :show.sync="showCreateModal">
|
||||
<div class="header">
|
||||
<translate v-if="step === 1" key="1" translate-context="Content/Channel/*/Verb">Create channel</translate>
|
||||
<translate v-else-if="category === 'podcast'" key="2" translate-context="Content/Channel/*">Podcast channel</translate>
|
||||
<translate v-else key="3" translate-context="Content/Channel/*">Artist channel</translate>
|
||||
</div>
|
||||
<div class="scrolling content" ref="modalContent">
|
||||
<channel-form
|
||||
ref="createForm"
|
||||
:object="null"
|
||||
:step="step"
|
||||
@loading="isLoading = $event"
|
||||
@submittable="submittable = $event"
|
||||
@category="category = $event"
|
||||
@errored="$refs.modalContent.scrollTop = 0"
|
||||
@created="$router.push({name: 'channels.detail', params: {id: $event.actor.preferred_username}})"></channel-form>
|
||||
<div class="ui hidden divider"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div v-if="step === 1" class="ui basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
<button v-if="step > 1" class="ui basic button" @click.stop.prevent="step -= 1">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Previous step</translate>
|
||||
</button>
|
||||
<button v-if="step === 1" class="ui primary button" @click.stop.prevent="step += 1">
|
||||
<translate translate-context="*/*/Button.Label">Next step</translate>
|
||||
</button>
|
||||
<button v-if="step === 2" :class="['ui', 'primary button', {loading: isLoading}]" type="submit" @click.prevent.stop="$refs.createForm.submit" :disabled="!submittable && !isLoading">
|
||||
<translate translate-context="*/Channels/Button.Label">Create channel</translate>
|
||||
</button>
|
||||
</div>
|
||||
</modal>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||
import ChannelsWidget from "@/components/audio/ChannelsWidget"
|
||||
import ChannelForm from "@/components/audio/ChannelForm"
|
||||
|
||||
export default {
|
||||
props: ['object'],
|
||||
components: {ChannelsWidget, LibraryWidget},
|
||||
components: {ChannelsWidget, LibraryWidget, ChannelForm, Modal},
|
||||
data () {
|
||||
return {
|
||||
showCreateModal: false,
|
||||
isLoading: false,
|
||||
submittable: false,
|
||||
step: 1,
|
||||
category: 'podcast',
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
</div>
|
||||
<template v-if="object && !isLoading">
|
||||
<section class="ui head vertical stripe segment container" v-title="object.artist.name">
|
||||
<div class="ui stackable two column grid">
|
||||
<div class="column">
|
||||
<div class="ui stackable grid">
|
||||
<div class="seven wide column">
|
||||
<div class="ui two column grid">
|
||||
<div class="column">
|
||||
<img class="huge channel-image" v-if="object.artist.cover" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.medium_square_crop)">
|
||||
|
@ -28,10 +28,48 @@
|
|||
</template>
|
||||
</template>
|
||||
<div class="ui hidden small divider"></div>
|
||||
<a :href="rssUrl" target="_blank" class="ui icon small basic button">
|
||||
<a @click.stop.prevent="showSubscribeModal = true" class="ui icon small basic button">
|
||||
<i class="feed icon"></i>
|
||||
</a>
|
||||
<div class="ui dropdown icon small basic button" ref="dropdown" v-dropdown>
|
||||
<modal class="tiny" :show.sync="showSubscribeModal">
|
||||
<div class="header">
|
||||
<translate translate-context="Popup/Channel/Title/Verb">Subscribe to this channel</translate>
|
||||
</div>
|
||||
<div class="scrollable content">
|
||||
<div class="description">
|
||||
|
||||
<template v-if="$store.state.auth.authenticated">
|
||||
<h3>
|
||||
<i class="user icon"></i>
|
||||
<translate translate-context="Content/Channels/Header">Subscribe on Funkwhale</translate>
|
||||
</h3>
|
||||
<subscribe-button @subscribed="object.subscriptions_count += 1" @unsubscribed="object.subscriptions_count -= 1" :channel="object"></subscribe-button>
|
||||
</template>
|
||||
<template v-if="object.rss_url">
|
||||
<h3>
|
||||
<i class="feed icon"></i>
|
||||
<translate translate-context="Content/Channels/Header">Subscribe via RSS</translate>
|
||||
</h3>
|
||||
<p><translate translate-context="Content/Channels/Label">Copy-paste the following URL in your favorite podcasting app:</translate></p>
|
||||
<copy-input :value="object.rss_url" />
|
||||
</template>
|
||||
<template v-if="object.actor">
|
||||
<h3>
|
||||
<i class="bell icon"></i>
|
||||
<translate translate-context="Content/Channels/Header">Subscribe on the Fediverse</translate>
|
||||
</h3>
|
||||
<p><translate translate-context="Content/Channels/Label">If you're using Mastodon or other fediverse applications, you can subscribe to this account:</translate></p>
|
||||
<copy-input :value="`@${object.actor.full_username}`" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
<div class="ui right floated pointing dropdown icon small basic button" ref="dropdown" v-dropdown="{direction: 'downward'}">
|
||||
<i class="ellipsis vertical icon"></i>
|
||||
<div class="menu">
|
||||
<div
|
||||
|
@ -52,27 +90,55 @@
|
|||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
|
||||
<i class="wrench icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||
</router-link>
|
||||
<template v-if="isOwner">
|
||||
<div class="divider"></div>
|
||||
<div
|
||||
class="item"
|
||||
role="button"
|
||||
@click.stop="showEditModal = true">
|
||||
<translate translate-context="*/*/*/Verb">Edit…</translate>
|
||||
</div>
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'item']"
|
||||
v-if="object"
|
||||
@confirm="remove()">
|
||||
<translate translate-context="*/*/*/Verb">Delete…</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this Channel?</translate></p>
|
||||
<div slot="modal-content">
|
||||
<p><translate translate-context="Content/Moderation/Paragraph">The channel will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
|
||||
</div>
|
||||
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
|
||||
</dangerous-button>
|
||||
</template>
|
||||
<template v-if="$store.state.auth.availablePermissions['library']" >
|
||||
<div class="divider"></div>
|
||||
<router-link class="basic item" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
|
||||
<i class="wrench icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="ui header">
|
||||
<div class="left aligned content ellipsis">
|
||||
<div class="left aligned" :title="object.artist.name">
|
||||
{{ object.artist.name }}
|
||||
<div class="ui hidden very small divider"></div>
|
||||
<div class="sub header">
|
||||
<div class="sub header ellipsis" :title="object.actor.full_username">
|
||||
{{ object.actor.full_username }}
|
||||
</div>
|
||||
</div>
|
||||
</h1>
|
||||
<div class="header-buttons">
|
||||
<div class="ui buttons" v-if="isOwner">
|
||||
<button class="ui basic labeled icon button" @click.prevent.stop="$store.commit('channels/showUploadModal', {show: true, config: {channel: object}})">
|
||||
<i class="upload icon"></i>
|
||||
<translate translate-context="Content/Channels/Button.Label/Verb">Upload</translate>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ui buttons">
|
||||
<play-button :is-playable="isPlayable" class="orange" :channel="object">
|
||||
<play-button :is-playable="isPlayable" class="orange" :artist="object.artist">
|
||||
<translate translate-context="Content/Channels/Button.Label/Verb">Play</translate>
|
||||
</play-button>
|
||||
</div>
|
||||
|
@ -90,21 +156,45 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui deny button">
|
||||
<div class="ui basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
<modal :show.sync="showEditModal" v-if="isOwner">
|
||||
<div class="header">
|
||||
<translate v-if="object.artist.content_category === 'podcast'" key="1" translate-context="Content/Channel/*">Podcast channel</translate>
|
||||
<translate v-else key="2" translate-context="Content/Channel/*">Artist channel</translate>
|
||||
|
||||
</div>
|
||||
<div class="scrolling content">
|
||||
<channel-form
|
||||
ref="editForm"
|
||||
:object="object"
|
||||
@loading="edit.isLoading = $event"
|
||||
@submittable="edit.submittable = $event"
|
||||
@updated="fetchData"></channel-form>
|
||||
<div class="ui hidden divider"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui left floated basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
<button @click.stop="$refs.editForm.submit" :class="['ui', 'primary', 'confirm', {loading: edit.isLoading}, 'button']" :disabled="!edit.submittable">
|
||||
<translate translate-context="*/Channels/Button.Label">Update channel</translate>
|
||||
</button>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="$store.getters['ui/layoutVersion'] === 'large'">
|
||||
<rendered-description
|
||||
@updated="object = $event"
|
||||
:content="object.artist.description"
|
||||
:update-url="`channels/${object.uuid}/`"
|
||||
:can-update="$store.state.auth.authenticated && object.attributed_to.full_username === $store.state.auth.fullUsername"></rendered-description>
|
||||
:can-update="false"></rendered-description>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="nine wide column">
|
||||
<div class="ui secondary pointing center aligned menu">
|
||||
<router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}">
|
||||
<translate translate-context="Content/Channels/Link">Overview</translate>
|
||||
|
@ -114,9 +204,7 @@
|
|||
</router-link>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<keep-alive>
|
||||
<router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event" ></router-view>
|
||||
</keep-alive>
|
||||
<router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event"></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -135,6 +223,7 @@ import TagsList from "@/components/tags/List"
|
|||
import ReportMixin from '@/components/mixins/Report'
|
||||
|
||||
import SubscribeButton from '@/components/channels/SubscribeButton'
|
||||
import ChannelForm from "@/components/audio/ChannelForm"
|
||||
|
||||
export default {
|
||||
mixins: [ReportMixin],
|
||||
|
@ -146,7 +235,8 @@ export default {
|
|||
TagsList,
|
||||
ChannelEntries,
|
||||
ChannelSeries,
|
||||
SubscribeButton
|
||||
SubscribeButton,
|
||||
ChannelForm,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -155,28 +245,54 @@ export default {
|
|||
totalTracks: 0,
|
||||
latestTracks: null,
|
||||
showEmbedModal: false,
|
||||
showEditModal: false,
|
||||
showSubscribeModal: false,
|
||||
edit: {
|
||||
submittable: false,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
to.meta.preserveScrollPosition = true
|
||||
next()
|
||||
},
|
||||
async created() {
|
||||
await this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
var self = this
|
||||
this.showEditModal = false
|
||||
this.edit.isLoading = false
|
||||
this.isLoading = true
|
||||
let channelPromise = axios.get(`channels/${this.id}`).then(response => {
|
||||
self.object = response.data
|
||||
})
|
||||
let tracksPromise = axios.get("tracks", {params: {channel: this.id, page_size: 1, playable: true, include_channels: true}}).then(response => {
|
||||
self.totalTracks = response.data.count
|
||||
let tracksPromise = axios.get("tracks", {params: {channel: response.data.uuid, page_size: 1, playable: true, include_channels: true}}).then(response => {
|
||||
self.totalTracks = response.data.count
|
||||
self.isLoading = false
|
||||
})
|
||||
})
|
||||
await channelPromise
|
||||
await tracksPromise
|
||||
self.isLoading = false
|
||||
},
|
||||
remove () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
axios.delete(`channels/${this.object.uuid}`).then((response) => {
|
||||
self.isLoading = false
|
||||
self.$emit('deleted')
|
||||
self.$router.push({name: 'profile.overview', params: {username: self.$store.state.auth.username}})
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels() {
|
||||
isOwner () {
|
||||
return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
|
||||
},
|
||||
labels () {
|
||||
return {
|
||||
title: this.$pgettext('*/*/*', 'Channel')
|
||||
}
|
||||
|
@ -190,9 +306,6 @@ export default {
|
|||
isPlayable () {
|
||||
return this.totalTracks > 0
|
||||
},
|
||||
rssUrl () {
|
||||
return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id() {
|
||||
|
|
|
@ -1,22 +1,86 @@
|
|||
<template>
|
||||
<section>
|
||||
<channel-entries :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
|
||||
<div class="ui info message" v-if="pendingUploads.length > 0">
|
||||
<template v-if="isSuccessfull">
|
||||
<i role="button" class="close icon" @click="pendingUploads = []"></i>
|
||||
<h3 class="ui header">
|
||||
<translate translate-context="Content/Channel/Header">Uploads published successfully</translate>
|
||||
</h3>
|
||||
<p>
|
||||
<translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="isOver">
|
||||
<h3 class="ui header">
|
||||
<translate translate-context="Content/Channel/Header">Some uploads couldn't be published</translate>
|
||||
</h3>
|
||||
<div class="ui hidden divider"></div>
|
||||
<router-link
|
||||
class="ui basic button"
|
||||
:to="{name: 'content.libraries.files', query: {q: 'status:skipped'}}"
|
||||
v-if="skippedUploads.length > 0">
|
||||
<translate translate-context="Content/Channel/Button">View skipped uploads</translate>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="ui basic button"
|
||||
:to="{name: 'content.libraries.files', query: {q: 'status:errored'}}"
|
||||
v-if="erroredUploads.length > 0">
|
||||
<translate translate-context="Content/Channel/Button">View errored uploads</translate>
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="ui inline right floated active loader"></div>
|
||||
<h3 class="ui header">
|
||||
<translate translate-context="Content/Channel/Header">Uploads are being processed</translate>
|
||||
</h3>
|
||||
<p>
|
||||
<translate translate-context="Content/Channel/Paragraph">Your uploads are being processed by Funkwhale and will be live very soon.</translate>
|
||||
</p>
|
||||
<p>
|
||||
<translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }}
|
||||
</p>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="$store.getters['ui/layoutVersion'] === 'small'">
|
||||
<rendered-description
|
||||
:content="object.artist.description"
|
||||
:update-url="`channels/${object.uuid}/`"
|
||||
:can-update="false"></rendered-description>
|
||||
<div class="ui hidden divider"></div>
|
||||
</div>
|
||||
<channel-entries :key="String(episodesKey) + 'entries'" :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
|
||||
<h2 class="ui header">
|
||||
<translate translate-context="Content/Channel/Paragraph">Latest episodes</translate>
|
||||
</h2>
|
||||
</channel-entries>
|
||||
<div class="ui hidden divider"></div>
|
||||
<channel-series :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
|
||||
<h2 class="ui header">
|
||||
<channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters">
|
||||
<h2 class="ui with-actions header">
|
||||
<translate translate-context="Content/Channel/Paragraph">Series</translate>
|
||||
<div class="actions" v-if="isOwner">
|
||||
<a @click.stop.prevent="$refs.albumModal.show = true">
|
||||
<i class="plus icon"></i>
|
||||
<translate translate-context="Content/Profile/Button">Add new</translate>
|
||||
</a>
|
||||
</div>
|
||||
</h2>
|
||||
</channel-series>
|
||||
<album-modal
|
||||
ref="albumModal"
|
||||
v-if="isOwner"
|
||||
:channel="object"
|
||||
@created="$refs.albumModal.show = false; seriesKey = new Date()"></album-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import qs from 'qs'
|
||||
|
||||
import ChannelEntries from "@/components/audio/ChannelEntries"
|
||||
import ChannelSeries from "@/components/audio/ChannelSeries"
|
||||
import AlbumModal from "@/components/channels/AlbumModal"
|
||||
|
||||
|
||||
export default {
|
||||
|
@ -24,6 +88,107 @@ export default {
|
|||
components: {
|
||||
ChannelEntries,
|
||||
ChannelSeries,
|
||||
AlbumModal,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
seriesKey: new Date(),
|
||||
episodesKey: new Date(),
|
||||
pendingUploads: [],
|
||||
}
|
||||
},
|
||||
async created () {
|
||||
if (this.isOwner) {
|
||||
await this.fetchPendingUploads()
|
||||
this.$store.commit("ui/addWebsocketEventHandler", {
|
||||
eventName: "import.status_updated",
|
||||
id: "fileUploadChannel",
|
||||
handler: this.handleImportEvent
|
||||
});
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
this.$store.commit("ui/removeWebsocketEventHandler", {
|
||||
eventName: "import.status_updated",
|
||||
id: "fileUploadChannel"
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
isOwner () {
|
||||
return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
|
||||
},
|
||||
seriesFilters () {
|
||||
let filters = {artist: this.object.artist.id, ordering: '-creation_date'}
|
||||
if (!this.isOwner) {
|
||||
filters.playable = 'true'
|
||||
}
|
||||
return filters
|
||||
},
|
||||
processedUploads () {
|
||||
return this.pendingUploads.filter((u) => {
|
||||
return u.import_status != "pending"
|
||||
})
|
||||
},
|
||||
erroredUploads () {
|
||||
return this.pendingUploads.filter((u) => {
|
||||
return u.import_status === "errored"
|
||||
})
|
||||
},
|
||||
skippedUploads () {
|
||||
return this.pendingUploads.filter((u) => {
|
||||
return u.import_status === "skipped"
|
||||
})
|
||||
},
|
||||
finishedUploads () {
|
||||
return this.pendingUploads.filter((u) => {
|
||||
return u.import_status === "finished"
|
||||
})
|
||||
},
|
||||
pendingUploadsById () {
|
||||
let d = {}
|
||||
this.pendingUploads.forEach((u) => {
|
||||
d[u.uuid] = u
|
||||
})
|
||||
return d
|
||||
},
|
||||
isOver () {
|
||||
return this.pendingUploads && this.processedUploads.length === this.pendingUploads.length
|
||||
},
|
||||
isSuccessfull () {
|
||||
return this.pendingUploads && this.finishedUploads.length === this.pendingUploads.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleImportEvent(event) {
|
||||
let self = this;
|
||||
if (!this.pendingUploadsById[event.upload.uuid]) {
|
||||
return;
|
||||
}
|
||||
Object.assign(this.pendingUploadsById[event.upload.uuid], event.upload)
|
||||
},
|
||||
async fetchPendingUploads () {
|
||||
let response = await axios.get('uploads/', {
|
||||
params: {channel: this.object.uuid, import_status: ['pending', 'skipped', 'errored'], include_channels: 'true'},
|
||||
paramsSerializer: function(params) {
|
||||
return qs.stringify(params, { indices: false })
|
||||
}
|
||||
})
|
||||
this.pendingUploads = response.data.results
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
"$store.state.channels.latestPublication" (v) {
|
||||
if (v && v.uploads && v.channel.uuid === this.object.uuid) {
|
||||
let test
|
||||
this.pendingUploads = [...this.pendingUploads, ...v.uploads]
|
||||
}
|
||||
},
|
||||
"isOver" (v) {
|
||||
if (v) {
|
||||
this.seriesKey = new Date()
|
||||
this.episodesKey = new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -2,21 +2,39 @@
|
|||
<section class="ui vertical aligned stripe segment" v-title="labels.title">
|
||||
<div class="ui text container">
|
||||
<h1>{{ labels.title }}</h1>
|
||||
<p><translate translate-context="Content/Library/Paragraph">There are various ways to grab new content and make it available here.</translate></p>
|
||||
<p>
|
||||
<strong><translate translate-context="Content/Library/Paragraph" :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space for every user.</translate></strong>
|
||||
</p>
|
||||
<div class="ui segment">
|
||||
<h2><translate translate-context="Content/Library/Title/Verb">Upload audio content</translate></h2>
|
||||
<p><translate translate-context="Content/Library/Paragraph">Upload music files (MP3, OGG, FLAC, etc.) from your personal library directly from your browser to enjoy them here.</translate></p>
|
||||
<h2>
|
||||
<i class="feed icon"></i>
|
||||
<translate translate-context="Content/Library/Title/Verb">Publish your work in a channel</translate>
|
||||
</h2>
|
||||
<p>
|
||||
<strong><translate translate-context="Content/Library/Paragraph" :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space for every user.</translate></strong>
|
||||
<translate translate-context="Content/Library/Paragraph">If you are a musician or a podcaster, channels are designed for you!</translate>
|
||||
<translate translate-context="Content/Library/Paragraph">Share your work publicly and get subscribers on Funkwhale, the Fediverse or any podcasting application.</translate>
|
||||
</p>
|
||||
<router-link :to="{name: 'content.libraries.index'}" class="ui green button">
|
||||
<router-link :to="{name: 'profile.overview', params: {username: $store.state.auth.username}, hash: '#channels'}" class="ui primary button">
|
||||
<translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="ui segment">
|
||||
<h2><translate translate-context="Content/Library/Title/Verb">Follow remote libraries</translate></h2>
|
||||
<p><translate translate-context="Content/Library/Paragraph">You can follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner.</translate></p>
|
||||
<router-link :to="{name: 'content.remote.index'}" class="ui green button">
|
||||
<h2>
|
||||
<i class="cloud icon"></i>
|
||||
<translate translate-context="Content/Library/Title/Verb">Upload third-party content in a library</translate>
|
||||
</h2>
|
||||
<p><translate translate-context="Content/Library/Paragraph">Upload your personal music library to Funkwhale to enjoy it from anywhere and share it with friends and family.</translate></p>
|
||||
<router-link :to="{name: 'content.libraries.index'}" class="ui primary button">
|
||||
<translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="ui segment">
|
||||
<h2>
|
||||
<i class="download icon"></i>
|
||||
<translate translate-context="Content/Library/Title/Verb">Follow remote libraries</translate>
|
||||
</h2>
|
||||
<p><translate translate-context="Content/Library/Paragraph">Follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner.</translate></p>
|
||||
<router-link :to="{name: 'content.remote.index'}" class="ui primary button">
|
||||
<translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
@ -28,6 +28,9 @@
|
|||
<option value>
|
||||
<translate translate-context="Content/*/Dropdown">All</translate>
|
||||
</option>
|
||||
<option value="draft">
|
||||
<translate translate-context="Content/Library/*/Short">Draft</translate>
|
||||
</option>
|
||||
<option value="pending">
|
||||
<translate translate-context="Content/Library/*/Short">Pending</translate>
|
||||
</option>
|
||||
|
@ -258,7 +261,8 @@ export default {
|
|||
page: this.page,
|
||||
page_size: this.paginateBy,
|
||||
ordering: this.getOrderingAsString(),
|
||||
q: this.search.query
|
||||
q: this.search.query,
|
||||
include_channels: 'true',
|
||||
},
|
||||
this.filters || {}
|
||||
);
|
||||
|
@ -288,7 +292,8 @@ export default {
|
|||
},
|
||||
actionFilters() {
|
||||
var currentFilters = {
|
||||
q: this.search.query
|
||||
q: this.search.query,
|
||||
include_channels: 'true',
|
||||
};
|
||||
if (this.filters) {
|
||||
return _.merge(currentFilters, this.filters);
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
<translate translate-context="Content/Library/Button.Label/Verb" v-if="library">Update library</translate>
|
||||
<translate translate-context="Content/Library/Button.Label/Verb" v-else>Create library</translate>
|
||||
</button>
|
||||
<dangerous-button v-if="library" class="right floated basic button" color='red' @confirm="remove()">
|
||||
<dangerous-button v-if="library" class="ui right floated basic red button" @confirm="remove()">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header">
|
||||
<translate translate-context="Popup/Library/Title">Delete this library?</translate>
|
||||
|
|
|
@ -31,8 +31,7 @@
|
|||
</router-link>
|
||||
|
||||
<dangerous-button
|
||||
color="grey"
|
||||
class="basic tiny"
|
||||
class="ui basic tiny grey button"
|
||||
:action="purgePendingFiles">
|
||||
<translate translate-context="*/*/*/Verb">Purge</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge pending files?</translate></p>
|
||||
|
@ -57,8 +56,7 @@
|
|||
<translate translate-context="Content/Library/Link/Verb">View files</translate>
|
||||
</router-link>
|
||||
<dangerous-button
|
||||
color="grey"
|
||||
class="basic tiny"
|
||||
class="ui basic tiny grey button"
|
||||
:action="purgeSkippedFiles">
|
||||
<translate translate-context="*/*/*/Verb">Purge</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge skipped files?</translate></p>
|
||||
|
@ -83,8 +81,7 @@
|
|||
<translate translate-context="Content/Library/Link/Verb">View files</translate>
|
||||
</router-link>
|
||||
<dangerous-button
|
||||
color="grey"
|
||||
class="basic tiny"
|
||||
class="ui basic tiny grey button"
|
||||
:action="purgeErroredFiles">
|
||||
<translate translate-context="*/*/*/Verb">Purge</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge errored files?</translate></p>
|
||||
|
@ -154,4 +151,4 @@ export default {
|
|||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -115,7 +115,6 @@
|
|||
</template>
|
||||
<template v-else-if="library.follow.approved">
|
||||
<dangerous-button
|
||||
color=""
|
||||
:class="['ui', 'button']"
|
||||
:action="unfollow">
|
||||
<translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate>
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
|
||||
</button>
|
||||
|
||||
<dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="labeled icon" :action="deletePlaylist">
|
||||
<dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="ui labeled red icon button" :action="deletePlaylist">
|
||||
<i class="trash icon"></i> <translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title/Call to action" :translate-params="{playlist: playlist.name}">
|
||||
Do you want to delete the playlist "%{ playlist }"?
|
||||
|
@ -58,7 +58,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui deny button">
|
||||
<div class="ui basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue