First round of improvements to channel management:

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

View File

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

View File

@ -23,7 +23,12 @@ urlpatterns = [
),
urls.re_path(
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",
),
]

View File

@ -6,6 +6,8 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.db.models.signals import post_delete
from django.dispatch import receiver
from funkwhale_api.federation import keys
from funkwhale_api.federation import models as federation_models
@ -44,14 +46,22 @@ class Channel(models.Model):
)
def get_absolute_url(self):
return federation_utils.full_url("/channels/{}".format(self.uuid))
suffix = self.uuid
if self.actor.is_local:
suffix = self.actor.preferred_username
else:
suffix = self.actor.full_username
return federation_utils.full_url("/channels/{}".format(suffix))
def get_rss_url(self):
if not self.artist.is_local:
return self.rss_url
return federation_utils.full_url(
reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid})
reverse(
"api:v1:channels-rss",
kwargs={"composite": self.actor.preferred_username},
)
)
@ -62,3 +72,10 @@ def generate_actor(username, **kwargs):
actor_data["public_key"] = public.decode("utf-8")
return federation_models.Actor.objects.create(**actor_data)
@receiver(post_delete, sender=Channel)
def delete_channel_related_objs(instance, **kwargs):
instance.library.delete()
instance.actor.delete()
instance.artist.delete()

View File

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

View File

@ -1,26 +1,33 @@
import urllib.parse
from django.conf import settings
from django.db.models import Q
from django.urls import reverse
from rest_framework import serializers
from funkwhale_api.common import preferences
from funkwhale_api.common import utils
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import spa_views
from . import models
def channel_detail(request, uuid):
queryset = models.Channel.objects.filter(uuid=uuid).select_related(
def channel_detail(query):
queryset = models.Channel.objects.filter(query).select_related(
"artist__attachment_cover", "actor", "library"
)
try:
obj = queryset.get()
except models.Channel.DoesNotExist:
return []
obj_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("channel_detail", kwargs={"uuid": obj.uuid}),
utils.spa_reverse(
"channel_detail", kwargs={"username": obj.actor.full_username}
),
)
metas = [
{"tag": "meta", "property": "og:url", "content": obj_url},
@ -72,3 +79,25 @@ def channel_detail(request, uuid):
# twitter player is also supported in various software
metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid)
return metas
def channel_detail_uuid(request, uuid):
validator = serializers.UUIDField().to_internal_value
try:
uuid = validator(uuid)
except serializers.ValidationError:
return []
return channel_detail(Q(uuid=uuid))
def channel_detail_username(request, username):
validator = federation_utils.get_actor_data_from_username
try:
username_data = validator(username)
except serializers.ValidationError:
return []
query = Q(
actor__domain=username_data["domain"],
actor__preferred_username__iexact=username_data["username"],
)
return channel_detail(query)

View File

