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