@ -7,18 +7,21 @@ from rest_framework import viewsets
from django import http
from django.db import transaction
from django.db.models import Count, Prefetch
from django.db.models import Count, Prefetch, Q
from django.db.utils import IntegrityError
from funkwhale_api.common import locales
from funkwhale_api.common import permissions
from funkwhale_api.common import preferences
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import routes
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, renderers, serializers
from . import categories, filters, models, renderers, serializers
ARTIST_PREFETCH_QS = (
music_models.Artist.objects.select_related("description", "attachment_cover",)
@ -36,6 +39,7 @@ class ChannelsMixin(object):
class ChannelViewSet(
ChannelsMixin,
MultipleLookupDetailMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
@ -43,7 +47,20 @@ class ChannelViewSet(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
url_lookups = [
{
"lookup_field": "uuid",
"validator": serializers.serializers.UUIDField().to_internal_value,
},
{
"lookup_field": "username",
"validator": federation_utils.get_actor_data_from_username,
"get_query": lambda v: Q(
actor__domain=v["domain"],
actor__preferred_username__iexact=v["username"],
),
},
]
filterset_class = filters.ChannelFilter
serializer_class = serializers.ChannelSerializer
queryset = (
@ -134,6 +151,25 @@ class ChannelViewSet(
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
return response.Response(data, status=200)
@decorators.action(
methods=["get"],
detail=False,
url_path="metadata-choices",
url_name="metadata_choices",
permission_classes=[],
)
def metedata_choices(self, request, *args, **kwargs):
data = {
"language": [
{"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
],
"itunes_category": [
{"value": code, "label": code, "children": children}
for code, children in categories.ITUNES_CATEGORIES.items()
],
}
return response.Response(data)
def get_serializer_context(self):
context = super().get_serializer_context()
context["subscriptions_count"] = self.action in [
@ -152,7 +188,7 @@ class ChannelViewSet(
{"type": "Delete", "object": {"type": instance.actor.type}},
context={"actor": instance.actor},
)
instance.delete()
instance.__class__.objects.filter(pk=instance.pk).delete()
class SubscriptionsViewSet(

View File

@ -1,4 +1,5 @@
import html
import logging
import io
import os
import re
@ -20,6 +21,8 @@ from . import utils
EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
logger = logging.getLogger(__name__)
def should_fallback_to_spa(path):
if path == "/":
@ -270,6 +273,17 @@ class ThrottleStatusMiddleware:
return response
class VerboseBadRequestsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if response.status_code == 400:
logger.warning("Bad request: %s", response.content)
return response
class ProfilerMiddleware:
"""
from https://github.com/omarish/django-cprofile-middleware/blob/master/django_cprofile_middleware/middleware.py

View File

@ -0,0 +1,34 @@
from rest_framework import serializers
from django.db.models import Q
from django.shortcuts import get_object_or_404
class MultipleLookupDetailMixin(object):
lookup_value_regex = "[^/]+"
lookup_field = "composite"
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
relevant_lookup = None
value = None
for lookup in self.url_lookups:
field_validator = lookup["validator"]
try:
value = field_validator(self.kwargs["composite"])
except serializers.ValidationError:
continue
else:
relevant_lookup = lookup
break
get_query = relevant_lookup.get(
"get_query", lambda value: Q(**{relevant_lookup["lookup_field"]: value})
)
query = get_query(value)
obj = get_object_or_404(queryset, query)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj

View File

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

View File

@ -279,7 +279,11 @@ HTML_PERMISSIVE_CLEANER = bleach.sanitizer.Cleaner(
attributes=["class", "rel", "alt", "title"],
)
HTML_LINKER = bleach.linkifier.Linker()
# support for additional tlds
# cf https://github.com/mozilla/bleach/issues/367#issuecomment-384631867
ALL_TLDS = set(settings.LINKIFIER_SUPPORTED_TLDS + bleach.linkifier.TLDS)
URL_RE = bleach.linkifier.build_url_re(tlds=sorted(ALL_TLDS, reverse=True))
HTML_LINKER = bleach.linkifier.Linker(url_re=URL_RE)
def clean_html(html, permissive=False):
@ -338,29 +342,34 @@ def attach_file(obj, field, file_data, fetch=False):
if not file_data:
return
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(file_data["mimetype"], "jpg")
attachment = models.Attachment(mimetype=file_data["mimetype"])
name_fields = ["uuid", "full_username", "pk"]
name = [getattr(obj, field) for field in name_fields if getattr(obj, field, None)][
0
]
filename = "{}-{}.{}".format(field, name, extension)
if "url" in file_data:
attachment.url = file_data["url"]
if isinstance(file_data, models.Attachment):
attachment = file_data
else:
f = ContentFile(file_data["content"])
attachment.file.save(filename, f, save=False)
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(file_data["mimetype"], "jpg")
attachment = models.Attachment(mimetype=file_data["mimetype"])
name_fields = ["uuid", "full_username", "pk"]
name = [
getattr(obj, field) for field in name_fields if getattr(obj, field, None)
][0]
filename = "{}-{}.{}".format(field, name, extension)
if "url" in file_data:
attachment.url = file_data["url"]
else:
f = ContentFile(file_data["content"])
attachment.file.save(filename, f, save=False)
if not attachment.file and fetch:
try:
tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
except Exception as e:
logger.warn("Cannot download attachment at url %s: %s", attachment.url, e)
attachment = None
if not attachment.file and fetch:
try:
tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
except Exception as e:
logger.warn(
"Cannot download attachment at url %s: %s", attachment.url, e
)
attachment = None
if attachment:
attachment.save()
if attachment:
attachment.save()
setattr(obj, field, attachment)
obj.save(update_fields=[field])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -512,9 +512,10 @@ class ArtistField(serializers.Field):
mbid = None
artist = {"name": name, "mbid": mbid}
final.append(artist)
field = serializers.ListField(child=ArtistSerializer(), min_length=1)
field = serializers.ListField(
child=ArtistSerializer(strict=self.context.get("strict", True)),
min_length=1,
)
return field.to_internal_value(final)
@ -647,15 +648,29 @@ class MBIDField(serializers.UUIDField):
class ArtistSerializer(serializers.Serializer):
name = serializers.CharField()
name = serializers.CharField(required=False, allow_null=True)
mbid = MBIDField()
def __init__(self, *args, **kwargs):
self.strict = kwargs.pop("strict", True)
super().__init__(*args, **kwargs)
def validate_name(self, v):
if self.strict and not v:
raise serializers.ValidationError("This field is required.")
return v
class AlbumSerializer(serializers.Serializer):
title = serializers.CharField()
title = serializers.CharField(required=False, allow_null=True)
mbid = MBIDField()
release_date = PermissiveDateField(required=False, allow_null=True)
def validate_title(self, v):
if self.context.get("strict", True) and not v:
raise serializers.ValidationError("This field is required.")
return v
class PositionField(serializers.CharField):
def to_internal_value(self, v):
@ -691,7 +706,7 @@ class DescriptionField(serializers.CharField):
class TrackMetadataSerializer(serializers.Serializer):
title = serializers.CharField()
title = serializers.CharField(required=False, allow_null=True)
position = PositionField(allow_blank=True, allow_null=True, required=False)
disc_number = PositionField(allow_blank=True, allow_null=True, required=False)
copyright = serializers.CharField(allow_blank=True, allow_null=True, required=False)
@ -714,6 +729,11 @@ class TrackMetadataSerializer(serializers.Serializer):
"tags",
]
def validate_title(self, v):
if self.context.get("strict", True) and not v:
raise serializers.ValidationError("This field is required.")
return v
def validate(self, validated_data):
validated_data = super().validate(validated_data)
for field in self.remove_blank_null_fields:

View File

@ -950,7 +950,11 @@ class Upload(models.Model):
def get_all_tagged_items(self):
track_tags = self.track.tagged_items.all()
album_tags = self.track.album.tagged_items.all()
album_tags = (
self.track.album.tagged_items.all()
if self.track.album
else tags_models.TaggedItem.objects.none()
)
artist_tags = self.track.artist.tagged_items.all()
items = (track_tags | album_tags | artist_tags).order_by("tag__name")

View File

@ -6,16 +6,30 @@ from django.conf import settings
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.audio import serializers as audio_serializers
from funkwhale_api.common import models as common_models
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import routes
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags.models import Tag
from funkwhale_api.tags import models as tag_models
from funkwhale_api.tags import serializers as tags_serializers
from . import filters, models, tasks
from . import filters, models, tasks, utils
NOOP = object()
COVER_WRITE_FIELD = common_serializers.RelatedField(
"uuid",
queryset=common_models.Attachment.objects.all().local(),
serializer=None,
allow_null=True,
required=False,
queryset_filter=lambda qs, context: qs.filter(actor=context["request"].user.actor),
write_only=True,
)
from funkwhale_api.audio import serializers as audio_serializers # NOQA
class CoverField(
@ -381,9 +395,30 @@ class UploadSerializer(serializers.ModelSerializer):
"import_date",
]
def validate(self, data):
validated_data = super().validate(data)
if "audio_file" in validated_data:
audio_data = utils.get_audio_file_data(validated_data["audio_file"])
if audio_data:
validated_data["duration"] = audio_data["length"]
validated_data["bitrate"] = audio_data["bitrate"]
return validated_data
def filter_album(qs, context):
if "channel" in context:
return qs.filter(artist__channel=context["channel"])
if "actor" in context:
return qs.filter(artist__attributed_to=context["actor"])
return qs.none()
class ImportMetadataSerializer(serializers.Serializer):
title = serializers.CharField(max_length=500, required=True)
description = serializers.CharField(
max_length=5000, required=False, allow_null=True
)
mbid = serializers.UUIDField(required=False, allow_null=True)
copyright = serializers.CharField(max_length=500, required=False, allow_null=True)
position = serializers.IntegerField(min_value=1, required=False, allow_null=True)
@ -391,12 +426,32 @@ class ImportMetadataSerializer(serializers.Serializer):
license = common_serializers.RelatedField(
"code", LicenseSerializer(), required=False, allow_null=True
)
cover = common_serializers.RelatedField(
"uuid",
queryset=common_models.Attachment.objects.all().local(),
serializer=None,
queryset_filter=lambda qs, context: qs.filter(actor=context["actor"]),
write_only=True,
required=False,
allow_null=True,
)
album = common_serializers.RelatedField(
"id",
queryset=models.Album.objects.all(),
serializer=None,
queryset_filter=filter_album,
write_only=True,
required=False,
allow_null=True,
)
class ImportMetadataField(serializers.JSONField):
def to_internal_value(self, v):
v = super().to_internal_value(v)
s = ImportMetadataSerializer(data=v)
s = ImportMetadataSerializer(
data=v, context={"actor": self.context["user"].actor}
)
s.is_valid(raise_exception=True)
return v
@ -464,6 +519,7 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
actions = [
common_serializers.Action("delete", allow_all=True),
common_serializers.Action("relaunch_import", allow_all=True),
common_serializers.Action("publish", allow_all=False),
]
filterset_class = filters.UploadFilter
pk_field = "uuid"
@ -490,10 +546,18 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
for pk in pks:
common_utils.on_commit(tasks.process_upload.delay, upload_id=pk)
@transaction.atomic
def handle_publish(self, objects):
qs = objects.filter(import_status="draft")
pks = list(qs.values_list("id", flat=True))
qs.update(import_status="pending")
for pk in pks:
common_utils.on_commit(tasks.process_upload.delay, upload_id=pk)
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
model = tag_models.Tag
fields = ("id", "name", "creation_date")
@ -509,7 +573,7 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
name = serializers.CharField(source="title")
artist = serializers.CharField(source="artist.name")
album = serializers.CharField(source="album.title")
album = serializers.SerializerMethodField()
class Meta:
model = models.Track
@ -518,6 +582,10 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
def get_type(self, obj):
return "Audio"
def get_album(self, o):
if o.album:
return o.album.title
def get_embed_url(type, id):
return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id)
@ -561,7 +629,13 @@ class OembedSerializer(serializers.Serializer):
embed_type = "track"
embed_id = track.pk
data["title"] = "{} by {}".format(track.title, track.artist.name)
if track.album.attachment_cover:
if track.attachment_cover:
data[
"thumbnail_url"
] = track.album.attachment_cover.download_url_medium_square_crop
data["thumbnail_width"] = 200
data["thumbnail_height"] = 200
elif track.album and track.album.attachment_cover:
data[
"thumbnail_url"
] = track.album.attachment_cover.download_url_medium_square_crop
@ -630,7 +704,16 @@ class OembedSerializer(serializers.Serializer):
elif match.url_name == "channel_detail":
from funkwhale_api.audio.models import Channel
qs = Channel.objects.filter(uuid=match.kwargs["uuid"]).select_related(
kwargs = {}
if "uuid" in match.kwargs:
kwargs["uuid"] = match.kwargs["uuid"]
else:
username_data = federation_utils.get_actor_data_from_username(
match.kwargs["username"]
)
kwargs["actor__domain"] = username_data["domain"]
kwargs["actor__preferred_username__iexact"] = username_data["username"]
qs = Channel.objects.filter(**kwargs).select_related(
"artist__attachment_cover"
)
try:
@ -705,3 +788,46 @@ class OembedSerializer(serializers.Serializer):
def create(self, data):
return data
class AlbumCreateSerializer(serializers.Serializer):
title = serializers.CharField(required=True, max_length=255)
cover = COVER_WRITE_FIELD
release_date = serializers.DateField(required=False, allow_null=True)
tags = tags_serializers.TagsListField(required=False)
description = common_serializers.ContentSerializer(allow_null=True, required=False)
artist = common_serializers.RelatedField(
"id",
queryset=models.Artist.objects.exclude(channel__isnull=True),
required=True,
serializer=None,
filters=lambda context: {"attributed_to": context["user"].actor},
)
def validate(self, validated_data):
duplicates = validated_data["artist"].albums.filter(
title__iexact=validated_data["title"]
)
if duplicates.exists():
raise serializers.ValidationError("An album with this title already exist")
return super().validate(validated_data)
def to_representation(self, obj):
obj.artist.attachment_cover
return AlbumSerializer(obj, context=self.context).data
def create(self, validated_data):
instance = models.Album.objects.create(
attributed_to=self.context["user"].actor,
artist=validated_data["artist"],
release_date=validated_data.get("release_date"),
title=validated_data["title"],
attachment_cover=validated_data.get("cover"),
)
common_utils.attach_content(
instance, "description", validated_data.get("description")
)
tag_models.set_tags(instance, *(validated_data.get("tags", []) or []))
return instance

View File

@ -49,16 +49,29 @@ def library_track(request, pk):
utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
),
},
{
"tag": "meta",
"property": "music:album",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
),
},
]
if obj.album.attachment_cover:
if obj.album:
metas.append(
{
"tag": "meta",
"property": "music:album",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
),
},
)
if obj.attachment_cover:
metas.append(
{
"tag": "meta",
"property": "og:image",
"content": obj.attachment_cover.download_url_medium_square_crop,
}
)
elif obj.album and obj.album.attachment_cover:
metas.append(
{
"tag": "meta",

View File

@ -175,46 +175,69 @@ def fail_import(upload, error_code, detail=None, **fields):
"upload",
)
def process_upload(upload, update_denormalization=True):
"""
Main handler to process uploads submitted by user and create the corresponding
metadata (tracks/artists/albums) in our DB.
"""
from . import serializers
channel = upload.library.get_channel()
# When upload is linked to a channel instead of a library
# we willingly ignore the metadata embedded in the file itself
# and rely on user metadata only
use_file_metadata = channel is None
import_metadata = upload.import_metadata or {}
internal_config = {"funkwhale": import_metadata.get("funkwhale", {})}
forced_values_serializer = serializers.ImportMetadataSerializer(
data=import_metadata
data=import_metadata,
context={"actor": upload.library.actor, "channel": channel},
)
if forced_values_serializer.is_valid():
forced_values = forced_values_serializer.validated_data
else:
forced_values = {}
if not use_file_metadata:
detail = forced_values_serializer.errors
metadata_dump = import_metadata
return fail_import(
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
)
if upload.library.get_channel():
if channel:
# ensure the upload is associated with the channel artist
forced_values["artist"] = upload.library.channel.artist
old_status = upload.import_status
audio_file = upload.get_audio_file()
additional_data = {}
additional_data = {"upload_source": upload.source}
m = metadata.Metadata(audio_file)
try:
serializer = metadata.TrackMetadataSerializer(data=m)
serializer.is_valid()
except Exception:
fail_import(upload, "unknown_error")
raise
if not serializer.is_valid():
detail = serializer.errors
if use_file_metadata:
audio_file = upload.get_audio_file()
m = metadata.Metadata(audio_file)
try:
metadata_dump = m.all()
except Exception as e:
logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
return fail_import(
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
)
serializer = metadata.TrackMetadataSerializer(data=m)
serializer.is_valid()
except Exception:
fail_import(upload, "unknown_error")
raise
if not serializer.is_valid():
detail = serializer.errors
try:
metadata_dump = m.all()
except Exception as e:
logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
return fail_import(
upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
)
final_metadata = collections.ChainMap(
additional_data, serializer.validated_data, internal_config
)
additional_data["upload_source"] = upload.source
final_metadata = collections.ChainMap(
additional_data, serializer.validated_data, internal_config
)
else:
final_metadata = collections.ChainMap(
additional_data, forced_values, internal_config,
)
try:
track = get_track_from_import_metadata(
final_metadata, attributed_to=upload.library.actor, **forced_values
@ -275,7 +298,7 @@ def process_upload(upload, update_denormalization=True):
)
# update album cover, if needed
if not track.album.attachment_cover:
if track.album and not track.album.attachment_cover:
populate_album_cover(
track.album, source=final_metadata.get("upload_source"),
)
@ -466,7 +489,11 @@ def _get_track(data, attributed_to=None, **forced_values):
track_mbid = (
forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None)
)
album_mbid = getter(data, "album", "mbid")
try:
album_mbid = getter(data, "album", "mbid")
except TypeError:
# album is forced
album_mbid = None
track_fid = getter(data, "fid")
query = None
@ -528,81 +555,94 @@ def _get_track(data, attributed_to=None, **forced_values):
if "album" in forced_values:
album = forced_values["album"]
else:
album_artists = getter(data, "album", "artists", default=artists) or artists
album_artist_data = album_artists[0]
album_artist_name = truncate(
album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
)
if album_artist_name == artist_name:
album_artist = artist
if "artist" in forced_values:
album_artist = forced_values["artist"]
else:
query = Q(name__iexact=album_artist_name)
album_artist_mbid = album_artist_data.get("mbid", None)
album_artist_fid = album_artist_data.get("fid", None)
if album_artist_mbid:
query |= Q(mbid=album_artist_mbid)
if album_artist_fid:
query |= Q(fid=album_artist_fid)
defaults = {
"name": album_artist_name,
"mbid": album_artist_mbid,
"fid": album_artist_fid,
"from_activity_id": from_activity_id,
"attributed_to": album_artist_data.get("attributed_to", attributed_to),
}
if album_artist_data.get("fdate"):
defaults["creation_date"] = album_artist_data.get("fdate")
album_artist, created = get_best_candidate_or_create(
models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
album_artists = getter(data, "album", "artists", default=artists) or artists
album_artist_data = album_artists[0]
album_artist_name = truncate(
album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
)
if created:
tags_models.add_tags(album_artist, *album_artist_data.get("tags", []))
common_utils.attach_content(
album_artist, "description", album_artist_data.get("description")
)
common_utils.attach_file(
album_artist,
"attachment_cover",
album_artist_data.get("cover_data"),
if album_artist_name == artist_name:
album_artist = artist
else:
query = Q(name__iexact=album_artist_name)
album_artist_mbid = album_artist_data.get("mbid", None)
album_artist_fid = album_artist_data.get("fid", None)
if album_artist_mbid:
query |= Q(mbid=album_artist_mbid)
if album_artist_fid:
query |= Q(fid=album_artist_fid)
defaults = {
"name": album_artist_name,
"mbid": album_artist_mbid,
"fid": album_artist_fid,
"from_activity_id": from_activity_id,
"attributed_to": album_artist_data.get(
"attributed_to", attributed_to
),
}
if album_artist_data.get("fdate"):
defaults["creation_date"] = album_artist_data.get("fdate")
album_artist, created = get_best_candidate_or_create(
models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
)
if created:
tags_models.add_tags(
album_artist, *album_artist_data.get("tags", [])
)
common_utils.attach_content(
album_artist,
"description",
album_artist_data.get("description"),
)
common_utils.attach_file(
album_artist,
"attachment_cover",
album_artist_data.get("cover_data"),
)
# get / create album
album_data = data["album"]
album_title = truncate(album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"])
album_fid = album_data.get("fid", None)
if "album" in data:
album_data = data["album"]
album_title = truncate(
album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"]
)
album_fid = album_data.get("fid", None)
if album_mbid:
query = Q(mbid=album_mbid)
if album_mbid:
query = Q(mbid=album_mbid)
else:
query = Q(title__iexact=album_title, artist=album_artist)
if album_fid:
query |= Q(fid=album_fid)
defaults = {
"title": album_title,
"artist": album_artist,
"mbid": album_mbid,
"release_date": album_data.get("release_date"),
"fid": album_fid,
"from_activity_id": from_activity_id,
"attributed_to": album_data.get("attributed_to", attributed_to),
}
if album_data.get("fdate"):
defaults["creation_date"] = album_data.get("fdate")
album, created = get_best_candidate_or_create(
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
)
if created:
tags_models.add_tags(album, *album_data.get("tags", []))
common_utils.attach_content(
album, "description", album_data.get("description")
)
common_utils.attach_file(
album, "attachment_cover", album_data.get("cover_data")
)
else:
query = Q(title__iexact=album_title, artist=album_artist)
if album_fid:
query |= Q(fid=album_fid)
defaults = {
"title": album_title,
"artist": album_artist,
"mbid": album_mbid,
"release_date": album_data.get("release_date"),
"fid": album_fid,
"from_activity_id": from_activity_id,
"attributed_to": album_data.get("attributed_to", attributed_to),
}
if album_data.get("fdate"):
defaults["creation_date"] = album_data.get("fdate")
album, created = get_best_candidate_or_create(
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
)
if created:
tags_models.add_tags(album, *album_data.get("tags", []))
common_utils.attach_content(
album, "description", album_data.get("description")
)
common_utils.attach_file(
album, "attachment_cover", album_data.get("cover_data")
)
album = None
# get / create track
track_title = (
forced_values["title"]
@ -629,6 +669,14 @@ def _get_track(data, attributed_to=None, **forced_values):
if "copyright" in forced_values
else truncate(data.get("copyright"), models.MAX_LENGTHS["COPYRIGHT"])
)
description = (
{"text": forced_values["description"], "content_type": "text/markdown"}
if "description" in forced_values
else data.get("description")
)
cover_data = (
forced_values["cover"] if "cover" in forced_values else data.get("cover_data")
)
query = Q(
title__iexact=track_title,
@ -670,8 +718,8 @@ def _get_track(data, attributed_to=None, **forced_values):
forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
)
tags_models.add_tags(track, *tags)
common_utils.attach_content(track, "description", data.get("description"))
common_utils.attach_file(track, "attachment_cover", data.get("cover_data"))
common_utils.attach_content(track, "description", description)
common_utils.attach_file(track, "attachment_cover", cover_data)
return track

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -1,3 +1,5 @@
import pytest
from django.urls import reverse
from funkwhale_api.federation import utils as federation_utils
@ -15,7 +17,10 @@ def test_channel(factories, now):
def test_channel_get_rss_url_local(factories):
channel = factories["audio.Channel"](artist__local=True)
expected = federation_utils.full_url(
reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
reverse(
"api:v1:channels-rss",
kwargs={"composite": channel.actor.preferred_username},
)
)
assert channel.get_rss_url() == expected
@ -23,3 +28,15 @@ def test_channel_get_rss_url_local(factories):
def test_channel_get_rss_url_remote(factories):
channel = factories["audio.Channel"]()
assert channel.get_rss_url() == channel.rss_url
def test_channel_delete(factories):
channel = factories["audio.Channel"]()
library = channel.library
actor = channel.library
artist = channel.artist
channel.delete()
for obj in [library, actor, artist]:
with pytest.raises(obj.DoesNotExist):
obj.refresh_from_db()

View File

@ -3,6 +3,8 @@ import datetime
import pytest
import pytz
from django.contrib.staticfiles.templatetags.staticfiles import static
from funkwhale_api.audio import serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
@ -11,20 +13,21 @@ from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import serializers as music_serializers
def test_channel_serializer_create(factories):
def test_channel_serializer_create(factories, mocker):
attributed_to = factories["federation.Actor"](local=True)
attachment = factories["common.Attachment"](actor=attributed_to)
request = mocker.Mock(user=mocker.Mock(actor=attributed_to))
data = {
# TODO: cover
"name": "My channel",
"username": "mychannel",
"description": {"text": "This is my channel", "content_type": "text/markdown"},
"tags": ["hello", "world"],
"content_category": "other",
"cover": attachment.uuid,
}
serializer = serializers.ChannelCreateSerializer(
data=data, context={"actor": attributed_to}
data=data, context={"actor": attributed_to, "request": request}
)
assert serializer.is_valid(raise_exception=True) is True
@ -37,14 +40,12 @@ def test_channel_serializer_create(factories):
== data["tags"]
)
assert channel.artist.description.text == data["description"]["text"]
assert channel.artist.attachment_cover == attachment
assert channel.artist.content_category == data["content_category"]
assert (
channel.artist.description.content_type == data["description"]["content_type"]
)
assert channel.attributed_to == attributed_to
assert channel.actor.summary == common_utils.render_html(
data["description"]["text"], "text/markdown"
)
assert channel.actor.preferred_username == data["username"]
assert channel.actor.name == data["name"]
assert channel.library.privacy_level == "everyone"
@ -150,24 +151,31 @@ def test_channel_serializer_create_podcast(factories):
assert channel.metadata == data["metadata"]
def test_channel_serializer_update(factories):
channel = factories["audio.Channel"](artist__set_tags=["rock"])
def test_channel_serializer_update(factories, mocker):
channel = factories["audio.Channel"](
artist__set_tags=["rock"], attributed_to__local=True
)
attributed_to = channel.attributed_to
attachment = factories["common.Attachment"](actor=attributed_to)
request = mocker.Mock(user=mocker.Mock(actor=attributed_to))
data = {
# TODO: cover
"name": "My channel",
"description": {"text": "This is my channel", "content_type": "text/markdown"},
"tags": ["hello", "world"],
"content_category": "other",
"cover": attachment.uuid,
}
serializer = serializers.ChannelUpdateSerializer(channel, data=data)
serializer = serializers.ChannelUpdateSerializer(
channel, data=data, context={"request": request}
)
assert serializer.is_valid(raise_exception=True) is True
serializer.save()
channel.refresh_from_db()
assert channel.artist.name == data["name"]
assert channel.artist.attachment_cover == attachment
assert channel.artist.content_category == data["content_category"]
assert (
sorted(channel.artist.tagged_items.values_list("tag__name", flat=True))
@ -281,7 +289,7 @@ def test_rss_item_serializer(factories):
{
"url": federation_utils.full_url(upload.get_listen_url("mp3")),
"length": upload.size,
"type": upload.mimetype,
"type": "audio/mpeg",
}
],
}
@ -350,6 +358,30 @@ def test_rss_channel_serializer(factories):
assert serializers.rss_serialize_channel(channel) == expected
def test_rss_channel_serializer_placeholder_image(factories):
description = factories["common.Content"]()
channel = factories["audio.Channel"](
artist__set_tags=["pop", "rock"],
artist__description=description,
artist__attachment_cover=None,
)
setattr(
channel.artist,
"_prefetched_tagged_items",
channel.artist.tagged_items.order_by("tag__name"),
)
expected = [
{
"href": federation_utils.full_url(
static("images/podcasts-cover-placeholder.png")
)
}
]
assert serializers.rss_serialize_channel(channel)["itunes:image"] == expected
def test_serialize_full_channel(factories):
channel = factories["audio.Channel"]()
upload1 = factories["music.Upload"](playable=True)

View File

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

View File

@ -3,8 +3,11 @@ import pytest
from django.urls import reverse
from funkwhale_api.audio import categories
from funkwhale_api.audio import serializers
from funkwhale_api.audio import views
from funkwhale_api.common import locales
from funkwhale_api.common import utils
def test_channel_create(logged_in_api_client):
@ -38,15 +41,25 @@ def test_channel_create(logged_in_api_client):
== data["tags"]
)
assert channel.attributed_to == actor
assert channel.actor.summary == channel.artist.description.rendered
assert channel.artist.description.text == data["description"]["text"]
assert (
channel.artist.description.content_type == data["description"]["content_type"]
)
assert channel.actor.preferred_username == data["username"]
assert channel.library.privacy_level == "everyone"
assert channel.library.actor == actor
def test_channel_detail(factories, logged_in_api_client):
channel = factories["audio.Channel"](artist__description=None)
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
@pytest.mark.parametrize(
"field", ["uuid", "actor.preferred_username", "actor.full_username"],
)
def test_channel_detail(field, factories, logged_in_api_client):
channel = factories["audio.Channel"](artist__description=None, local=True)
url = reverse(
"api:v1:channels-detail",
kwargs={"composite": utils.recursive_getattr(channel, field)},
)
setattr(channel.artist, "_tracks_count", 0)
setattr(channel.artist, "_prefetched_tagged_items", [])
@ -85,7 +98,7 @@ def test_channel_update(logged_in_api_client, factories):
"name": "new name"
}
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
response = logged_in_api_client.patch(url, data)
assert response.status_code == 200
@ -101,7 +114,7 @@ def test_channel_update_permission(logged_in_api_client, factories):
data = {"name": "new name"}
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
response = logged_in_api_client.patch(url, data)
assert response.status_code == 403
@ -112,7 +125,7 @@ def test_channel_delete(logged_in_api_client, factories, mocker):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor)
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
response = logged_in_api_client.delete(url)
@ -131,7 +144,7 @@ def test_channel_delete_permission(logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]()
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
response = logged_in_api_client.patch(url)
assert response.status_code == 403
@ -151,7 +164,7 @@ def test_channel_views_disabled_via_feature_flag(
def test_channel_subscribe(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](artist__description=None)
url = reverse("api:v1:channels-subscribe", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-subscribe", kwargs={"composite": channel.uuid})
response = logged_in_api_client.post(url)
@ -173,7 +186,7 @@ def test_channel_unsubscribe(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]()
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
url = reverse("api:v1:channels-unsubscribe", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-unsubscribe", kwargs={"composite": channel.uuid})
response = logged_in_api_client.post(url)
@ -229,7 +242,7 @@ def test_channel_rss_feed(factories, api_client, preferences):
channel=channel, uploads=[upload2, upload1]
)
url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
response = api_client.get(url)
@ -242,7 +255,7 @@ def test_channel_rss_feed_remote(factories, api_client, preferences):
preferences["common__api_authentication_required"] = False
channel = factories["audio.Channel"]()
url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
response = api_client.get(url)
@ -253,8 +266,28 @@ def test_channel_rss_feed_authentication_required(factories, api_client, prefere
preferences["common__api_authentication_required"] = True
channel = factories["audio.Channel"](local=True)
url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
response = api_client.get(url)
assert response.status_code == 401
def test_channel_metadata_choices(factories, api_client):
expected = {
"language": [
{"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
],
"itunes_category": [
{"value": code, "label": code, "children": children}
for code, children in categories.ITUNES_CATEGORIES.items()
],
}
url = reverse("api:v1:channels-metadata_choices")
response = api_client.get(url)
assert response.status_code == 200
assert response.data == expected

View File

@ -174,6 +174,17 @@ def test_attach_file_url_fetch(factories, r_mock):
assert new_attachment.mimetype == data["mimetype"]
def test_attach_file_attachment(factories, r_mock):
album = factories["music.Album"]()
data = factories["common.Attachment"]()
utils.attach_file(album, "attachment_cover", data)
album.refresh_from_db()
assert album.attachment_cover == data
def test_attach_file_content(factories, r_mock):
album = factories["music.Album"]()

View File

@ -78,12 +78,21 @@ def test_actor_get_quota(factories):
audio_file__data=b"aaaa",
)
# this one is in a channel
channel = factories["audio.Channel"](attributed_to=library.actor)
factories["music.Upload"](
library=channel.library,
import_status="finished",
audio_file__from_path=None,
audio_file__data=b"aaaaa",
)
expected = {
"total": 19,
"total": 24,
"pending": 1,
"skipped": 2,
"errored": 3,
"finished": 8,
"finished": 13,
"draft": 5,
}

View File

@ -117,6 +117,41 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
)
def test_inbox_follow_channel_autoapprove(factories, mocker):
mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
local_actor = factories["users.User"]().create_actor()
remote_actor = factories["federation.Actor"]()
channel = factories["audio.Channel"](attributed_to=local_actor)
ii = factories["federation.InboxItem"](actor=channel.actor)
payload = {
"type": "Follow",
"id": "https://test.follow",
"actor": remote_actor.fid,
"object": channel.actor.fid,
}
result = routes.inbox_follow(
payload,
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
)
follow = channel.actor.received_follows.latest("id")
assert result["object"] == channel.actor
assert result["related_object"] == follow
assert follow.fid == payload["id"]
assert follow.actor == remote_actor
assert follow.approved is True
mocked_outbox_dispatch.assert_called_once_with(
{"type": "Accept"}, context={"follow": follow}
)
def test_inbox_follow_library_manual_approve(factories, mocker):
mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"

View File

@ -657,6 +657,34 @@ def test_serializer_empty_fields(field_name):
assert serializer.validated_data == expected
def test_serializer_strict_mode_false():
data = {}
expected = {
"artists": [{"name": None, "mbid": None}],
"album": {
"title": "[Unknown Album]",
"mbid": None,
"release_date": None,
"artists": [],
"cover_data": None,
},
}
serializer = metadata.TrackMetadataSerializer(
data=metadata.FakeMetadata(data), context={"strict": False}
)
assert serializer.is_valid(raise_exception=True) is True
assert serializer.validated_data == expected
def test_serializer_strict_mode_true():
data = {}
serializer = metadata.TrackMetadataSerializer(
data=metadata.FakeMetadata(data), context={"strict": True}
)
with pytest.raises(metadata.serializers.ValidationError):
assert serializer.is_valid(raise_exception=True)
def test_artist_field_featuring():
data = {
"artist": "Santana feat. Chris Cornell",

View File

@ -361,6 +361,19 @@ def test_manage_upload_action_relaunch_import(factories, mocker):
assert m.call_count == 3
def test_manage_upload_action_publish(factories, mocker):
m = mocker.patch("funkwhale_api.common.utils.on_commit")
draft = factories["music.Upload"](import_status="draft")
s = serializers.UploadActionSerializer(queryset=None)
s.handle_publish(models.Upload.objects.all())
draft.refresh_from_db()
assert draft.import_status == "pending"
m.assert_any_call(tasks.process_upload.delay, upload_id=draft.pk)
def test_serialize_upload(factories):
upload = factories["music.Upload"]()
@ -511,6 +524,18 @@ def test_upload_import_metadata_serializer_full():
assert serializer.validated_data == expected
def test_upload_import_metadata_serializer_channel_checks_owned_album(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"]()
data = {"title": "hello", "album": album.pk}
serializer = serializers.ImportMetadataSerializer(
data=data, context={"channel": channel}
)
with pytest.raises(serializers.serializers.ValidationError):
serializer.is_valid(raise_exception=True)
def test_upload_with_channel_keeps_import_metadata(factories, uploaded_audio_file):
channel = factories["audio.Channel"](attributed_to__local=True)
user = channel.attributed_to.user

View File

@ -7,7 +7,9 @@ from funkwhale_api.music import serializers
def test_library_track(spa_html, no_api_auth, client, factories, settings):
upload = factories["music.Upload"](playable=True, track__disc_number=1)
upload = factories["music.Upload"](
playable=True, track__disc_number=1, track__attachment_cover=None
)
track = upload.track
url = "/library/tracks/{}".format(track.pk)

View File

@ -1014,45 +1014,73 @@ def test_get_track_from_import_metadata_with_forced_values(factories, mocker, fa
)
def test_get_track_from_import_metadata_with_forced_values_album(
factories, mocker, faker
):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
forced_values = {
"title": "Real title",
"album": album.pk,
}
upload = factories["music.Upload"](
import_metadata=forced_values, library=channel.library, track=None
)
tasks.process_upload(upload_id=upload.pk)
upload.refresh_from_db()
assert upload.import_status == "finished"
assert upload.track.title == forced_values["title"]
assert upload.track.album == album
assert upload.track.artist == channel.artist
def test_process_channel_upload_forces_artist_and_attributed_to(
factories, mocker, faker
):
track = factories["music.Track"]()
channel = factories["audio.Channel"]()
channel = factories["audio.Channel"](attributed_to__local=True)
attachment = factories["common.Attachment"](actor=channel.attributed_to)
import_metadata = {
"title": "Real title",
"position": 3,
"copyright": "Real copyright",
"tags": ["hello", "world"],
"description": "my description",
"cover": attachment.uuid,
}
expected_forced_values = import_metadata.copy()
expected_forced_values["artist"] = channel.artist
expected_forced_values["attributed_to"] = channel.attributed_to
expected_forced_values["cover"] = attachment
upload = factories["music.Upload"](
track=None, import_metadata=import_metadata, library=channel.library
)
get_track_from_import_metadata = mocker.patch.object(
tasks, "get_track_from_import_metadata", return_value=track
)
get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
tasks.process_upload(upload_id=upload.pk)
upload.refresh_from_db()
serializer = tasks.metadata.TrackMetadataSerializer(
data=tasks.metadata.Metadata(upload.get_audio_file())
)
assert serializer.is_valid() is True
audio_metadata = serializer.validated_data
expected_final_metadata = tasks.collections.ChainMap(
{"upload_source": None}, audio_metadata, {"funkwhale": {}},
{"upload_source": None}, expected_forced_values, {"funkwhale": {}},
)
assert upload.import_status == "finished"
get_track_from_import_metadata.assert_called_once_with(
expected_final_metadata, **expected_forced_values
expected_final_metadata,
attributed_to=channel.attributed_to,
**expected_forced_values
)
assert upload.track.description.content_type == "text/markdown"
assert upload.track.description.text == import_metadata["description"]
assert upload.track.title == import_metadata["title"]
assert upload.track.position == import_metadata["position"]
assert upload.track.copyright == import_metadata["copyright"]
assert upload.track.get_tags() == import_metadata["tags"]
assert upload.track.artist == channel.artist
assert upload.track.attributed_to == channel.attributed_to
assert upload.track.attachment_cover == attachment
def test_process_upload_uses_import_metadata_if_valid(factories, mocker):
track = factories["music.Track"]()

View File

@ -723,6 +723,7 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f
"source": "upload://test",
"import_reference": "test",
"library": library.uuid,
"import_metadata": '{"title": "foo"}',
},
)
@ -735,6 +736,38 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f
assert upload.source == "upload://test"
assert upload.import_reference == "test"
assert upload.import_status == "pending"
assert upload.import_metadata == {"title": "foo"}
assert upload.track is None
m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk)
def test_user_can_create_upload_in_channel(
logged_in_api_client, factories, mocker, audio_file
):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor)
url = reverse("api:v1:uploads-list")
m = mocker.patch("funkwhale_api.common.utils.on_commit")
album = factories["music.Album"](artist=channel.artist)
response = logged_in_api_client.post(
url,
{
"audio_file": audio_file,
"source": "upload://test",
"import_reference": "test",
"channel": channel.uuid,
"import_metadata": '{"title": "foo", "album": ' + str(album.pk) + "}",
},
)
assert response.status_code == 201
upload = channel.library.uploads.latest("id")
assert upload.source == "upload://test"
assert upload.import_reference == "test"
assert upload.import_status == "pending"
assert upload.import_metadata == {"title": "foo", "album": album.pk}
assert upload.track is None
m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk)
@ -1318,3 +1351,106 @@ def test_detail_includes_description_key(
response = logged_in_api_client.get(url)
assert response.data["description"] is None
def test_channel_owner_can_create_album(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor)
attachment = factories["common.Attachment"](actor=actor)
url = reverse("api:v1:albums-list")
data = {
"artist": channel.artist.pk,
"cover": attachment.uuid,
"title": "Hello world",
"release_date": "2019-01-02",
"tags": ["Hello", "World"],
"description": {"content_type": "text/markdown", "text": "hello world"},
}
response = logged_in_api_client.post(url, data, format="json")
assert response.status_code == 201
album = channel.artist.albums.get(title=data["title"])
assert (
response.data
== serializers.AlbumSerializer(album, context={"description": True}).data
)
assert album.attachment_cover == attachment
assert album.attributed_to == actor
assert album.release_date == datetime.date(2019, 1, 2)
assert album.get_tags() == ["Hello", "World"]
assert album.description.content_type == "text/markdown"
assert album.description.text == "hello world"
def test_channel_owner_can_delete_album(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor)
album = factories["music.Album"](artist=channel.artist)
url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
with pytest.raises(album.DoesNotExist):
album.refresh_from_db()
def test_other_user_cannot_create_album(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]()
attachment = factories["common.Attachment"](actor=actor)
url = reverse("api:v1:albums-list")
data = {
"artist": channel.artist.pk,
"cover": attachment.uuid,
"title": "Hello world",
"release_date": "2019-01-02",
"tags": ["Hello", "World"],
"description": {"content_type": "text/markdown", "text": "hello world"},
}
response = logged_in_api_client.post(url, data, format="json")
assert response.status_code == 400
def test_other_user_cannot_delete_album(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
response = logged_in_api_client.delete(url)
assert response.status_code == 404
album.refresh_from_db()
def test_channel_owner_can_delete_track(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor)
track = factories["music.Track"](artist=channel.artist)
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
with pytest.raises(track.DoesNotExist):
track.refresh_from_db()
def test_other_user_cannot_delete_track(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]()
track = factories["music.Track"](artist=channel.artist)
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
response = logged_in_api_client.delete(url)
assert response.status_code == 404
track.refresh_from_db()

View File

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

View File

@ -1,5 +1,5 @@
<template>
<div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}]">
<div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}, `is-${ $store.getters['ui/windowSize']}`]">
<!-- here, we display custom stylesheets, if any -->
<link
v-for="url in customStylesheets"
@ -33,6 +33,7 @@
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
></app-footer>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
<channel-upload-modal v-if="$store.state.auth.authenticated"></channel-upload-modal>
<filter-modal v-if="$store.state.auth.authenticated"></filter-modal>
<report-modal></report-modal>
<shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
@ -57,6 +58,7 @@ export default {
Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"),
Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"),
PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"),
ChannelUploadModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/channels/UploadModal"),
Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"),
AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"),
ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"),
@ -393,6 +395,13 @@ export default {
top: 0;
}
}
.dimmed {
.ui.bottom-player {
@include media("<desktop") {
z-index: 0;
}
}
}
#app.queue-focused {
.queue-not-focused {
@include media("<desktop") {

View File

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

View File

@ -35,7 +35,7 @@
</form>
</div>
<div class="actions">
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
</div>
</modal>
</template>

View File

@ -36,7 +36,7 @@
</div>
</section>
<footer class="actions">
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div>
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div>
</footer>
</modal>
</template>

View File

@ -1,13 +1,13 @@
<template>
<div class="card app-card">
<div
@click="$router.push({name: 'channels.detail', params: {id: object.uuid}})"
:class="['ui', 'head-image', 'padded image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
@click="$router.push({name: 'channels.detail', params: {id: urlId}})"
:class="['ui', 'head-image', {'circular': object.artist.content_category != 'podcast'}, {'padded': object.artist.content_category === 'podcast'}, 'image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object.artist"></play-button>
</div>
<div class="content">
<strong>
<router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: object.uuid}}">
<router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: urlId}}">
{{ object.artist.name }}
</router-link>
</strong>
@ -49,6 +49,15 @@ export default {
return null
}
return url
},
urlId () {
if (this.object.actor && this.object.actor.is_local) {
return this.object.actor.preferred_username
} else if (this.object.actor) {
return this.object.actor.full_username
} else {
return this.object.uuid
}
}
}
}

View File

@ -1,6 +1,6 @@
<template>
<div class="channel-entry-card">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover && cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png">
<div class="content">
<strong>
@ -39,6 +39,9 @@ export default {
return url
},
cover () {
if (this.entry.cover) {
return this.entry.cover
}
if (this.entry.album && this.entry.album.cover) {
return this.entry.album.cover
}

View File

@ -0,0 +1,299 @@
<template>
<form class="ui form" @submit.prevent.stop="submit">
<div v-if="errors.length > 0" class="ui negative message">
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while saving channel</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<template v-if="metadataChoices">
<div v-if="creating && step === 1" class="ui grouped channel-type required field">
<label>
<translate translate-context="Content/Channel/Paragraph">What this channel will be used for?</translate>
</label>
<div class="ui hidden divider"></div>
<div class="field">
<div :class="['ui', 'radio', 'checkbox', {selected: choice.value == newValues.content_category}]" v-for="choice in categoryChoices">
<input type="radio" name="channel-category" :id="`category-${choice.value}`" :value="choice.value" v-model="newValues.content_category">
<label :for="`category-${choice.value}`">
<span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]"></span>
<strong>{{ choice.label }}</strong>
<div class="ui small hidden divider"></div>
{{ choice.helpText }}
</label>
</div>
</div>
</div>
<template v-if="!creating || step === 2">
<div class="ui required field">
<label for="channel-name">
<translate translate-context="Content/Channel/*">Name</translate>
</label>
<input type="text" required v-model="newValues.name" :placeholder="labels.namePlaceholder">
</div>
<div class="ui required field">
<label for="channel-username">
<translate translate-context="Content/Channel/*">Social Network Name</translate>
</label>
<div class="ui left labeled input">
<div class="ui basic label">@</div>
<input type="text" :required="creating" :disabled="!creating" :placeholder="labels.usernamePlaceholder" v-model="newValues.username">
</div>
<template v-if="creating">
<div class="ui small hidden divider"></div>
<p>
<translate translate-context="Content/Channels/Paragraph">Used in URLs and to follow this channel on the federation. You cannot change it afterwards.</translate>
</p>
</template>
</div>
<div class="six wide column">
<attachment-input
v-model="newValues.cover"
:required="false"
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
@delete="newValues.cover = null">
<translate translate-context="Content/Channel/*" slot="label">Channel Picture</translate>
</attachment-input>
</div>
<div class="ui small hidden divider"></div>
<div class="ui stackable grid row">
<div class="ten wide column">
<div class="ui field">
<label for="channel-tags">
<translate translate-context="*/*/*">Tags</translate>
</label>
<tags-selector
v-model="newValues.tags"
id="channel-tags"
:required="false"></tags-selector>
</div>
</div>
<div class="six wide column" v-if="newValues.content_category === 'podcast'">
<div class="ui required field">
<label for="channel-language">
<translate translate-context="*/*/*">Language</translate>
</label>
<select
name="channel-language"
id="channel-language"
v-model="newValues.metadata.language"
required
class="ui search selection dropdown">
<option v-for="v in metadataChoices.language" :value="v.value">{{ v.label }}</option>
</select>
</div>
</div>
</div>
<div class="ui small hidden divider"></div>
<div class="ui field">
<label for="channel-name">
<translate translate-context="*/*/*">Description</translate>
</label>
<content-form v-model="newValues.description"></content-form>
</div>
<div class="ui two fields" v-if="newValues.content_category === 'podcast'">
<div class="ui required field">
<label for="channel-itunes-category">
<translate translate-context="*/*/*">Category</translate>
</label>
<select
name="itunes-category"
id="itunes-category"
v-model="newValues.metadata.itunes_category"
required
class="ui dropdown">
<option v-for="v in metadataChoices.itunes_category" :value="v.value">{{ v.label }}</option>
</select>
</div>
<div class="ui field">
<label for="channel-itunes-category">
<translate translate-context="*/*/*">Subcategory</translate>
</label>
<select
name="itunes-category"
id="itunes-category"
v-model="newValues.metadata.itunes_subcategory"
:disabled="!newValues.metadata.itunes_category"
class="ui dropdown">
<option v-for="v in itunesSubcategories" :value="v">{{ v }}</option>
</select>
</div>
</div>
</template>
</template>
<div v-else class="ui active inverted dimmer">
<div class="ui text loader">
<translate translate-context="*/*/*">Loading</translate>
</div>
</div>
</form>
</template>
<script>
import axios from 'axios'
import AttachmentInput from '@/components/common/AttachmentInput'
import TagsSelector from '@/components/library/TagsSelector'
function slugify(text) {
return text.toString().toLowerCase()
.replace(/\s+/g, '') // Remove spaces
.replace(/[^\w]+/g, '') // Remove all non-word chars
}
export default {
props: {
object: {type: Object, required: false, default: null},
step: {type: Number, required: false, default: 1},
},
components: {
AttachmentInput,
TagsSelector
},
created () {
this.fetchMetadataChoices()
},
data () {
let oldValues = {}
if (this.object) {
oldValues.metadata = {...(this.object.metadata || {})}
oldValues.name = this.object.artist.name
oldValues.description = this.object.artist.description
oldValues.cover = this.object.artist.cover
oldValues.tags = this.object.artist.tags
oldValues.content_category = this.object.artist.content_category
oldValues.username = this.object.actor.preferred_username
}
return {
isLoading: false,
errors: [],
metadataChoices: null,
newValues: {
name: oldValues.name || "",
username: oldValues.username || "",
tags: oldValues.tags || [],
description: (oldValues.description || {}).text || "",
cover: (oldValues.cover || {}).uuid || null,
content_category: oldValues.content_category || "podcast",
metadata: oldValues.metadata || {},
}
}
},
computed: {
creating () {
return this.object === null
},
categoryChoices () {
return [
{
value: "podcast",
label: this.$pgettext('*/*/*', "Podcasts"),
helpText: this.$pgettext('Content/Channels/Help', "Host your episodes and keep your community updated."),
},
{
value: "music",
label: this.$pgettext('*/*/*', "Artist discography"),
helpText: this.$pgettext('Content/Channels/Help', "Publish music you make as a nice discography of albums and singles."),
}
]
},
itunesSubcategories () {
for (let index = 0; index < this.metadataChoices.itunes_category.length; index++) {
const element = this.metadataChoices.itunes_category[index];
if (element.value === this.newValues.metadata.itunes_category) {
return element.children || []
}
}
return []
},
labels () {
return {
namePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "Awesome channel name"),
usernamePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "awesomechannelname"),
}
},
submittable () {
let v = this.newValues.name && this.newValues.username
if (this.newValues.content_category === 'podcast') {
v = v && this.newValues.metadata.itunes_category && this.newValues.metadata.language
}
return !!v
}
},
methods: {
fetchMetadataChoices () {
let self = this
axios.get('channels/metadata-choices').then((response) => {
self.metadataChoices = response.data
}, error => {
self.errors = error.backendErrors
})
},
submit () {
this.isLoading = true
let self = this
let handler = this.creating ? axios.post : axios.patch
let url = this.creating ? `channels/` : `channels/${this.object.uuid}`
let payload = {
name: this.newValues.name,
username: this.newValues.username,
tags: this.newValues.tags,
content_category: this.newValues.content_category,
cover: this.newValues.cover,
metadata: this.newValues.metadata,
}
if (this.newValues.description) {
payload.description = {
content_type: 'text/markdown',
text: this.newValues.description,
}
} else {
payload.description = null
}
handler(url, payload).then((response) => {
self.isLoading = false
if (self.creating) {
self.$emit('created', response.data)
} else {
self.$emit('updated', response.data)
}
}, error => {
self.isLoading = false
self.errors = error.backendErrors
self.$emit('errored', self.errors)
})
}
},
watch: {
"newValues.name" (v) {
if (this.creating) {
this.newValues.username = slugify(v)
}
},
"newValues.metadata.itunes_category" (v) {
this.newValues.metadata.itunes_subcategory = null
},
"newValues.content_category": {
handler (v) {
this.$emit("category", v)
},
immediate: true
},
isLoading: {
handler (v) {
this.$emit("loading", v)
},
immediate: true
},
submittable: {
handler (v) {
this.$emit("submittable", v)
},
immediate: true
},
}
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}]">
<span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button']">
<button
v-if="!dropdownOnly"
:title="labels.playNow"
@ -102,7 +102,9 @@ export default {
}
if (this.track) {
return this.track.uploads && this.track.uploads.length > 0
} else if (this.artist) {
} else if (this.artist && this.artist.tracks_count) {
return this.artist.tracks_count > 0
} else if (this.artist && this.artist.albums) {
return this.artist.albums.filter((a) => {
return a.is_playable === true
}).length > 0
@ -189,10 +191,10 @@ export default {
resolve(tracks)
})
} else if (self.artist) {
let params = {'artist': self.artist.id, 'ordering': 'album__release_date,disc_number,position'}
let params = {'artist': self.artist.id, include_channels: 'true', 'ordering': 'album__release_date,disc_number,position'}
self.getTracksPage(1, params, resolve)
} else if (self.album) {
let params = {'album': self.album.id, 'ordering': 'disc_number,position'}
let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'}
self.getTracksPage(1, params, resolve)
}
})
@ -255,9 +257,27 @@ export default {
// works as expected
self.$refs[$el.data('ref')].click()
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
},
})
jQuery(this.$el).find('.ui.dropdown').dropdown('show', function () {
// little magic to ensure the menu is always visible in the viewport
// By default, try to diplay it on the right if there is enough room
let menu = jQuery(self.$el).find('.ui.dropdown').find(".menu")
let viewportOffset = menu.get(0).getBoundingClientRect();
let left = viewportOffset.left;
let viewportWidth = document.documentElement.clientWidth
let rightOverflow = viewportOffset.right - viewportWidth
let leftOverflow = -viewportOffset.left
let offset = 0
if (rightOverflow > 0) {
offset = -rightOverflow - 5
menu.css({cssText: `left: ${offset}px !important;`});
}
else if (leftOverflow > 0) {
offset = leftOverflow + 5
menu.css({cssText: `right: -${offset}px !important;`});
}
})
jQuery(this.$el).find('.ui.dropdown').dropdown('show')
})
}
}

View File

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

View File

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

View File

@ -0,0 +1,71 @@
<template>
<form @submit.stop.prevent :class="['ui', {loading: isLoading}, 'form']">
<div v-if="errors.length > 0" class="ui negative message">
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while creating</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="ui required field">
<label for="album-title">
<translate translate-context="*/*/*/Noun">Title</translate>
</label>
<input type="text" v-model="values.title">
</div>
</div>
</form>
</template>
<script>
import axios from 'axios'
export default {
props: {
channel: {type: Object, required: true},
},
components: {},
data () {
return {
errors: [],
isLoading: false,
values: {
title: "",
},
}
},
computed: {
submittable () {
return this.values.title.length > 0
}
},
methods: {
submit () {
let self = this
self.isLoading = true
self.errors = []
let payload = {
...this.values,
artist: this.channel.artist.id,
}
return axios.post('albums/', payload).then(
response => {
self.isLoading = false
self.$emit("created")
},
error => {
self.errors = error.backendErrors
self.isLoading = false
}
)
}
},
watch: {
submittable (v) {
this.$emit("submittable", v)
},
isLoading (v) {
this.$emit("loading", v)
}
}
}
</script>

View File

@ -0,0 +1,48 @@
<template>
<modal class="small" :show.sync="show">
<div class="header">
<translate key="1" v-if="channel.content_category === 'podcasts'" translate-context="Popup/Channels/Title/Verb">New serie</translate>
<translate key="2" v-else translate-context="Popup/Channels/Title">New album</translate>
</div>
<div class="scrolling content">
<channel-album-form
ref="albumForm"
@loading="isLoading = $event"
@submittable="submittable = $event"
@created="$emit('created', $event)"
:channel="channel"></channel-album-form>
</div>
<div class="actions">
<button class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button>
<button :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="!submittable" @click.stop.prevent="$refs.albumForm.submit()">
<translate translate-context="*/*/Button.Label">Create</translate>
</button>
</div>
</modal>
</template>
<script>
import Modal from '@/components/semantic/Modal'
import ChannelAlbumForm from '@/components/channels/AlbumForm'
export default {
props: ['channel'],
components: {
Modal,
ChannelAlbumForm
},
data () {
return {
isLoading: false,
submittable: false,
show: false,
}
},
watch: {
show () {
this.isLoading = false
this.submittable = false
}
}
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<div>
<label for="album-dropdown">
<translate v-if="channel && channel.artist.content_category === 'podcast'" key="1" translate-context="*/*/*">Serie</translate>
<translate v-else key="2" translate-context="*/*/*">Album</translate>
</label>
<select id="album-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown">
<option value="">
<translate translate-context="*/*/*">None</translate>
</option>
<option v-for="album in albums" :key="album.id" :value="album.id">
{{ album.title }} (<translate translate-context="*/*/*" :translate-params="{count: album.tracks.length}" :translate-n="album.tracks.length" translate-plural="%{ count } tracks">%{ count } track</translate>)
</option>
</select>
</div>
</template>
<script>
import axios from 'axios'
export default {
props: ['value', 'channel'],
data () {
return {
albums: [],
isLoading: false,
}
},
async created () {
await this.fetchData()
},
methods: {
async fetchData () {
this.albums = []
if (!this.channel) {
return
}
this.isLoading = true
let response = await axios.get('albums/', {params: {artist: this.channel.artist.id, include_channels: 'true'}})
this.albums = response.data.results
this.isLoading = false
},
},
watch: {
async channel () {
await this.fetchData()
}
}
}
</script>

View File

@ -0,0 +1,69 @@
<template>
<div>
<label for="license-dropdown">
<translate translate-context="Content/*/*/Noun">License</translate>
</label>
<select id="license-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown">
<option value="">
<translate translate-context="*/*/*">None</translate>
</option>
<option v-for="l in featuredLicenses" :key="l.code" :value="l.code">{{ l.name }}</option>
</select>
<p class="help" v-if="value">
<div class="ui very small hidden divider"></div>
<a :href="currentLicense.url" v-if="value" target="_blank" rel="noreferrer noopener">
<translate translate-context="Content/*/*">About this license</translate>
</a>
</p>
</div>
</template>
<script>
import axios from 'axios'
export default {
props: ['value'],
data () {
return {
availableLicenses: [],
featuredLicensesIds: [
'cc0-1.0',
'cc-by-4.0',
'cc-by-sa-4.0',
'cc-by-nc-4.0',
'cc-by-nc-sa-4.0',
'cc-by-nc-nd-4.0',
'cc-by-nd-4.0',
],
isLoading: false,
}
},
async created () {
await this.fetchLicenses()
},
computed: {
featuredLicenses () {
let self = this
return this.availableLicenses.filter((l) => {
return self.featuredLicensesIds.indexOf(l.code) > -1
})
},
currentLicense () {
let self = this
if (this.value) {
return this.availableLicenses.filter((l) => {
return l.code === self.value
})[0]
}
}
},
methods: {
async fetchLicenses () {
this.isLoading = true
let response = await axios.get('licenses/')
this.availableLicenses = response.data.results
this.isLoading = false
},
},
}
</script>

View File

@ -0,0 +1,528 @@
<template>
<form @submit.stop.prevent :class="['ui', {loading: isLoadingStep1}, 'form']">
<div v-if="errors.length > 0" class="ui negative message">
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while publishing</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div :class="['ui', 'required', {hidden: step > 1}, 'field']">
<label for="channel-dropdown">
<translate translate-context="*/*/*">Channel</translate>
</label>
<div id="channel-dropdown" class="ui search normal selection dropdown">
<div class="text"></div>
<i class="dropdown icon"></i>
</div>
</div>
<album-select v-model.number="values.album" :channel="selectedChannel" :class="['ui', {hidden: step > 1}, 'field']"></album-select>
<license-select v-model="values.license" :class="['ui', {hidden: step > 1}, 'field']"></license-select>
<div :class="['ui', {hidden: step > 1}, 'message']">
<div class="content">
<p>
<i class="copyright icon"></i>
<translate translate-context="Content/Channels/Popup.Paragraph">Add a license to your upload to ensure some freedoms to your public.</translate>
</p>
</div>
</div>
<template v-if="step >= 2 && step < 4">
<div class="ui warning message" v-if="remainingSpace === 0">
<div class="content">
<p>
<i class="warning icon"></i>
<translate translate-context="Content/Library/Paragraph">You don't have any space left to upload your files. Please contact the moderators.</translate>
</p>
</div>
</div>
<template v-else>
<div class="ui visible info message" v-if="step === 2 && draftUploads && draftUploads.length > 0 && includeDraftUploads === null">
<p>
<i class="redo icon"></i>
<translate translate-context="Popup/Channels/Paragraph">You have some draft uploads pending publication.</translate>
</p>
<button @click.stop.prevent="includeDraftUploads = false" class="ui basic button">
<translate translate-context="*/*/*">Ignore</translate>
</button>
<button @click.stop.prevent="includeDraftUploads = true" class="ui basic button">
<translate translate-context="*/*/*">Resume</translate>
</button>
</div>
<div v-if="uploadedFiles.length > 0" :class="[{hidden: step === 3}]">
<div class="channel-file" v-for="(file, idx) in uploadedFiles">
<div class="content">
<div role="button"
v-if="file.response.uuid"
@click.stop.prevent="selectedUploadId = file.response.uuid"
class="ui basic icon button"
:title="labels.editTitle">
<i class="pencil icon"></i>
</div>
<div
v-if="file.error"
@click.stop.prevent="selectedUploadId = file.response.uuid"
class="ui basic red icon label"
:title="file.error">
<i class="warning sign icon"></i>
</div>
<div v-else-if="file.active" class="ui active slow inline loader"></div>
</div>
<h4 class="ui header">
<template v-if="file.metadata.title">
{{ file.metadata.title }}
</template>
<template v-else>
{{ file.name }}
</template>
<div class="sub header">
<template v-if="file.response.uuid">
{{ file.size | humanSize }}
<template v-if="file.response.duration">
· <human-duration :duration="file.response.duration"></human-duration>
</template>
</template>
<template v-else>
<translate key="1" v-if="file.active" translate-context="Channels/*/*">Uploading</translate>
<translate key="2" v-else-if="file.error" translate-context="Channels/*/*">Errored</translate>
<translate key="3" v-else translate-context="Channels/*/*">Pending</translate>
· {{ file.size | humanSize }}
· {{ parseInt(file.progress) }}%
</template>
· <a @click.stop.prevent="remove(file)">
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
</a>
<template v-if="file.error"> ·
<a @click.stop.prevent="retry(file)">
<translate translate-context="*/*/*">Retry</translate>
</a>
</template>
</div>
</h4>
</div>
</div>
<upload-metadata-form
:key="selectedUploadId"
v-if="selectedUpload"
:upload="selectedUpload"
:values="uploadImportData[selectedUploadId]"
@values="setDynamic('uploadImportData', selectedUploadId, $event)"></upload-metadata-form>
<div class="ui message" v-if="step === 2">
<div class="content">
<p>
<i class="info icon"></i>
<translate translate-context="Content/Library/Paragraph" :translate-params="{extensions: $store.state.ui.supportedExtensions.join(', ')}">Supported extensions: %{ extensions }</translate>
</p>
</div>
</div>
<file-upload-widget
:class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]"
:post-action="uploadUrl"
:multiple="true"
:data="baseImportMetadata"
:drop="true"
:extensions="$store.state.ui.supportedExtensions"
:value="files"
@input="updateFiles"
name="audio_file"
:thread="1"
@input-file="inputFile"
ref="upload">
<div>
<i class="upload icon"></i>&nbsp;
<translate translate-context="Content/Channels/Paragraph">Drag and drop your files here or open the browser to upload your files</translate>
</div>
<div class="ui very small divider"></div>
<div>
<translate translate-context="*/*/*">Browse</translate>
</div>
</file-upload-widget>
<div class="ui hidden divider"></div>
</template>
</template>
</form>
</template>
<script>
import axios from 'axios'
import $ from 'jquery'
import LicenseSelect from '@/components/channels/LicenseSelect'
import AlbumSelect from '@/components/channels/AlbumSelect'
import FileUploadWidget from "@/components/library/FileUploadWidget";
import UploadMetadataForm from '@/components/channels/UploadMetadataForm'
function setIfEmpty (obj, k, v) {
if (obj[k] != undefined) {
return
}
obj[k] = v
}
export default {
props: {
channel: {type: Object, default: null, required: false},
},
components: {
AlbumSelect,
LicenseSelect,
FileUploadWidget,
UploadMetadataForm,
},
data () {
return {
availableChannels: {
results: [],
count: 0,
},
audioMetadata: {},
uploadData: {},
uploadImportData: {},
draftUploads: null,
files: [],
errors: [],
removed: [],
includeDraftUploads: null,
uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
quotaStatus: null,
isLoadingStep1: true,
step: 1,
values: {
channel: (this.channel || {}).uuid,
license: null,
album: null,
},
selectedUploadId: null,
}
},
async created () {
this.isLoadingStep1 = true
let p1 = this.fetchChannels()
await p1
this.isLoadingStep1 = false
this.fetchQuota()
},
computed: {
labels () {
return {
editTitle: this.$pgettext('Content/*/Button.Label/Verb', 'Edit'),
}
},
baseImportMetadata () {
return {
channel: this.values.channel,
import_status: 'draft',
import_metadata: {license: this.values.license, album: this.values.album || null}
}
},
remainingSpace () {
if (!this.quotaStatus) {
return 0
}
return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000)))
},
selectedChannel () {
let self = this
return this.availableChannels.results.filter((c) => {
return c.uuid === self.values.channel
})[0]
},
selectedUpload () {
let self = this
if (!this.selectedUploadId) {
return null
}
let selected = this.uploadedFiles.filter((f) => {
return f.response && f.response.uuid == self.selectedUploadId
})[0]
return {
...selected.response,
_fileObj: selected._fileObj
}
},
uploadedFilesById () {
let data = {}
this.uploadedFiles.forEach((u) => {
data[u.response.uuid] = u
})
return data
},
uploadedFiles () {
let self = this
self.uploadData
self.audioMetadata
let files = this.files.map((f) => {
let data = {
...f,
_fileObj: f,
metadata: {}
}
let metadata = {}
if (f.response && f.response.uuid) {
let uploadImportMetadata = self.uploadImportData[f.response.uuid] || self.uploadData[f.response.uuid].import_metadata
data.metadata = {
...uploadImportMetadata,
}
data.removed = self.removed.indexOf(f.response.uuid) >= 0
}
return data
})
let final = []
if (this.includeDraftUploads) {
// we have two different objects: draft uploads (so already uploaded in a previous)
// session, and files uploaded in the current session
// so we ensure we have a similar structure for both.
final = [
...this.draftUploads.map((u) => {
return {
response: u,
_fileObj: null,
size: u.size,
progress: 100,
name: u.source.replace('upload://', ''),
active: false,
removed: self.removed.indexOf(u.uuid) >= 0,
metadata: self.uploadImportData[u.uuid] || self.audioMetadata[u.uuid] || u.import_metadata
}
}),
...files
]
} else {
final = files
}
return final.filter((f) => {
return !f.removed
})
},
summaryData () {
let speed = null
let remaining = null
if (this.activeFile) {
speed = this.activeFile.speed
remaining = parseInt(this.totalSize / speed)
}
return {
totalFiles: this.uploadedFiles.length,
totalSize: this.totalSize,
uploadedSize: this.uploadedSize,
progress: parseInt(this.uploadedSize * 100 / this.totalSize),
canSubmit: !this.activeFile && this.uploadedFiles.length > 0,
speed,
remaining,
quotaStatus: this.quotaStatus,
}
},
totalSize () {
let total = 0
this.uploadedFiles.forEach((f) => {
if (!f.error) {
total += f.size
}
})
return total
},
uploadedSize () {
let uploaded = 0
this.uploadedFiles.forEach((f) => {
if (f._fileObj && !f.error) {
uploaded += f.size * (f.progress / 100)
}
})
return uploaded
},
activeFile () {
return this.files.filter((f) => {
return f.active
})[0]
}
},
methods: {
async fetchChannels () {
let response = await axios.get('channels/', {params: {scope: 'me'}})
this.availableChannels = response.data
},
async patchUpload (id, data) {
let response = await axios.patch(`uploads/${id}/`, data)
this.uploadData[id] = response.data
this.uploadImportData[id] = response.data.import_metadata
},
fetchQuota () {
let self = this
axios.get('users/users/me/').then((response) => {
self.quotaStatus = response.data.quota_status
})
},
publish () {
let self = this
self.isLoading = true
self.errors = []
let ids = this.uploadedFiles.map((f) => {
return f.response.uuid
})
let payload = {
action: 'publish',
objects: ids,
}
return axios.post('uploads/action/', payload).then(
response => {
self.isLoading = false
self.$emit("published", {
uploads: self.uploadedFiles.map((u) => {
return {
...u.response,
import_status: 'pending',
}
}),
channel: self.selectedChannel})
},
error => {
self.errors = error.backendErrors
}
)
},
setupChannelsDropdown () {
let self = this
$(this.$el).find('#channel-dropdown').dropdown({
onChange (value, text, $choice) {
self.values.channel = value
},
values: this.availableChannels.results.map((c) => {
let d = {
name: c.artist.name,
value: c.uuid,
selected: self.channel && self.channel.uuid === c.uuid,
}
if (c.artist.cover && c.artist.cover.small_square_crop) {
let coverUrl = self.$store.getters['instance/absoluteUrl'](c.artist.cover.small_square_crop)
d.image = coverUrl
if (c.artist.content_category === 'podcast') {
d.imageClass = 'ui image'
} else {
d.imageClass = "ui avatar image"
}
} else {
d.icon = "user"
if (c.artist.content_category === 'podcast') {
d.iconClass = "bordered grey icon"
} else {
d.iconClass = "circular grey icon"
}
}
return d
})
})
$(this.$el).find('#channel-dropdown').dropdown('hide')
},
inputFile(newFile, oldFile) {
if (!newFile) {
return
}
if (this.remainingSpace < newFile.size / (1000 * 1000)) {
newFile.error = 'denied'
} else {
this.$refs.upload.active = true;
}
},
fetchAudioMetadata (uuid) {
let self = this
self.audioMetadata[uuid] = null
axios.get(`uploads/${uuid}/audio-file-metadata/`).then((response) => {
self.setDynamic('audioMetadata', uuid, response.data)
let uploadedFile = self.uploadedFilesById[uuid]
if (uploadedFile._fileObj && uploadedFile.response.import_metadata.title === uploadedFile._fileObj.name.replace(/\.[^/.]+$/, "") && response.data.title) {
// replace existing title deduced from file by the one in audio file metadat, if any
self.uploadImportData[uuid].title = response.data.title
} else {
setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title)
}
setIfEmpty(self.uploadImportData[uuid], 'title', response.data.title)
setIfEmpty(self.uploadImportData[uuid], 'position', response.data.position)
setIfEmpty(self.uploadImportData[uuid], 'tags', response.data.tags)
setIfEmpty(self.uploadImportData[uuid], 'description', (response.data.description || {}).text)
self.patchUpload(uuid, {import_metadata: self.uploadImportData[uuid]})
})
},
setDynamic (objName, key, data) {
// cf https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats
let newData = {}
newData[key] = data
this[objName] = Object.assign({}, this[objName], newData)
},
updateFiles (value) {
let self = this
this.files = value
this.files.forEach((f) => {
if (f.response && f.response.uuid && self.audioMetadata[f.response.uuid] === undefined) {
self.uploadData[f.response.uuid] = f.response
self.setDynamic('uploadImportData', f.response.uuid, {
...f.response.import_metadata
})
self.fetchAudioMetadata(f.response.uuid)
}
})
},
async fetchDraftUploads (channel) {
let self = this
this.draftUploads = null
let response = await axios.get('uploads', {params: {import_status: 'draft', channel: channel}})
this.draftUploads = response.data.results
this.draftUploads.forEach((u) => {
self.uploadImportData[u.uuid] = u.import_metadata
})
},
remove (file) {
if (file.response && file.response.uuid) {
axios.delete(`uploads/${file.response.uuid}/`)
this.removed.push(file.response.uuid)
} else {
this.$refs.upload.remove(file)
}
},
retry (file) {
this.$refs.upload.update(file, {error: '', progress: '0.00'})
this.$refs.upload.active = true;
}
},
watch: {
"availableChannels.results" () {
this.setupChannelsDropdown()
},
"values.channel": {
async handler (v) {
this.files = []
if (v) {
await this.fetchDraftUploads(v)
}
},
immediate: true,
},
step: {
handler (value) {
this.$emit('step', value)
if (value === 2) {
this.selectedUploadId = null
}
},
immediate: true,
},
async selectedUploadId (v, o) {
if (v) {
this.step = 3
} else {
this.step = 2
}
if (o) {
await this.patchUpload(o, {import_metadata: this.uploadImportData[o]})
}
},
summaryData: {
handler (v) {
this.$emit('status', v)
},
immediate: true,
}
}
}
</script>

View File

@ -0,0 +1,72 @@
<template>
<div :class="['ui', {loading: isLoading}, 'form']">
<div class="ui required field">
<label for="upload-title">
<translate translate-context="*/*/*/Noun">Title</translate>
</label>
<input type="text" v-model="newValues.title">
</div>
<attachment-input
v-model="newValues.cover"
:required="false"
@delete="newValues.cover = null">
<translate translate-context="Content/Channel/*" slot="label">Track Picture</translate>
</attachment-input>
<div class="ui small hidden divider"></div>
<div class="ui two fields">
<div class="ui field">
<label for="upload-tags">
<translate translate-context="*/*/*/Noun">Tags</translate>
</label>
<tags-selector
v-model="newValues.tags"
id="upload-tags"
:required="false"></tags-selector>
</div>
<div class="ui field">
<label for="upload-position">
<translate translate-context="*/*/*/Short, Noun">Position</translate>
</label>
<input type="number" min="1" step="1" v-model="newValues.position">
</div>
</div>
<div class="ui field">
<label for="upload-description">
<translate translate-context="*/*/*">Description</translate>
</label>
<content-form v-model="newValues.description" field-id="upload-description"></content-form>
</div>
</div>
</template>
<script>
import axios from 'axios'
import TagsSelector from '@/components/library/TagsSelector'
import AttachmentInput from '@/components/common/AttachmentInput'
export default {
props: ['upload', 'values'],
components: {
TagsSelector,
AttachmentInput
},
data () {
return {
newValues: {...this.values} || this.upload.import_metadata
}
},
computed: {
isLoading () {
return !!this.metadata
}
},
watch: {
newValues: {
handler (v) {
this.$emit('values', v)
},
immediate: true
},
}
}
</script>

View File

@ -0,0 +1,119 @@
<template>
<modal class="small" @update:show="update" :show="$store.state.channels.showUploadModal">
<div class="header">
<translate key="1" v-if="step === 1" translate-context="Popup/Channels/Title/Verb">Publish audio</translate>
<translate key="2" v-else-if="step === 2" translate-context="Popup/Channels/Title">Files to upload</translate>
<translate key="3" v-else-if="step === 3" translate-context="Popup/Channels/Title">Upload details</translate>
<translate key="4" v-else-if="step === 4" translate-context="Popup/Channels/Title">Processing uploads</translate>
</div>
<div class="scrolling content">
<channel-upload-form
ref="uploadForm"
@step="step = $event"
@loading="isLoading = $event"
@published="$store.commit('channels/publish', $event)"
@status="statusData = $event"
@submittable="submittable = $event"
:channel="$store.state.channels.uploadModalConfig.channel"></channel-upload-form>
</div>
<div class="actions">
<div class="left floated text left align">
<template v-if="statusData && step >= 2">
{{ statusInfo.join(' · ') }}
</template>
<div class="ui very small hidden divider"></div>
<template v-if="statusData && statusData.quotaStatus">
<translate translate-context="Content/Library/Paragraph">Remaining storage space:</translate>
{{ (statusData.quotaStatus.remaining * 1000 * 1000) - statusData.uploadedSize | humanSize }}
</template>
</div>
<div class="ui hidden clearing divider mobile-only"></div>
<button class="ui basic cancel button" v-if="step === 1"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button>
<button class="ui basic button" v-else-if="step < 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Previous step</translate></button>
<button class="ui basic button" v-else-if="step === 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Update</translate></button>
<button v-if="step === 1" class="ui primary button" @click.stop.prevent="$refs.uploadForm.step += 1">
<translate translate-context="*/*/Button.Label">Next step</translate>
</button>
<div class="ui primary buttons" v-if="step === 2">
<button
:class="['ui', 'primary button', {loading: isLoading}]"
type="submit"
:disabled="!statusData || !statusData.canSubmit"
@click.prevent.stop="$refs.uploadForm.publish">
<translate translate-context="*/Channels/Button.Label">Publish</translate>
</button>
<button class="ui floating dropdown icon button" ref="dropdown" v-dropdown :disabled="!statusData || !statusData.canSubmit">
<i class="dropdown icon"></i>
<div class="menu">
<div
role="button"
@click="update(false)"
class="basic item">
<translate translate-context="Content/*/Button.Label/Verb">Finish later</translate>
</div>
</div>
</button>
</div>
<button class="ui basic cancel button" @click="update(false)" v-if="step === 4"><translate translate-context="*/*/Button.Label/Verb">Close</translate></button>
</div>
</modal>
</template>
<script>
import Modal from '@/components/semantic/Modal'
import ChannelUploadForm from '@/components/channels/UploadForm'
import {humanSize} from '@/filters'
export default {
components: {
Modal,
ChannelUploadForm
},
data () {
return {
step: 1,
isLoading: false,
submittable: true,
statusData: null,
}
},
methods: {
update (v) {
this.$store.commit('channels/showUploadModal', {show: v})
},
},
computed: {
labels () {
return {}
},
statusInfo () {
if (!this.statusData) {
return []
}
let info = []
if (this.statusData.totalSize) {
info.push(humanSize(this.statusData.totalSize))
}
if (this.statusData.totalFiles) {
let msg = this.$npgettext('*/*/*', '%{ count } file', '%{ count } files', this.statusData.totalFiles)
info.push(
this.$gettextInterpolate(msg, {count: this.statusData.totalFiles}),
)
}
if (this.statusData.progress) {
info.push(`${this.statusData.progress}%`)
}
if (this.statusData.speed) {
info.push(`${humanSize(this.statusData.speed)}/s`)
}
return info
}
},
watch: {
'$store.state.route.path' () {
this.$store.commit('channels/showUploadModal', {show: false})
},
}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,7 @@
</div>
</div>
<div class="actions">
<div class="ui deny button">
<div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
</div>
@ -73,6 +73,18 @@
<i class="edit icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<dangerous-button
:class="['ui', {loading: isLoading}, 'item']"
v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
@confirm="remove()">
<i class="ui trash icon"></i>
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
</div>
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
</dangerous-button>
<div class="divider"></div>
<div
role="button"
@ -143,6 +155,7 @@ export default {
return {
isLoading: true,
object: null,
artist: null,
discs: [],
libraries: [],
showEmbedModal: false
@ -160,8 +173,23 @@ export default {
axios.get(url, {params: {refresh: 'true'}}).then(response => {
self.object = backend.Album.clean(response.data)
self.discs = self.object.tracks.reduce(groupByDisc, [])
axios.get(`artists/${response.data.artist.id}/`).then(response => {
self.artist = response.data
})
self.isLoading = false
})
},
remove () {
let self = this
self.isLoading = true
axios.delete(`albums/${this.object.id}`).then((response) => {
self.isLoading = false
self.$emit('deleted')
self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}})
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
},
computed: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template>
<main>
<div v-if="isLoadingTrack" class="ui vertical segment" v-title="labels.title">
<div v-if="isLoading" class="ui vertical segment" v-title="labels.title">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="track">
@ -50,7 +50,7 @@
</div>
</div>
<div class="actions">
<div class="ui deny button">
<div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
</div>
@ -89,6 +89,18 @@
<i class="edit icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<dangerous-button
:class="['ui', {loading: isLoading}, 'item']"
v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
@confirm="remove()">
<i class="ui trash icon"></i>
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this track?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Content/Moderation/Paragraph">The track will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
</div>
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
</dangerous-button>
<div class="divider"></div>
<div
role="button"
@ -151,8 +163,9 @@ export default {
data() {
return {
time,
isLoadingTrack: true,
isLoading: true,
track: null,
artist: null,
showEmbedModal: false,
libraries: []
}
@ -163,14 +176,29 @@ export default {
methods: {
fetchData() {
var self = this
this.isLoadingTrack = true
this.isLoading = true
let url = FETCH_URL + this.id + "/"
logger.default.debug('Fetching track "' + this.id + '"')
axios.get(url, {params: {refresh: 'true'}}).then(response => {
self.track = response.data
self.isLoadingTrack = false
axios.get(`artists/${response.data.artist.id}/`).then(response => {
self.artist = response.data
})
self.isLoading = false
})
},
remove () {
let self = this
self.isLoading = true
axios.delete(`tracks/${this.track.id}`).then((response) => {
self.isLoading = false
self.$emit('deleted')
self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}})
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
},
computed: {
publicLibraries () {
@ -224,7 +252,9 @@ export default {
return u
},
cover() {
return null
if (this.track.cover) {
return this.track.cover
}
},
albumUrl () {
let route = this.$router.resolve({name: 'library.albums.detail', params: {id: this.track.album.id }})
@ -235,12 +265,12 @@ export default {
return route.href
},
headerStyle() {
if (!this.cover) {
if (!this.cover || !this.cover.original) {
return ""
}
return (
"background-image: url(" +
this.$store.getters["instance/absoluteUrl"](this.cover) +
this.$store.getters["instance/absoluteUrl"](this.cover.original) +
")"
)
},

View File

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

View File

@ -58,7 +58,7 @@
<translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-if="object" key="1">Update</translate>
<translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-else key="2">Create</translate>
</button>
<dangerous-button v-if="object" class="right floated basic button" color='red' @confirm="remove">
<dangerous-button v-if="object" class="ui right floated basic red button" @confirm="remove">
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header">
<translate translate-context="Popup/Moderation/Title">Delete this moderation rule?</translate>

View File

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

View File

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

View File

@ -25,6 +25,10 @@ export default {
label: this.$pgettext('Content/Library/*', 'Skipped'),
help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries'),
},
draft: {
label: this.$pgettext('Content/Library/*/Short', 'Draft'),
help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been scheduled for processing yet'),
},
pending: {
label: this.$pgettext('Content/Library/*/Short', 'Pending'),
help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet'),

View File

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

View File

@ -57,7 +57,7 @@
</div>
</div>
<div class="actions">
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
<button
v-if="canSubmit"
:class="['ui', 'green', {loading: isLoading}, 'button']"

View File

@ -47,7 +47,7 @@
</translate>
</div>
<dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
<dangerous-button :disabled="plts.length === 0" class="ui labeled right floated yellow icon button" :action="clearPlaylist">
<i class="eraser icon"></i> <translate translate-context="*/Playlist/Button.Label/Verb">Clear playlist</translate>
<p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title" :translate-params="{playlist: playlist.name}">
Do you want to clear the playlist "%{ playlist }"?

View File

@ -25,7 +25,7 @@
:translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"><strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>.</p>
<button
@click="duplicateTrackAddConfirm(false)"
class="ui small cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
class="ui small basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</button>
<button
class="ui small green button"
@ -101,7 +101,7 @@
</div>
</div>
<div class="actions">
<div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
<div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
</div>
</modal>
</template>

View File

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

View File

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

View File

@ -19,6 +19,7 @@ import { sync } from 'vuex-router-sync'
import locales from '@/locales'
import filters from '@/filters' // eslint-disable-line
import {parseAPIErrors} from '@/utils'
import globals from '@/components/globals' // eslint-disable-line
import './registerServiceWorker'
@ -67,6 +68,7 @@ Vue.directive('title', function (el, binding) {
Vue.directive('dropdown', function (el, binding) {
jQuery(el).dropdown({
selectOnKeydown: false,
...(binding.value || {})
})
})
axios.interceptors.request.use(function (config) {
@ -127,15 +129,8 @@ axios.interceptors.response.use(function (response) {
error.backendErrors.push(error.response.data.detail)
} else {
error.rawPayload = error.response.data
for (var field in error.response.data) {
// some views (e.g. v1/playlists/{id}/add) have deeper nested data (e.g. data[field]
// is another object), so don't try to unpack non-array fields
if (error.response.data.hasOwnProperty(field) && error.response.data[field].forEach) {
error.response.data[field].forEach(e => {
error.backendErrors.push(e)
})
}
}
let parsedErrors = parseAPIErrors(error.response.data)
error.backendErrors = [...error.backendErrors, ...parsedErrors]
}
}
if (error.backendErrors.length === 0) {
@ -157,6 +152,19 @@ store.dispatch('instance/fetchFrontSettings').finally(() => {
components: { App },
created () {
APP = this
window.addEventListener('resize', this.handleResize)
this.handleResize();
},
destroyed() {
window.removeEventListener('resize', this.handleResize)
},
methods: {
handleResize() {
this.$store.commit('ui/window', {
width: window.innerWidth,
height: window.innerHeight,
})
}
},
})

View File

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

View File

@ -5,7 +5,12 @@ export default {
namespaced: true,
state: {
subscriptions: [],
count: 0
count: 0,
showUploadModal: false,
latestPublication: null,
uploadModalConfig: {
channel: null,
}
},
mutations: {
subscriptions: (state, {uuid, value}) => {
@ -24,6 +29,22 @@ export default {
reset (state) {
state.subscriptions = []
state.count = 0
},
showUploadModal (state, value) {
state.showUploadModal = value.show
if (value.config) {
state.uploadModalConfig = {
...value.config
}
}
},
publish (state, {uploads, channel}) {
state.latestPublication = {
date: new Date(),
uploads,
channel,
}
state.showUploadModal = false
}
},
getters: {

View File

@ -11,8 +11,13 @@ export default {
lastDate: new Date(),
maxMessages: 100,
messageDisplayDuration: 10000,
supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
messages: [],
theme: 'light',
window: {
height: 0,
width: 0,
},
notifications: {
inbox: 0,
pendingReviewEdits: 0,
@ -125,6 +130,33 @@ export default {
count += 1
}
return count
},
windowSize: (state, getters) => {
// IMPORTANT: if you modify these breakpoints, also modify the values in
// style/vendor/_media.scss
let width = state.window.width
let breakpoints = [
{name: 'widedesktop', width: 1200},
{name: 'desktop', width: 1024},
{name: 'tablet', width: 768},
{name: 'phone', width: 320},
]
for (let index = 0; index < breakpoints.length; index++) {
const element = breakpoints[index];
if (width >= element.width) {
return element.name
}
}
return 'phone'
},
layoutVersion: (state, getters) => {
if (['tablet', 'phone'].indexOf(getters.windowSize) > -1) {
return 'small'
} else {
return 'large'
}
}
},
mutations: {
@ -193,6 +225,9 @@ export default {
serviceWorker: (state, value) => {
state.serviceWorker = {...state.serviceWorker, ...value}
},
window: (state, value) => {
state.window = value
}
},
actions: {

View File

@ -112,7 +112,6 @@ html {
.toast-container {
bottom: $bottom-player-height + 1rem;
}
}
}
@ -141,6 +140,7 @@ html {
#app {
> .main.pusher,
> .footer {
position: relative;
@include media(">desktop") {
margin-left: $desktop-sidebar-width !important;
}
@ -263,6 +263,9 @@ a {
.segment.hidden {
display: none;
}
.hidden:not(.divider) {
display: none !important;
}
.nomargin {
margin: 0 !important;
@ -291,6 +294,9 @@ button.reset {
text-align: inherit;
}
.text.align.left {
text-align: left;
}
.ui.table > caption {
font-weight: bold;
padding: 0.5em;
@ -422,6 +428,11 @@ input + .help {
display: none !important;
}
}
.mobile-only {
@include media(">tablet") {
display: none !important;
}
}
:not(.menu) > {
a, .link {
&:not(.button):not(.list) {
@ -433,19 +444,36 @@ input + .help {
}
.ui.cards.app-cards {
$card-width: 14em;
$card-hight: 22em;
$card-height: 22em;
$small-card-width: 11em;
$small-card-height: 19em;
.app-card {
display: flex;
width: $card-width;
height: $card-hight;
width: $small-card-width;
height: $small-card-height;
font-size: 95%;
@include media(">tablet") {
font-size: 100%;
width: $card-width;
height: $card-height;
}
.content:not(.extra) {
padding: 0.5em 1em 0;
padding: 0.25em 0.5em 0;
@include media(">tablet") {
padding: 0.5em 1em 0;
}
}
.content.extra {
padding: 0.5em 1em;
padding: 0.25em 0.5em;
@include media(">tablet") {
padding: 0.5em 1em;
}
}
.head-image {
height: $card-width;
height: $small-card-width;
@include media(">tablet") {
height: $card-width;
}
background-size: cover !important;
background-position: center !important;
display: flex !important;
@ -457,9 +485,14 @@ input + .help {
&.circular {
overflow: visible;
border-radius: 50% !important;
height: $card-width - 1em;
width: $card-width - 1em;
margin: 0.5em;
width: $small-card-width - 0.5em;
height: $small-card-width - 0.5em;
margin: 0.25em;
@include media(">tablet") {
width: $card-width - 1em;
height: $card-width - 1em;
margin: 0.5em;
}
}
&.padded {
@ -543,7 +576,8 @@ input + .help {
}
}
.channel-image {
border: 1px solid rgba(0, 0, 0, 0.5);
border: 1px solid rgba(0, 0, 0, 0.15);
background-color: white;
border-radius: 0.3em;
&.large {
width: 8em !important;
@ -560,5 +594,70 @@ input + .help {
width: 100%;
}
}
.placeholder.image {
background-color: rgba(0,0,0,.08);
width: 3em;
height: 3em;
&.large {
width: 8em;
height: 8em;
}
max-width: 100%;
display: block;
&.circular {
border-radius: 50%;
}
&.static {
animation: none;
}
}
.channel-type.field .radio {
display: block;
padding: 1.5em;
&.selected {
background-color: rgba(0, 0, 0, 0.05);
}
}
.header.with-actions {
display: flex;
justify-content: space-between;
align-items: center;
.actions {
font-weight: normal;
font-size: 0.6em;
}
}
.file-uploads.channels.ui.button {
display: block;
padding: 2em 1em;
width: 100%;
box-shadow: none;
border-style: dashed !important;
border: 2px solid rgba(50, 50, 50, 0.5);
font-size: 1.2em;
padding: 0;
> div:not(.divider) {
padding: 1em;
}
}
.channel-file {
display: flex;
align-items: top;
margin-bottom: 1em;
> :first-child {
width: 3em;
}
.header {
margin: 0 1em;
.sub.header {
margin-top: 0.5em;
}
}
}
.modal > .header {
text-align: center;
}
@import "./themes/_light.scss";
@import "./themes/_dark.scss";

View File

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

View File

@ -6,3 +6,30 @@ export function setUpdate(obj, statuses, value) {
statuses[k] = value
})
}
export function parseAPIErrors(responseData, parentField) {
let errors = []
for (var field in responseData) {
if (responseData.hasOwnProperty(field)) {
let value = responseData[field]
let fieldName = lodash.startCase(field.replace('_', ' '))
if (parentField) {
fieldName = `${parentField} - ${fieldName}`
}
if (value.forEach) {
value.forEach(e => {
if (e.toLocaleLowerCase().includes('this field ')) {
errors.push(`${fieldName}: ${e}`)
} else {
errors.push(e)
}
})
} else if (typeof value === 'object') {
// nested errors
let nestedErrors = parseAPIErrors(value, fieldName)
errors = [...errors, ...nestedErrors]
}
}
}
return errors
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,60 +3,77 @@
<div v-if="isLoading" class="ui vertical segment">
<div class="ui centered active inline loader"></div>
</div>
<template v-if="object">
<div class="ui dropdown icon small basic right floated button" ref="dropdown" v-dropdown style="right: 1em; top: 1em; z-index: 5">
<i class="ellipsis vertical icon"></i>
<div class="menu">
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({account: object})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="ui head vertical stripe segment container">
<div class="ui stackable grid" v-if="object">
<div class="ui five wide column">
<div class="ui pointing dropdown icon small basic right floated button" ref="dropdown" v-dropdown="{direction: 'downward'}" style="position: absolute; right: 1em; top: 1em; z-index: 5">
<i class="ellipsis vertical icon"></i>
<div class="menu">
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({account: object})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
</div>
</div>
<div class="ui head vertical stripe segment">
<h1 class="ui center aligned icon header">
<i v-if="!object.icon" class="circular inverted user green icon"></i>
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
<div class="ellispsis content">
<div class="ui very small hidden divider"></div>
<span :title="displayName">{{ displayName }}</span>
<div class="ui very small hidden divider"></div>
<span class="ui grey tiny text" :title="object.full_username">{{ object.full_username }}</span>
</div>
<template v-if="object.full_username === $store.state.auth.fullUsername">
<div class="ui very small hidden divider"></div>
<div class="ui basic green label">
<translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
</div>
</template>
</h1>
<div class="ui container">
<div class="ui secondary pointing center aligned menu">
<router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}">
<translate translate-context="Content/Profile/Link">Overview</translate>
</router-link>
<router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}">
<translate translate-context="Content/Profile/*">Activity</translate>
</router-link>
</div>
<div class="ui hidden divider"></div>
<keep-alive>
<router-view @updated="fetch" :object="object"></router-view>
</keep-alive>
<h1 class="ui center aligned icon header">
<i v-if="!object.icon" class="circular inverted user green icon"></i>
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
<div class="ellispsis content">
<div class="ui very small hidden divider"></div>
<span :title="displayName">{{ displayName }}</span>
<div class="ui very small hidden divider"></div>
<div class="sub header ellipsis" :title="object.full_username">
{{ object.full_username }}
</div>
</div>
<template v-if="object.full_username === $store.state.auth.fullUsername">
<div class="ui very small hidden divider"></div>
<div class="ui basic green label">
<translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
</div>
</template>
</h1>
<div class="ui small hidden divider"></div>
<div v-if="$store.getters['ui/layoutVersion'] === 'large'">
<rendered-description
@updated="$emit('updated', $event)"
:content="object.summary"
:field-name="'summary'"
:update-url="`users/users/${$store.state.auth.username}/`"
:can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
</div>
</div>
<div class="ui eleven wide column">
<div class="ui head vertical stripe segment">
<div class="ui container">
<div class="ui secondary pointing center aligned menu">
<router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}">
<translate translate-context="Content/Profile/Link">Overview</translate>
</router-link>
<router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}">
<translate translate-context="Content/Profile/*">Activity</translate>
</router-link>
</div>
<div class="ui hidden divider"></div>
<keep-alive>
<router-view @updated="fetch" :object="object"></router-view>
</keep-alive>
</div>
</div>
</div>
</div>
</template>
</div>
</main>
</template>
@ -81,6 +98,10 @@ export default {
created() {
this.fetch()
},
beforeRouteUpdate (to, from, next) {
to.meta.preserveScrollPosition = true
next()
},
methods: {
fetch () {
let self = this

View File

@ -1,17 +1,23 @@
<template>
<section class="ui stackable grid">
<div class="six wide column">
<section>
<div v-if="$store.getters['ui/layoutVersion'] === 'small'">
<rendered-description
@updated="$emit('updated', $event)"
:content="object.summary"
:field-name="'summary'"
:update-url="`users/users/${$store.state.auth.username}/`"
:can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
<div class="ui hidden divider"></div>
</div>
<div class="ten wide column">
<h2 class="ui header">
<div>
<h2 class="ui with-actions header">
<translate translate-context="*/*/*">Channels</translate>
<div class="actions" v-if="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername">
<a @click.stop.prevent="showCreateModal = true">
<i class="plus icon"></i>
<translate translate-context="Content/Profile/Button">Add new</translate>
</a>
</div>
</h2>
<channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
<h2 class="ui header">
@ -21,15 +27,61 @@
<translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>
</library-widget>
</div>
<modal :show.sync="showCreateModal">
<div class="header">
<translate v-if="step === 1" key="1" translate-context="Content/Channel/*/Verb">Create channel</translate>
<translate v-else-if="category === 'podcast'" key="2" translate-context="Content/Channel/*">Podcast channel</translate>
<translate v-else key="3" translate-context="Content/Channel/*">Artist channel</translate>
</div>
<div class="scrolling content" ref="modalContent">
<channel-form
ref="createForm"
:object="null"
:step="step"
@loading="isLoading = $event"
@submittable="submittable = $event"
@category="category = $event"
@errored="$refs.modalContent.scrollTop = 0"
@created="$router.push({name: 'channels.detail', params: {id: $event.actor.preferred_username}})"></channel-form>
<div class="ui hidden divider"></div>
</div>
<div class="actions">
<div v-if="step === 1" class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
<button v-if="step > 1" class="ui basic button" @click.stop.prevent="step -= 1">
<translate translate-context="*/*/Button.Label/Verb">Previous step</translate>
</button>
<button v-if="step === 1" class="ui primary button" @click.stop.prevent="step += 1">
<translate translate-context="*/*/Button.Label">Next step</translate>
</button>
<button v-if="step === 2" :class="['ui', 'primary button', {loading: isLoading}]" type="submit" @click.prevent.stop="$refs.createForm.submit" :disabled="!submittable && !isLoading">
<translate translate-context="*/Channels/Button.Label">Create channel</translate>
</button>
</div>
</modal>
</section>
</template>
<script>
import Modal from '@/components/semantic/Modal'
import LibraryWidget from "@/components/federation/LibraryWidget"
import ChannelsWidget from "@/components/audio/ChannelsWidget"
import ChannelForm from "@/components/audio/ChannelForm"
export default {
props: ['object'],
components: {ChannelsWidget, LibraryWidget},
components: {ChannelsWidget, LibraryWidget, ChannelForm, Modal},
data () {
return {
showCreateModal: false,
isLoading: false,
submittable: false,
step: 1,
category: 'podcast',
}
}
}
</script>

View File

@ -5,8 +5,8 @@
</div>
<template v-if="object && !isLoading">
<section class="ui head vertical stripe segment container" v-title="object.artist.name">
<div class="ui stackable two column grid">
<div class="column">
<div class="ui stackable grid">
<div class="seven wide column">
<div class="ui two column grid">
<div class="column">
<img class="huge channel-image" v-if="object.artist.cover" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.medium_square_crop)">
@ -28,10 +28,48 @@
</template>
</template>
<div class="ui hidden small divider"></div>
<a :href="rssUrl" target="_blank" class="ui icon small basic button">
<a @click.stop.prevent="showSubscribeModal = true" class="ui icon small basic button">
<i class="feed icon"></i>
</a>
<div class="ui dropdown icon small basic button" ref="dropdown" v-dropdown>
<modal class="tiny" :show.sync="showSubscribeModal">
<div class="header">
<translate translate-context="Popup/Channel/Title/Verb">Subscribe to this channel</translate>
</div>
<div class="scrollable content">
<div class="description">
<template v-if="$store.state.auth.authenticated">
<h3>
<i class="user icon"></i>
<translate translate-context="Content/Channels/Header">Subscribe on Funkwhale</translate>
</h3>
<subscribe-button @subscribed="object.subscriptions_count += 1" @unsubscribed="object.subscriptions_count -= 1" :channel="object"></subscribe-button>
</template>
<template v-if="object.rss_url">
<h3>
<i class="feed icon"></i>
<translate translate-context="Content/Channels/Header">Subscribe via RSS</translate>
</h3>
<p><translate translate-context="Content/Channels/Label">Copy-paste the following URL in your favorite podcasting app:</translate></p>
<copy-input :value="object.rss_url" />
</template>
<template v-if="object.actor">
<h3>
<i class="bell icon"></i>
<translate translate-context="Content/Channels/Header">Subscribe on the Fediverse</translate>
</h3>
<p><translate translate-context="Content/Channels/Label">If you're using Mastodon or other fediverse applications, you can subscribe to this account:</translate></p>
<copy-input :value="`@${object.actor.full_username}`" />
</template>
</div>
</div>
<div class="actions">
<div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
</div>
</modal>
<div class="ui right floated pointing dropdown icon small basic button" ref="dropdown" v-dropdown="{direction: 'downward'}">
<i class="ellipsis vertical icon"></i>
<div class="menu">
<div
@ -52,27 +90,55 @@
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
<template v-if="isOwner">
<div class="divider"></div>
<div
class="item"
role="button"
@click.stop="showEditModal = true">
<translate translate-context="*/*/*/Verb">Edit</translate>
</div>
<dangerous-button
:class="['ui', {loading: isLoading}, 'item']"
v-if="object"
@confirm="remove()">
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this Channel?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Content/Moderation/Paragraph">The channel will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
</div>
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
</dangerous-button>
</template>
<template v-if="$store.state.auth.availablePermissions['library']" >
<div class="divider"></div>
<router-link class="basic item" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
</template>
</div>
</div>
</div>
</div>
<h1 class="ui header">
<div class="left aligned content ellipsis">
<div class="left aligned" :title="object.artist.name">
{{ object.artist.name }}
<div class="ui hidden very small divider"></div>
<div class="sub header">
<div class="sub header ellipsis" :title="object.actor.full_username">
{{ object.actor.full_username }}
</div>
</div>
</h1>
<div class="header-buttons">
<div class="ui buttons" v-if="isOwner">
<button class="ui basic labeled icon button" @click.prevent.stop="$store.commit('channels/showUploadModal', {show: true, config: {channel: object}})">
<i class="upload icon"></i>
<translate translate-context="Content/Channels/Button.Label/Verb">Upload</translate>
</button>
</div>
<div class="ui buttons">
<play-button :is-playable="isPlayable" class="orange" :channel="object">
<play-button :is-playable="isPlayable" class="orange" :artist="object.artist">
<translate translate-context="Content/Channels/Button.Label/Verb">Play</translate>
</play-button>
</div>
@ -90,21 +156,45 @@
</div>
</div>
<div class="actions">
<div class="ui deny button">
<div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
</div>
</modal>
<modal :show.sync="showEditModal" v-if="isOwner">
<div class="header">
<translate v-if="object.artist.content_category === 'podcast'" key="1" translate-context="Content/Channel/*">Podcast channel</translate>
<translate v-else key="2" translate-context="Content/Channel/*">Artist channel</translate>
</div>
<div class="scrolling content">
<channel-form
ref="editForm"
:object="object"
@loading="edit.isLoading = $event"
@submittable="edit.submittable = $event"
@updated="fetchData"></channel-form>
<div class="ui hidden divider"></div>
</div>
<div class="actions">
<div class="ui left floated basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
<button @click.stop="$refs.editForm.submit" :class="['ui', 'primary', 'confirm', {loading: edit.isLoading}, 'button']" :disabled="!edit.submittable">
<translate translate-context="*/Channels/Button.Label">Update channel</translate>
</button>
</div>
</modal>
</div>
<div>
<div v-if="$store.getters['ui/layoutVersion'] === 'large'">
<rendered-description
@updated="object = $event"
:content="object.artist.description"
:update-url="`channels/${object.uuid}/`"
:can-update="$store.state.auth.authenticated && object.attributed_to.full_username === $store.state.auth.fullUsername"></rendered-description>
:can-update="false"></rendered-description>
</div>
</div>
<div class="column">
<div class="nine wide column">
<div class="ui secondary pointing center aligned menu">
<router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}">
<translate translate-context="Content/Channels/Link">Overview</translate>
@ -114,9 +204,7 @@
</router-link>
</div>
<div class="ui hidden divider"></div>
<keep-alive>
<router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event" ></router-view>
</keep-alive>
<router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event"></router-view>
</div>
</div>
</section>
@ -135,6 +223,7 @@ import TagsList from "@/components/tags/List"
import ReportMixin from '@/components/mixins/Report'
import SubscribeButton from '@/components/channels/SubscribeButton'
import ChannelForm from "@/components/audio/ChannelForm"
export default {
mixins: [ReportMixin],
@ -146,7 +235,8 @@ export default {
TagsList,
ChannelEntries,
ChannelSeries,
SubscribeButton
SubscribeButton,
ChannelForm,
},
data() {
return {
@ -155,28 +245,54 @@ export default {
totalTracks: 0,
latestTracks: null,
showEmbedModal: false,
showEditModal: false,
showSubscribeModal: false,
edit: {
submittable: false,
loading: false,
}
}
},
beforeRouteUpdate (to, from, next) {
to.meta.preserveScrollPosition = true
next()
},
async created() {
await this.fetchData()
},
methods: {
async fetchData() {
var self = this
this.showEditModal = false
this.edit.isLoading = false
this.isLoading = true
let channelPromise = axios.get(`channels/${this.id}`).then(response => {
self.object = response.data
})
let tracksPromise = axios.get("tracks", {params: {channel: this.id, page_size: 1, playable: true, include_channels: true}}).then(response => {
self.totalTracks = response.data.count
let tracksPromise = axios.get("tracks", {params: {channel: response.data.uuid, page_size: 1, playable: true, include_channels: true}}).then(response => {
self.totalTracks = response.data.count
self.isLoading = false
})
})
await channelPromise
await tracksPromise
self.isLoading = false
},
remove () {
let self = this
self.isLoading = true
axios.delete(`channels/${this.object.uuid}`).then((response) => {
self.isLoading = false
self.$emit('deleted')
self.$router.push({name: 'profile.overview', params: {username: self.$store.state.auth.username}})
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
},
computed: {
labels() {
isOwner () {
return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
},
labels () {
return {
title: this.$pgettext('*/*/*', 'Channel')
}
@ -190,9 +306,6 @@ export default {
isPlayable () {
return this.totalTracks > 0
},
rssUrl () {
return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`)
}
},
watch: {
id() {

View File

@ -1,22 +1,86 @@
<template>
<section>
<channel-entries :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
<div class="ui info message" v-if="pendingUploads.length > 0">
<template v-if="isSuccessfull">
<i role="button" class="close icon" @click="pendingUploads = []"></i>
<h3 class="ui header">
<translate translate-context="Content/Channel/Header">Uploads published successfully</translate>
</h3>
<p>
<translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }}
</p>
</template>
<template v-else-if="isOver">
<h3 class="ui header">
<translate translate-context="Content/Channel/Header">Some uploads couldn't be published</translate>
</h3>
<div class="ui hidden divider"></div>
<router-link
class="ui basic button"
:to="{name: 'content.libraries.files', query: {q: 'status:skipped'}}"
v-if="skippedUploads.length > 0">
<translate translate-context="Content/Channel/Button">View skipped uploads</translate>
</router-link>
<router-link
class="ui basic button"
:to="{name: 'content.libraries.files', query: {q: 'status:errored'}}"
v-if="erroredUploads.length > 0">
<translate translate-context="Content/Channel/Button">View errored uploads</translate>
</router-link>
</template>
<template v-else>
<div class="ui inline right floated active loader"></div>
<h3 class="ui header">
<translate translate-context="Content/Channel/Header">Uploads are being processed</translate>
</h3>
<p>
<translate translate-context="Content/Channel/Paragraph">Your uploads are being processed by Funkwhale and will be live very soon.</translate>
</p>
<p>
<translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }}
</p>
</template>
</div>
<div v-if="$store.getters['ui/layoutVersion'] === 'small'">
<rendered-description
:content="object.artist.description"
:update-url="`channels/${object.uuid}/`"
:can-update="false"></rendered-description>
<div class="ui hidden divider"></div>
</div>
<channel-entries :key="String(episodesKey) + 'entries'" :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
<h2 class="ui header">
<translate translate-context="Content/Channel/Paragraph">Latest episodes</translate>
</h2>
</channel-entries>
<div class="ui hidden divider"></div>
<channel-series :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
<h2 class="ui header">
<channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters">
<h2 class="ui with-actions header">
<translate translate-context="Content/Channel/Paragraph">Series</translate>
<div class="actions" v-if="isOwner">
<a @click.stop.prevent="$refs.albumModal.show = true">
<i class="plus icon"></i>
<translate translate-context="Content/Profile/Button">Add new</translate>
</a>
</div>
</h2>
</channel-series>
<album-modal
ref="albumModal"
v-if="isOwner"
:channel="object"
@created="$refs.albumModal.show = false; seriesKey = new Date()"></album-modal>
</section>
</template>
<script>
import axios from 'axios'
import qs from 'qs'
import ChannelEntries from "@/components/audio/ChannelEntries"
import ChannelSeries from "@/components/audio/ChannelSeries"
import AlbumModal from "@/components/channels/AlbumModal"
export default {
@ -24,6 +88,107 @@ export default {
components: {
ChannelEntries,
ChannelSeries,
AlbumModal,
},
data () {
return {
seriesKey: new Date(),
episodesKey: new Date(),
pendingUploads: [],
}
},
async created () {
if (this.isOwner) {
await this.fetchPendingUploads()
this.$store.commit("ui/addWebsocketEventHandler", {
eventName: "import.status_updated",
id: "fileUploadChannel",
handler: this.handleImportEvent
});
}
},
destroyed() {
this.$store.commit("ui/removeWebsocketEventHandler", {
eventName: "import.status_updated",
id: "fileUploadChannel"
});
},
computed: {
isOwner () {
return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
},
seriesFilters () {
let filters = {artist: this.object.artist.id, ordering: '-creation_date'}
if (!this.isOwner) {
filters.playable = 'true'
}
return filters
},
processedUploads () {
return this.pendingUploads.filter((u) => {
return u.import_status != "pending"
})
},
erroredUploads () {
return this.pendingUploads.filter((u) => {
return u.import_status === "errored"
})
},
skippedUploads () {
return this.pendingUploads.filter((u) => {
return u.import_status === "skipped"
})
},
finishedUploads () {
return this.pendingUploads.filter((u) => {
return u.import_status === "finished"
})
},
pendingUploadsById () {
let d = {}
this.pendingUploads.forEach((u) => {
d[u.uuid] = u
})
return d
},
isOver () {
return this.pendingUploads && this.processedUploads.length === this.pendingUploads.length
},
isSuccessfull () {
return this.pendingUploads && this.finishedUploads.length === this.pendingUploads.length
}
},
methods: {
handleImportEvent(event) {
let self = this;
if (!this.pendingUploadsById[event.upload.uuid]) {
return;
}
Object.assign(this.pendingUploadsById[event.upload.uuid], event.upload)
},
async fetchPendingUploads () {
let response = await axios.get('uploads/', {
params: {channel: this.object.uuid, import_status: ['pending', 'skipped', 'errored'], include_channels: 'true'},
paramsSerializer: function(params) {
return qs.stringify(params, { indices: false })
}
})
this.pendingUploads = response.data.results
}
},
watch: {
"$store.state.channels.latestPublication" (v) {
if (v && v.uploads && v.channel.uuid === this.object.uuid) {
let test
this.pendingUploads = [...this.pendingUploads, ...v.uploads]
}
},
"isOver" (v) {
if (v) {
this.seriesKey = new Date()
this.episodesKey = new Date()
}
}
}
}
</script>

View File

@ -2,21 +2,39 @@
<section class="ui vertical aligned stripe segment" v-title="labels.title">
<div class="ui text container">
<h1>{{ labels.title }}</h1>
<p><translate translate-context="Content/Library/Paragraph">There are various ways to grab new content and make it available here.</translate></p>
<p>
<strong><translate translate-context="Content/Library/Paragraph" :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space for every user.</translate></strong>
</p>
<div class="ui segment">
<h2><translate translate-context="Content/Library/Title/Verb">Upload audio content</translate></h2>
<p><translate translate-context="Content/Library/Paragraph">Upload music files (MP3, OGG, FLAC, etc.) from your personal library directly from your browser to enjoy them here.</translate></p>
<h2>
<i class="feed icon"></i>&nbsp;
<translate translate-context="Content/Library/Title/Verb">Publish your work in a channel</translate>
</h2>
<p>
<strong><translate translate-context="Content/Library/Paragraph" :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space for every user.</translate></strong>
<translate translate-context="Content/Library/Paragraph">If you are a musician or a podcaster, channels are designed for you!</translate>
<translate translate-context="Content/Library/Paragraph">Share your work publicly and get subscribers on Funkwhale, the Fediverse or any podcasting application.</translate>
</p>
<router-link :to="{name: 'content.libraries.index'}" class="ui green button">
<router-link :to="{name: 'profile.overview', params: {username: $store.state.auth.username}, hash: '#channels'}" class="ui primary button">
<translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
</router-link>
</div>
<div class="ui segment">
<h2><translate translate-context="Content/Library/Title/Verb">Follow remote libraries</translate></h2>
<p><translate translate-context="Content/Library/Paragraph">You can follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner.</translate></p>
<router-link :to="{name: 'content.remote.index'}" class="ui green button">
<h2>
<i class="cloud icon"></i>&nbsp;
<translate translate-context="Content/Library/Title/Verb">Upload third-party content in a library</translate>
</h2>
<p><translate translate-context="Content/Library/Paragraph">Upload your personal music library to Funkwhale to enjoy it from anywhere and share it with friends and family.</translate></p>
<router-link :to="{name: 'content.libraries.index'}" class="ui primary button">
<translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
</router-link>
</div>
<div class="ui segment">
<h2>
<i class="download icon"></i>&nbsp;
<translate translate-context="Content/Library/Title/Verb">Follow remote libraries</translate>
</h2>
<p><translate translate-context="Content/Library/Paragraph">Follow libraries from other users to get access to new music. Public libraries can be followed immediately, while following a private library requires approval from its owner.</translate></p>
<router-link :to="{name: 'content.remote.index'}" class="ui primary button">
<translate translate-context="Content/Library/Button.Label/Verb">Get started</translate>
</router-link>
</div>

View File

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

View File

@ -26,7 +26,7 @@
<translate translate-context="Content/Library/Button.Label/Verb" v-if="library">Update library</translate>
<translate translate-context="Content/Library/Button.Label/Verb" v-else>Create library</translate>
</button>
<dangerous-button v-if="library" class="right floated basic button" color='red' @confirm="remove()">
<dangerous-button v-if="library" class="ui right floated basic red button" @confirm="remove()">
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header">
<translate translate-context="Popup/Library/Title">Delete this library?</translate>

View File

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

View File

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

View File

@ -39,7 +39,7 @@
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
</button>
<dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="labeled icon" :action="deletePlaylist">
<dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="ui labeled red icon button" :action="deletePlaylist">
<i class="trash icon"></i> <translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title/Call to action" :translate-params="{playlist: playlist.name}">
Do you want to delete the playlist "%{ playlist }"?
@ -58,7 +58,7 @@
</div>
</div>
<div class="actions">
<div class="ui deny button">
<div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
</div>

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